Skip to content

Commit c2c91e3

Browse files
Improve backend (#679)
2 parents d1fba11 + e58f757 commit c2c91e3

27 files changed

Lines changed: 1179 additions & 587 deletions

backend/poetry.lock

Lines changed: 135 additions & 34 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/projectify/lib/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
4343
kwargs.setdefault("blank", True)
4444
kwargs.setdefault("auto_now_add", True)
4545
# projectify/lib/models.py:58: error: Argument 1 to "__init__" of "Field" has incompatible type "CreationDateTimeField"; expected "Field[_ST, _GT]" [arg-type]
46-
DateTimeField[datetime.datetime].__init__(self, *args, **kwargs) # type: ignore[arg-type]
46+
DateTimeField[datetime.datetime].__init__(self, *args, **kwargs)
4747

4848
def get_internal_type(self) -> str:
4949
"""Return internal type."""
@@ -74,7 +74,7 @@ def __init__(self, *args: Any, **kwargs: Any):
7474
"""Enable auto_now=True."""
7575
kwargs.setdefault("auto_now", True)
7676
# projectify/lib/models.py:88: error: Argument 1 to "__init__" of "Field" has incompatible type "ModificationDateTimeField"; expected "Field[_ST, _GT]" [arg-type]
77-
DateTimeField[datetime.datetime].__init__(self, *args, **kwargs) # type: ignore[arg-type]
77+
DateTimeField[datetime.datetime].__init__(self, *args, **kwargs)
7878

7979
def get_internal_type(self) -> str:
8080
"""Return internal type."""

backend/projectify/storage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class LocalhostStorage(FileSystemStorage):
1515
"""Override file system storage."""
1616

1717
@cached_property
18-
def base_url(self) -> str: # type: ignore
18+
def base_url(self) -> str:
1919
"""Override base url to point to localhost."""
2020
settings = get_settings()
2121
if not settings.FRONTEND_URL:

backend/projectify/workspace/models/section.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def __str__(self) -> str:
5151

5252
def get_absolute_url(self) -> str:
5353
"""Get URL to section within project."""
54-
return f"{reverse("dashboard:projects:detail", args=(str(self.project.uuid),))}#{self.uuid}"
54+
return f"{reverse("dashboard:projects:detail", args=(str(self.project.uuid),))}#section-{self.uuid}"
5555

5656
class Meta:
5757
"""Meta."""

backend/projectify/workspace/selectors/project.py

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@
2929

3030
def project_detail_query_set(
3131
*,
32-
team_member_uuids: Optional[list[UUID]] = None,
33-
label_uuids: Optional[list[UUID]] = None,
34-
unassigned_tasks: Optional[bool] = None,
35-
unlabeled_tasks: Optional[bool] = None,
32+
filter_by_team_members: Optional[QuerySet[TeamMember]] = None,
33+
filter_by_labels: Optional[QuerySet[Label]] = None,
34+
# TODO rename to filter_by_unassigned
35+
unassigned_tasks: bool = False,
36+
# TODO rename to filter_by_unlabeled
37+
unlabeled_tasks: bool = False,
3638
task_search_query: Optional[str] = None,
3739
who: Optional[User] = None,
3840
) -> QuerySet[Project]:
@@ -46,39 +48,51 @@ def project_detail_query_set(
4648
task_count=Count("tasklabel", filter=project_not_archived),
4749
)
4850
)
51+
4952
task_q = Q()
50-
assignee_uuid = Q(assignee__uuid__in=team_member_uuids)
53+
54+
# Annotate team members in side nav with whether they're filtered or not
55+
assignee_contained = Q(assignee__in=filter_by_team_members)
5156
assignee_empty = Q(assignee__isnull=True)
52-
match team_member_uuids, unassigned_tasks:
53-
case None, None | False:
57+
team_member_is_filtered: Union[Value, Exists] = Value(False)
58+
match filter_by_team_members, unassigned_tasks:
59+
case None, False:
5460
pass
5561
case None, True:
5662
task_q = task_q & assignee_empty
57-
case uuids, None | False:
58-
task_q = task_q & assignee_uuid
59-
team_member_qs = team_member_qs.annotate(
60-
is_filtered=Q(uuid__in=uuids)
63+
case QuerySet(), False:
64+
task_q = task_q & assignee_contained
65+
team_member_is_filtered = Exists(
66+
filter_by_team_members.filter(pk=OuterRef("pk"))
6167
)
62-
case uuids, True:
63-
task_q = task_q & (assignee_uuid & assignee_empty)
64-
team_member_qs = team_member_qs.annotate(
65-
is_filtered=Q(uuid__in=uuids)
68+
case QuerySet(), True:
69+
task_q = task_q & (assignee_contained | assignee_empty)
70+
team_member_is_filtered = Exists(
71+
filter_by_team_members.filter(pk=OuterRef("pk"))
6672
)
73+
team_member_qs = team_member_qs.annotate(
74+
is_filtered=team_member_is_filtered
75+
)
6776

68-
labels_uuid = Q(labels__uuid__in=label_uuids)
77+
# Annotate labels shown in side nav with whether they're filtered or not
78+
label_contained = Q(labels__in=filter_by_labels)
6979
labels_empty = Q(labels__isnull=True)
70-
label_is_filtered: Union[Value, Q] = Value(False)
71-
match label_uuids, unlabeled_tasks:
72-
case None, None | False:
80+
label_is_filtered: Union[Value, Exists] = Value(False)
81+
match filter_by_labels, unlabeled_tasks:
82+
case None, False:
7383
pass
7484
case None, True:
7585
task_q = task_q & labels_empty
76-
case uuids, None | False:
77-
task_q = task_q & labels_uuid
78-
label_is_filtered = Q(uuid__in=uuids)
79-
case uuids, True:
80-
task_q = task_q & (labels_uuid | labels_empty)
81-
label_is_filtered = Q(uuid__in=uuids)
86+
case QuerySet(), False:
87+
task_q = task_q & label_contained
88+
label_is_filtered = Exists(
89+
filter_by_labels.filter(pk=OuterRef("pk"))
90+
)
91+
case QuerySet(), True:
92+
task_q = task_q & (label_contained | labels_empty)
93+
label_is_filtered = Exists(
94+
filter_by_labels.filter(pk=OuterRef("pk"))
95+
)
8296
label_qs = label_qs.annotate(is_filtered=label_is_filtered)
8397

8498
if task_search_query is not None:

backend/projectify/workspace/selectors/section.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,28 @@
66
from typing import Optional
77
from uuid import UUID
88

9-
from django.db.models import QuerySet
9+
from django.db.models import Prefetch, QuerySet
1010

1111
from projectify.user.models import User
12+
from projectify.workspace.models.label import Label
13+
from projectify.workspace.models.project import Project
1214
from projectify.workspace.models.section import Section
15+
from projectify.workspace.selectors.labels import labels_annotate_with_colors
1316

1417
SectionDetailQuerySet = Section.objects.prefetch_related(
1518
"task_set",
1619
"task_set__assignee",
1720
"task_set__assignee__user",
1821
"task_set__labels",
1922
"task_set__subtask_set",
23+
Prefetch(
24+
"project__workspace__project_set",
25+
queryset=Project.objects.filter(archived__isnull=True),
26+
),
27+
Prefetch(
28+
"project__workspace__label_set",
29+
queryset=labels_annotate_with_colors(Label.objects.all()),
30+
),
2031
).select_related(
2132
"project",
2233
"project__workspace",

backend/projectify/workspace/selectors/task.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313

1414
from ..models.chat_message import ChatMessage
1515
from ..models.label import Label
16+
from ..models.project import Project
1617
from ..models.task import Task
1718
from .labels import labels_annotate_with_colors
1819

1920
TaskDetailQuerySet: QuerySet[Task] = (
2021
Task.objects.select_related(
21-
"section__project__workspace",
22+
"workspace",
2223
"assignee",
2324
"assignee__user",
2425
)
@@ -31,6 +32,12 @@
3132
queryset=labels_annotate_with_colors(Label.objects.all()),
3233
),
3334
)
35+
.prefetch_related(
36+
Prefetch(
37+
"workspace__label_set",
38+
queryset=labels_annotate_with_colors(Label.objects.all()),
39+
),
40+
)
3441
.prefetch_related(
3542
Prefetch(
3643
"chatmessage_set",
@@ -40,6 +47,12 @@
4047
),
4148
),
4249
)
50+
.prefetch_related(
51+
Prefetch(
52+
"workspace__project_set",
53+
queryset=Project.objects.filter(archived__isnull=True),
54+
),
55+
)
4356
.annotate(
4457
sub_task_progress=Count(
4558
"subtask",

backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html

Lines changed: 8 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -31,84 +31,22 @@
3131
<p class=" font-bold truncate py-2">{% trans "Filter team members" %}</p>
3232
</legend>
3333
<div class="flex flex-col gap-2">
34-
<div class="flex flex-col overflow-y-auto">
35-
<div class="grid grid-cols-[auto_1fr_auto] gap-2 items-center hover:bg-background p-2">
36-
<input type="checkbox"
37-
id="{{ task_filter_form.filter_by_unassigned.id_for_label }}"
38-
name="{{ task_filter_form.filter_by_unassigned.html_name }}"
39-
value="on"
40-
{% if task_filter_form.filter_by_unassigned.data %}checked{% endif %}
41-
class="cursor-pointer" />
42-
<label for="{{ task_filter_form.filter_by_unassigned.id_for_label }}"
43-
class="flex flex-1 gap-2 text-regular min-w-0 cursor-pointer">
44-
{% user_avatar None %}
45-
<span class="truncate">{{ task_filter_form.filter_by_unassigned.label }}</span>
46-
</label>
47-
{# no count #}
48-
</div>
49-
{% for member in task_filter_form.filter_by_team_member %}
50-
<div class="grid grid-cols-[auto_1fr_auto] gap-2 items-center hover:bg-background p-2">
51-
<input type="checkbox"
52-
id="{{ member.data.attrs.id }}"
53-
name="{{ task_filter_form.filter_by_team_member.html_name }}"
54-
{% if member.data.is_filtered %}checked{% endif %}
55-
value="{{ member.data.value }}"
56-
class="cursor-pointer" />
57-
<label for="{{ member.id_for_label }}"
58-
class="flex flex-1 gap-2 text-regular min-w-0 cursor-pointer">
59-
{% user_avatar member.data.member %}
60-
<span class="truncate">{{ member.choice_label }}</span>
61-
</label>
62-
<div class="flex shrink-0 flex-row items-center gap-2 rounded-2.5xl bg-background px-2 py-0.5 text-primary group-hover:bg-foreground">
63-
{{ member.data.task_count }}
64-
</div>
65-
</div>
66-
{% endfor %}
67-
</div>
34+
<div class="flex flex-col overflow-y-auto">{{ task_filter_form.filter_by_team_member }}</div>
6835
</div>
6936
</fieldset>
7037
</div>
71-
<div class="flex flex-col px-4">
72-
<fieldset class="w-full max-w-full">
38+
<div class="flex flex-col gap-4 px-4">
39+
<fieldset class="w-full max-w-full min-w-0">
7340
<legend class="flex items-center gap-2">
7441
<div class="w-4 h-4">{% include "heroicons/tag.svg" %}</div>
7542
<p class=" font-bold truncate py-2">{% trans "Filter labels" %}</p>
7643
</legend>
77-
<div class="flex flex-col gap-2">
78-
<div class="flex flex-col overflow-y-auto">
79-
<div class="grid grid-cols-[auto_1fr_auto] gap-2 items-center hover:bg-background p-2">
80-
<input type="checkbox"
81-
id="{{ task_filter_form.filter_by_unlabeled.id_for_label }}"
82-
name="{{ task_filter_form.filter_by_unlabeled.html_name }}"
83-
{% if task_filter_form.filter_by_unlabeled.data %}checked{% endif %}
84-
value="on">
85-
<label class="flex items-center min-w-0 gap-2"
86-
for="{{ task_filter_form.filter_by_unlabeled.id_for_label }}">
87-
<div class="h-6 w-6 shrink-0 rounded-full border border-utility"></div>
88-
<div class="text-regular truncate min-w-0">{{ task_filter_form.filter_by_unlabeled.label }}</div>
89-
</label>
90-
</div>
91-
{% for label in task_filter_form.filter_by_label %}
92-
<div class="grid grid-cols-[auto_1fr_auto] gap-2 items-center hover:bg-background p-2">
93-
<input type="checkbox"
94-
id="{{ label.data.attrs.id }}"
95-
name="{{ task_filter_form.filter_by_label.html_name }}"
96-
{% if label.data.is_filtered %}checked{% endif %}
97-
value="{{ label.data.value }}">
98-
<label class="flex items-center min-w-0 gap-2"
99-
for="{{ label.id_for_label }}">
100-
<div class="h-6 w-6 shrink-0 rounded-full {{ label.data.bg_class }} border {{ label.data.border_class }}"></div>
101-
<div class="text-regular truncate min-w-0">{{ label.choice_label }}</div>
102-
</label>
103-
<div class="flex shrink-0 flex-row items-center gap-2 rounded-2.5xl bg-background px-2 py-0.5 text-primary group-hover:bg-foreground">
104-
{{ label.data.task_count }}
105-
</div>
106-
</div>
107-
{% endfor %}
108-
</div>
109-
</div>
44+
<div class="flex flex-col overflow-y-auto">{{ task_filter_form.filter_by_label }}</div>
11045
</fieldset>
46+
{% if workspace %}
47+
{% anchor "dashboard:workspaces:labels" _("Edit labels") workspace_uuid=workspace.uuid %}
48+
{% endif %}
49+
{% include "projectify/forms/submit.html" with text=_("Filter") %}
11150
</div>
112-
{% include "projectify/forms/submit.html" with text=_("Filter") %}
11351
</form>
11452
{% endif %}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{# SPDX-FileCopyrightText: 2026 JWP Consulting GK #}
2+
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
3+
{# SPDX-License-Identifier: BSD-3-Clause #}
4+
{# SPDX-FileCopyrightText: Django Software Foundation and individual contributors. #}
5+
{% if errors %}
6+
<tr>
7+
<td colspan="2">
8+
{{ errors }}
9+
{% if not fields %}
10+
{% for field in hidden_fields %}{{ field }}{% endfor %}
11+
{% endif %}
12+
</td>
13+
</tr>
14+
{% endif %}
15+
{% for field, errors in fields %}
16+
<tr class="contents">
17+
<th scope="row" class="col-span-1 text-left font-bold">
18+
{% if field.label %}{{ field.label_tag }}{% endif %}
19+
</th>
20+
<td class="col-span-3">
21+
{% if errors %}<div class="text-destructive">{{ errors }}</div>{% endif %}
22+
{{ field }}
23+
{% if field.help_text %}
24+
<br>
25+
<span class="helptext"
26+
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</span>
27+
{% endif %}
28+
{% if forloop.last %}
29+
{% for field in hidden_fields %}{{ field }}{% endfor %}
30+
{% endif %}
31+
</td>
32+
</tr>
33+
{% endfor %}
34+
{% if not fields and not errors %}
35+
{% for field in hidden_fields %}{{ field }}{% endfor %}
36+
{% endif %}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{# SPDX-FileCopyrightText: 2026 JWP Consulting GK #}
2+
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
3+
{% load projectify %}
4+
<div class="grid grid-cols-[auto_1fr_auto] gap-2 items-center hover:bg-background p-2">
5+
<input type="radio"
6+
name="{{ widget.name }}"
7+
value="{{ widget.value }}"
8+
class="cursor-pointer"
9+
{% include "django/forms/widgets/attrs.html" %}>
10+
<label for="{{ widget.attrs.id }}"
11+
class="flex flex-1 gap-2 text-regular min-w-0 cursor-pointer">
12+
{% if widget.value.instance %}
13+
{% user_avatar widget.value.instance %}
14+
{% else %}
15+
{% user_avatar None %}
16+
{% endif %}
17+
<span class="truncate">{{ widget.label }}</span>
18+
</label>
19+
{% if widget.task_count is not None %}
20+
<div class="flex shrink-0 flex-row items-center gap-2 rounded-2.5xl bg-background px-2 py-0.5 text-primary group-hover:bg-foreground">
21+
{{ widget.task_count }}
22+
</div>
23+
{% endif %}
24+
</div>

0 commit comments

Comments
 (0)