From 458a911b785afc73521fcb7f98af8979d8ec0906 Mon Sep 17 00:00:00 2001 From: KavyanshKhaitan2 Date: Fri, 18 Sep 2026 20:15:42 +0530 Subject: [PATCH 001/104] Initial pathway rework --- twisted/mysite/settings.py | 1 + .../migrations/0034_pathwaygroup.py | 141 ++++++++++++ .../migrations/0035_pathwaygroup_name.py | 24 ++ ...pathwaytimespent_golden_twists_and_more.py | 23 ++ twisted/twisted_site/models.py | 163 ++++++------- .../templates/admin/pathways/create.html | 41 ++-- .../templates/admin/pathways/detail.html | 4 + .../admin/pathways/groups/create.html | 54 +++++ .../admin/pathways/groups/detail.html | 103 +++++++++ .../templates/admin/pathways/groups/list.html | 55 +++++ .../templates/admin/pathways/list.html | 9 +- .../templates/client/pathways.html | 20 +- twisted/twisted_site/urls.py | 20 ++ twisted/twisted_site/views/admin/__init__.py | 12 +- twisted/twisted_site/views/admin/pathways.py | 215 ++++++++++++++++-- twisted/twisted_site/views/client/__init__.py | 3 +- twisted/twisted_site/views/client/pathways.py | 51 ++++- 17 files changed, 795 insertions(+), 144 deletions(-) create mode 100644 twisted/twisted_site/migrations/0034_pathwaygroup.py create mode 100644 twisted/twisted_site/migrations/0035_pathwaygroup_name.py create mode 100644 twisted/twisted_site/migrations/0036_pathwaytimespent_golden_twists_and_more.py create mode 100644 twisted/twisted_site/templates/admin/pathways/groups/create.html create mode 100644 twisted/twisted_site/templates/admin/pathways/groups/detail.html create mode 100644 twisted/twisted_site/templates/admin/pathways/groups/list.html diff --git a/twisted/mysite/settings.py b/twisted/mysite/settings.py index 42669ed..48738de 100644 --- a/twisted/mysite/settings.py +++ b/twisted/mysite/settings.py @@ -66,6 +66,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.humanize", + "django.contrib.postgres", # Your apps "common", "twisted_site", diff --git a/twisted/twisted_site/migrations/0034_pathwaygroup.py b/twisted/twisted_site/migrations/0034_pathwaygroup.py new file mode 100644 index 0000000..a2becc5 --- /dev/null +++ b/twisted/twisted_site/migrations/0034_pathwaygroup.py @@ -0,0 +1,141 @@ +import django.contrib.postgres.constraints +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def migrate_pathways_to_groups(apps, schema_editor): + """Group existing Pathways into PathwayGroups, merging any that overlap in time.""" + Pathway = apps.get_model("twisted_site", "Pathway") + PathwayGroup = apps.get_model("twisted_site", "PathwayGroup") + + clusters = [] + for pathway in Pathway.objects.order_by("start"): + target = None + for cluster in clusters: + if pathway.start < cluster["end"] and pathway.end > cluster["start"]: + target = cluster + break + if target is None: + clusters.append({"start": pathway.start, "end": pathway.end, "pathways": [pathway]}) + continue + target["start"] = min(target["start"], pathway.start) + target["end"] = max(target["end"], pathway.end) + target["pathways"].append(pathway) + + # Widening a cluster above may make it overlap another cluster that was + # already closed off; keep merging until no two clusters overlap. + merged = True + while merged: + merged = False + for i, cluster_a in enumerate(clusters): + for cluster_b in clusters[i + 1 :]: + if cluster_a["start"] < cluster_b["end"] and cluster_a["end"] > cluster_b["start"]: + cluster_a["start"] = min(cluster_a["start"], cluster_b["start"]) + cluster_a["end"] = max(cluster_a["end"], cluster_b["end"]) + cluster_a["pathways"].extend(cluster_b["pathways"]) + clusters.remove(cluster_b) + merged = True + break + if merged: + break + + for cluster in clusters: + group = PathwayGroup.objects.create(start=cluster["start"], end=cluster["end"]) + for pathway in cluster["pathways"]: + pathway.group = group + pathway.save(update_fields=["group"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("twisted_site", "0033_project_hackatime_project_names"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PathwayGroup", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("start", models.DateTimeField()), + ("end", models.DateTimeField()), + ], + options={ + "constraints": [ + models.CheckConstraint( + condition=models.Q(("start__lt", models.F("end"))), + name="pathwaygroup_start_before_end", + ), + django.contrib.postgres.constraints.ExclusionConstraint( + expressions=[ + (models.Func(models.F("start"), models.F("end"), function="tstzrange"), "&&"), + ], + name="pathwaygroup_no_overlapping_ranges", + ), + ], + }, + ), + migrations.CreateModel( + name="PathwayTimeSpent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("minutes", models.IntegerField(default=0)), + ( + "pathway", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="twisted_site.pathway"), + ), + ( + "user", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ], + options={ + "unique_together": {("pathway", "user")}, + }, + ), + migrations.AddField( + model_name="pathway", + name="group", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="pathways", + to="twisted_site.pathwaygroup", + ), + ), + migrations.RunPython(migrate_pathways_to_groups, migrations.RunPython.noop), + migrations.AlterField( + model_name="pathway", + name="group", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="pathways", + to="twisted_site.pathwaygroup", + ), + ), + migrations.RemoveField( + model_name="pathway", + name="end", + ), + migrations.RemoveField( + model_name="pathway", + name="start", + ), + ] diff --git a/twisted/twisted_site/migrations/0035_pathwaygroup_name.py b/twisted/twisted_site/migrations/0035_pathwaygroup_name.py new file mode 100644 index 0000000..96bc1eb --- /dev/null +++ b/twisted/twisted_site/migrations/0035_pathwaygroup_name.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +def backfill_group_names(apps, schema_editor): + PathwayGroup = apps.get_model("twisted_site", "PathwayGroup") + for group in PathwayGroup.objects.filter(name=""): + group.name = f"Group ({group.start.date()} - {group.end.date()})" + group.save(update_fields=["name"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("twisted_site", "0034_pathwaygroup"), + ] + + operations = [ + migrations.AddField( + model_name="pathwaygroup", + name="name", + field=models.CharField(default="", max_length=200), + preserve_default=False, + ), + migrations.RunPython(backfill_group_names, migrations.RunPython.noop), + ] diff --git a/twisted/twisted_site/migrations/0036_pathwaytimespent_golden_twists_and_more.py b/twisted/twisted_site/migrations/0036_pathwaytimespent_golden_twists_and_more.py new file mode 100644 index 0000000..853fe8d --- /dev/null +++ b/twisted/twisted_site/migrations/0036_pathwaytimespent_golden_twists_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.7 on 2026-09-18 10:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0035_pathwaygroup_name'), + ] + + operations = [ + migrations.AddField( + model_name='pathwaytimespent', + name='golden_twists', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='pathwaytimespent', + name='unlocked', + field=models.BooleanField(default=False), + ), + ] diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index 01fd746..e345651 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -1,10 +1,14 @@ -from typing import TYPE_CHECKING, Any, cast, override +from django.db.models.query import QuerySet +from typing import TYPE_CHECKING, Any, ClassVar, cast, override from django.contrib.auth import get_user_model from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.postgres.constraints import ExclusionConstraint +from django.contrib.postgres.fields import RangeOperators +from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models -from django.db.models import TextField +from django.db.models import CheckConstraint, F, Func, Q, TextField, Sum from django.utils import timezone from . import hackatime @@ -248,11 +252,48 @@ class ProjectShip(models.Model): def __str__(self) -> str: return f"Ship created at {self.created_at} ({self.get_status_display()})" # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] - -class Pathway(models.Model): +class PathwayGroup(models.Model): + name = models.CharField(max_length=200) start = models.DateTimeField() end = models.DateTimeField() + def get_unspent_mins(self, user:User) -> float: + all_journals = Journal.objects.filter(project__user=user) + timed_journals = all_journals.filter(created_at__gte=self.start, created_at__lte=self.end) + earned = timed_journals.aggregate(total=Sum("reduced_minutes"))["total"] or 0 + spent = PathwayTimeSpent.objects.filter(pathway__group=self, user=user).aggregate(total=Sum("minutes"))["total"] or 0 # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] + return earned - spent + + + class Meta: + """Meta class for the PathwayGroup model.""" + + constraints: ClassVar[list[models.BaseConstraint]] = [ + CheckConstraint(condition=Q(start__lt=F("end")), name="pathwaygroup_start_before_end"), + ExclusionConstraint( + name="pathwaygroup_no_overlapping_ranges", + expressions=[(Func(F("start"), F("end"), function="tstzrange"), RangeOperators.OVERLAPS)], + ), + ] + + @override + def __str__(self) -> str: + return cast("str", self.name) # pyrefly: ignore[redundant-cast] + + def clean(self) -> None: + super().clean() + if self.start is not None and self.end is not None and self.start >= self.end: + msg = "Start must be before end." + raise ValidationError(msg) + overlapping = PathwayGroup.objects.filter(start__lt=self.end, end__gt=self.start).exclude(pk=self.pk) + if overlapping.exists(): + msg = "This time window overlaps with an existing pathway group." + raise ValidationError(msg) + + +class Pathway(models.Model): + group = models.ForeignKey("twisted_site.PathwayGroup", on_delete=models.PROTECT, related_name="pathways") + name = models.CharField(max_length=200) min_mins = models.IntegerField(default=300) @@ -263,11 +304,19 @@ class Pathway(models.Model): def __str__(self) -> str: return cast("str", self.name) # pyrefly: ignore[redundant-cast] + @property + def start(self) -> "datetime": + return self.group.start # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-attribute] + + @property + def end(self) -> "datetime": + return self.group.end # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-attribute] + def ended(self) -> bool: - return timezone.now() > cast("datetime", self.end) # pyrefly: ignore[redundant-cast] + return timezone.now() > self.end def didnt_start(self) -> bool: - return cast("datetime", self.start) > timezone.now() # pyrefly: ignore[redundant-cast] + return self.start > timezone.now() def in_progress(self) -> bool: return not self.ended() and not self.didnt_start() @@ -282,42 +331,8 @@ def status(self) -> str | None: return None def mins_spent(self, user: AbstractBaseUser) -> int: - pathways = Pathway.objects.order_by("start").values("id", "start", "end", "min_mins") - if not pathways.exists(): - return 0 - - pathway_totals: dict[int, int] = {p["id"]: 0 for p in pathways} - - journals = ( - Journal.objects.filter(project__user=user) - .order_by("created_at") - .values_list("created_at", "reduced_minutes") - ) - - for j_created, j_mins in journals: - mins_remaining = cast("int", j_mins) - for pathway in pathways: - if mins_remaining <= 0: - break - - # Check if journal falls within the pathway window - if pathway["start"] > j_created or pathway["end"] < j_created: - continue - - p_id = cast("int", pathway["id"]) - mins_completed = pathway_totals.get(p_id, 0) - mins_required = cast("int", pathway["min_mins"]) - - if mins_completed >= mins_required: - continue - - mins_needed = mins_required - mins_completed - mins_donated = min(mins_remaining, mins_needed) - - mins_remaining -= mins_donated - pathway_totals[p_id] = mins_completed + mins_donated - - return pathway_totals[self.id] # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] + time_spent = PathwayTimeSpent.objects.filter(pathway=self, user=user).first() # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] + return time_spent.minutes if time_spent else 0 def mins_spent_per_participant(self) -> dict[int, int]: """ @@ -327,57 +342,10 @@ def mins_spent_per_participant(self) -> dict[int, int]: dict: {user_id: mins_spent} """ - # Fetch all pathways to accurately model the sequential time donation - pathways = list(Pathway.objects.order_by("start").values("id", "start", "end", "min_mins")) - if len(pathways) == 0: - return {} - - # Fetch journals from all users that fit within this pathway's active time frame - journals = ( - Journal.objects.filter( - created_at__gte=self.start, - created_at__lte=self.end, - reduced_minutes__gt=0, - ) - .order_by("project__user_id", "created_at") - .values_list("project__user_id", "created_at", "reduced_minutes") + return dict( + PathwayTimeSpent.objects.filter(pathway=self).values_list("user_id", "minutes"), # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] ) - user_pathway_totals: dict[int, dict[int, int]] = {} - - for user_id, j_created, j_mins in journals: - if user_id not in user_pathway_totals: - user_pathway_totals[user_id] = {p["id"]: 0 for p in pathways} - - pathway_totals = user_pathway_totals[user_id] - mins_remaining = cast("int", j_mins) - - for pathway in pathways: - if mins_remaining <= 0: - break - - if pathway["start"] > j_created or pathway["end"] < j_created: - continue - - p_id = cast("int", pathway["id"]) - mins_completed = pathway_totals[p_id] - mins_required = cast("int", pathway["min_mins"]) - - if mins_completed >= mins_required: - continue - - mins_needed = mins_required - mins_completed - mins_donated = min(mins_remaining, mins_needed) - - mins_remaining -= mins_donated - pathway_totals[p_id] += mins_donated - - # Extract only this pathway's result for each participant - return { - user_id: totals.get(self.id, 0) # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] - for user_id, totals in user_pathway_totals.items() - } - def qualified_participants(self) -> list[AbstractBaseUser]: per_part = self.mins_spent_per_participant() qualified: list[AbstractBaseUser] = [] @@ -387,6 +355,19 @@ def qualified_participants(self) -> list[AbstractBaseUser]: return qualified +class PathwayTimeSpent(models.Model): + pathway = models.ForeignKey("twisted_site.Pathway", on_delete=models.CASCADE) + user = models.ForeignKey(to=User, on_delete=models.CASCADE) + + unlocked = models.BooleanField(default=False) + minutes = models.IntegerField(default=0) + golden_twists = models.IntegerField(default=0) + + class Meta: #meta :loll: + """Meta class for the PathwayTimeSpent model.""" + + unique_together = ("pathway", "user") + class AuditLog(models.Model): timestamp = models.DateTimeField(auto_now_add=True) user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="audit_logs") diff --git a/twisted/twisted_site/templates/admin/pathways/create.html b/twisted/twisted_site/templates/admin/pathways/create.html index d5cbbc5..d097733 100644 --- a/twisted/twisted_site/templates/admin/pathways/create.html +++ b/twisted/twisted_site/templates/admin/pathways/create.html @@ -1,5 +1,3 @@ -{% load tz %} -{% get_current_timezone as TIMEZONE %}
@@ -18,31 +16,24 @@

Create Pathway

placeholder="CPU" required="True" value="{{ pathway_name }}" /> -
-
- - Start - -
- - -
-
- -
- - End - -
- - -
-
+
+ + {% for group in groups %} + + {{ group.name }} ({{ group.start }} – {{ group.end }}) + + {% endfor %} + + {% if not groups %} + + No pathway groups exist yet. + + {% endif %}
- - Start/End is in {{ TIMEZONE }} (your local time). - + + Create new pathway group +
{{ pathway.name }}

End

{{ pathway.end }}

+
+

Group

+

{{ pathway.group.name }}

+

Minimum time required

{{ pathway.min_mins|minutes_to_hours_minutes }}

diff --git a/twisted/twisted_site/templates/admin/pathways/groups/create.html b/twisted/twisted_site/templates/admin/pathways/groups/create.html new file mode 100644 index 0000000..3e26f5a --- /dev/null +++ b/twisted/twisted_site/templates/admin/pathways/groups/create.html @@ -0,0 +1,54 @@ +{% load tz %} +{% get_current_timezone as TIMEZONE %} + +
+ + Admin + Pathways + Groups + + New + + +

Create Pathway Group

+
+ {% csrf_token %} +
+ +
+
+ + Start + +
+ + +
+
+ +
+ + End + +
+ + +
+
+
+
+ + Start/End is in {{ TIMEZONE }} (your local time). This window cannot overlap with an existing pathway group. + +
+ + Create Group + +
+
+
+
diff --git a/twisted/twisted_site/templates/admin/pathways/groups/detail.html b/twisted/twisted_site/templates/admin/pathways/groups/detail.html new file mode 100644 index 0000000..f9d3937 --- /dev/null +++ b/twisted/twisted_site/templates/admin/pathways/groups/detail.html @@ -0,0 +1,103 @@ +{% load tz %} +{% get_current_timezone as TIMEZONE %} + +
+ + Admin + Pathways + Groups + + {{ group.name }} + + +

{{ group.name }}

+
+ {% csrf_token %} +
+ +
+
+ + Start + +
+ + +
+
+ +
+ + End + +
+ + +
+
+
+
+ + Start/End is in {{ TIMEZONE }} (your local time). This window cannot overlap with an existing pathway group. + +
+ + Save changes + +
+
+
+

Pathways in this group

+ {% if pathways %} + + + + + Name + Minimum time required + Status + + + + {% for pathway in pathways %} + + + {{ pathway.name }} + + {{ pathway.min_mins }} min + + {% if pathway.in_progress %} + + In progress + + {% elif pathway.didnt_start %} + + Not started + + {% elif pathway.ended %} + + Ended + + {% endif %} + + + {% endfor %} + + + + {% else %} + +

No pathways yet

+

+ Create one + for this group. +

+
+ {% endif %} +
+
+
diff --git a/twisted/twisted_site/templates/admin/pathways/groups/list.html b/twisted/twisted_site/templates/admin/pathways/groups/list.html new file mode 100644 index 0000000..df815ed --- /dev/null +++ b/twisted/twisted_site/templates/admin/pathways/groups/list.html @@ -0,0 +1,55 @@ + +
+ + Admin + Pathways + + Groups + + +
+

Pathway Groups

+ + + Create new + + +
+ + + + + Name + Start + End + Pathways + + + + {% for group in groups %} + + + {{ group.name }} + + {{ group.start }} + {{ group.end }} + + {% if group.pathways.all %} + {% for pathway in group.pathways.all %} + {{ pathway.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% else %} + None yet + {% endif %} + + + {% empty %} + + No pathway groups yet. + + {% endfor %} + + + +
+
diff --git a/twisted/twisted_site/templates/admin/pathways/list.html b/twisted/twisted_site/templates/admin/pathways/list.html index 437dee2..4502fed 100644 --- a/twisted/twisted_site/templates/admin/pathways/list.html +++ b/twisted/twisted_site/templates/admin/pathways/list.html @@ -1,7 +1,7 @@
-
-

Pathways

+
diff --git a/twisted/twisted_site/templates/client/pathways.html b/twisted/twisted_site/templates/client/pathways.html index c745df3..e1ef5e8 100644 --- a/twisted/twisted_site/templates/client/pathways.html +++ b/twisted/twisted_site/templates/client/pathways.html @@ -17,6 +17,9 @@

Current Pathway{{ current_pathways|pluralize }}

+
+

You have {{ unspent_mins|minutes_to_hours_minutes }} to spend.

+
{% for pathway_info in current_pathways %}
@@ -40,8 +43,21 @@

-
{{ pathway_info.minutes_spent|minutes_to_hours_minutes }}
-
of {{ pathway_info.pathway.min_mins|minutes_to_hours_minutes }}
+ {% if pathway_info.time_spent.unlocked %} +
unlocked
+
spent {{ pathway_info.time_spent.minutes|minutes_to_hours_minutes }}
+ {% elif unspent_mins >= pathway_info.pathway.min_mins %} +
+ {% csrf_token %} + +
+ {% else %} +
unlock for {{ pathway_info.pathway.min_mins|minutes_to_hours_minutes }}
+
You need {{ pathway_info.pathway.min_mins|sub:unspent_mins|minutes_to_hours_minutes }} more to unlock
+ {% endif %}
diff --git a/twisted/twisted_site/urls.py b/twisted/twisted_site/urls.py index 7462231..ef48f56 100644 --- a/twisted/twisted_site/urls.py +++ b/twisted/twisted_site/urls.py @@ -66,6 +66,11 @@ name="fr.projects.journals.edit", ), path("dashboard/frame/pathways/", client.PathwaysView.as_view(), name="fr.pathways"), + path( + "dashboard/frame/pathways//unlock/", + client.UnlockPathway.as_view(), + name="fr.pathways.unlock", + ), path( "dashboard/frame/referrals/", client.ReferralsView.as_view(), @@ -90,6 +95,21 @@ admin.PathwayCreateView.as_view(), name="admin.pathways.create", ), + path( + "admin/pathway-groups/", + admin.PathwayGroupListView.as_view(), + name="admin.pathway_groups", + ), + path( + "admin/pathway-groups/new/", + admin.PathwayGroupCreateView.as_view(), + name="admin.pathway_groups.create", + ), + path( + "admin/pathway-groups//", + admin.PathwayGroupDetailView.as_view(), + name="admin.pathway_groups.detail", + ), path("admin/fulfillment/", admin.FulfillmentView.as_view(), name="admin.fulfillment"), path("admin/shop/", admin.ShopView.as_view(), name="admin.shop"), path("admin/review/", admin.ReviewView.as_view(), name="admin.review"), diff --git a/twisted/twisted_site/views/admin/__init__.py b/twisted/twisted_site/views/admin/__init__.py index 8ef4e48..fceee7c 100644 --- a/twisted/twisted_site/views/admin/__init__.py +++ b/twisted/twisted_site/views/admin/__init__.py @@ -2,7 +2,14 @@ from .audit_logs import AuditLogsView from .dashboard import DashboardView from .fulfillment import FulfillmentView -from .pathways import PathwayCreateView, PathwayDetailView, PathwayListView +from .pathways import ( + PathwayCreateView, + PathwayDetailView, + PathwayGroupCreateView, + PathwayGroupDetailView, + PathwayGroupListView, + PathwayListView, +) from .review import ReviewView from .shop import ShopView from .users import UserDetailView, UsersView @@ -14,6 +21,9 @@ "FulfillmentView", "PathwayCreateView", "PathwayDetailView", + "PathwayGroupCreateView", + "PathwayGroupDetailView", + "PathwayGroupListView", "PathwayListView", "ReviewView", "ShopView", diff --git a/twisted/twisted_site/views/admin/pathways.py b/twisted/twisted_site/views/admin/pathways.py index 081ccb9..1a1d0d5 100644 --- a/twisted/twisted_site/views/admin/pathways.py +++ b/twisted/twisted_site/views/admin/pathways.py @@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone -from twisted_site.models import Pathway, User +from twisted_site.models import Pathway, PathwayGroup, User from .admin import AdminView @@ -21,9 +21,9 @@ def get(self, request: HttpRequest) -> HttpResponse: context = self.get_context_data(page="pathways") - context["pathways"] = Pathway.objects.all().order_by("start") + context["pathways"] = Pathway.objects.all().order_by("group__start") - pathways = Pathway.objects.order_by("start").all() + pathways = Pathway.objects.order_by("group__start").all() current_pathways: list[Pathway] = [] past_pathways: list[Pathway] = [] @@ -45,7 +45,20 @@ def get(self, request: HttpRequest) -> HttpResponse: return render(request, "admin/pathways/list.html", context=context) -class PathwayCreateView(AdminView): +class PathwayGroupListView(AdminView): + def get(self, request: HttpRequest) -> HttpResponse: + if self.perms.view_pathways: + self.allowed = True + else: + return HttpResponse("err") + + context = self.get_context_data(page="pathways", subpage="groups") + context["groups"] = PathwayGroup.objects.order_by("-start").prefetch_related("pathways") + + return render(request, "admin/pathways/groups/list.html", context=context) + + +class PathwayGroupCreateView(AdminView): def get( self, request: HttpRequest, @@ -60,13 +73,13 @@ def get( if extracontext is None: extracontext = {} - context = self.get_context_data(page="pathways", subpage="create") + context = self.get_context_data(page="pathways", subpage="groups.create") context.update(extracontext) if error not in (None, ""): messages.error(request, error) - return render(request, "admin/pathways/create.html", context=context) + return render(request, "admin/pathways/groups/create.html", context=context) def post(self, request: HttpRequest) -> HttpResponse: if self.perms.manage_pathways: @@ -74,7 +87,7 @@ def post(self, request: HttpRequest) -> HttpResponse: else: return HttpResponse("err") - pathway_name: str | None = request.POST.get("name") + group_name: str | None = request.POST.get("name") start_date: str | None = request.POST.get("startDate") start_time: str | None = request.POST.get("startTime") @@ -82,19 +95,16 @@ def post(self, request: HttpRequest) -> HttpResponse: end_date: str | None = request.POST.get("endDate") end_time: str | None = request.POST.get("endTime") - min_mins = int(request.POST.get("mins", "0")) - errcontext: dict[str, Any] = { # pyrefly: ignore[explicit-any] - "pathway_name": pathway_name, + "group_name": group_name, "start_date": start_date, "start_time": start_time, "end_date": end_date, "end_time": end_time, - "min_mins": min_mins, } - if pathway_name in (None, ""): - return self.get(request, "No pathway name typed!", errcontext) + if group_name in (None, ""): + return self.get(request, "No group name typed!", errcontext) if start_date in (None, ""): return self.get(request, "No start date selected!", errcontext) @@ -108,8 +118,107 @@ def post(self, request: HttpRequest) -> HttpResponse: if end_time in (None, ""): return self.get(request, "No end time selected!", errcontext) - if min_mins <= 0: - return self.get(request, "Minimum minutes must be greater than zero!", errcontext) + current_tz_offset = datetime.now(timezone.get_current_timezone()).strftime("%z") + + start = datetime.strptime( + f"{start_date} {start_time} {current_tz_offset}", + "%Y-%m-%d %H:%M %z", + ) + + end = datetime.strptime(f"{end_date} {end_time} {current_tz_offset}", "%Y-%m-%d %H:%M %z") + + if start >= end: + return self.get(request, "Start must be before end!", errcontext) + + if PathwayGroup.objects.filter(start__lt=end, end__gt=start).exists(): + return self.get(request, "This time window overlaps with an existing pathway group!", errcontext) + + group = PathwayGroup.objects.create(name=group_name, start=start, end=end) + + messages.success(request, f'Successfully created pathway group "{group_name}"!') + + return redirect("admin.pathway_groups.detail", group_id=group.id) # pyright: ignore[reportAttributeAccessIssue] + + +class PathwayGroupDetailView(AdminView): + def get( + self, + request: HttpRequest, + group_id: int, + error: str | None = None, + extracontext: dict[str, Any] | None = None, # pyrefly: ignore[explicit-any] + ) -> HttpResponse: + if self.perms.view_pathways: + self.allowed = True + else: + return HttpResponse("err") + + group = get_object_or_404(PathwayGroup, id=group_id) + + if extracontext is None: + local_start = timezone.localtime(group.start) + local_end = timezone.localtime(group.end) + extracontext = { + "group_name": group.name, + "start_date": local_start.strftime("%Y-%m-%d"), + "start_time": local_start.strftime("%H:%M"), + "end_date": local_end.strftime("%Y-%m-%d"), + "end_time": local_end.strftime("%H:%M"), + } + + if not isinstance(self.audit_log.additional_context, dict): + self.audit_log.additional_context = {} + + self.audit_log.additional_context["pathway_group_name"] = group.name + + context = self.get_context_data(page="pathways", subpage="groups.detail") + context["group"] = group + context["pathways"] = group.pathways.order_by("name") # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-attribute] + context.update(extracontext) + + if error not in (None, ""): + messages.error(request, error) + + return render(request, "admin/pathways/groups/detail.html", context=context) + + def post(self, request: HttpRequest, group_id: int) -> HttpResponse: + if self.perms.manage_pathways: + self.allowed = True + else: + return HttpResponse("err") + + group = get_object_or_404(PathwayGroup, id=group_id) + + group_name: str | None = request.POST.get("name") + + start_date: str | None = request.POST.get("startDate") + start_time: str | None = request.POST.get("startTime") + + end_date: str | None = request.POST.get("endDate") + end_time: str | None = request.POST.get("endTime") + + errcontext: dict[str, Any] = { # pyrefly: ignore[explicit-any] + "group_name": group_name, + "start_date": start_date, + "start_time": start_time, + "end_date": end_date, + "end_time": end_time, + } + + if group_name in (None, ""): + return self.get(request, group_id, "No group name typed!", errcontext) + + if start_date in (None, ""): + return self.get(request, group_id, "No start date selected!", errcontext) + + if start_time in (None, ""): + return self.get(request, group_id, "No start time selected!", errcontext) + + if end_date in (None, ""): + return self.get(request, group_id, "No end date selected!", errcontext) + + if end_time in (None, ""): + return self.get(request, group_id, "No end time selected!", errcontext) current_tz_offset = datetime.now(timezone.get_current_timezone()).strftime("%z") @@ -120,7 +229,81 @@ def post(self, request: HttpRequest) -> HttpResponse: end = datetime.strptime(f"{end_date} {end_time} {current_tz_offset}", "%Y-%m-%d %H:%M %z") - _ = Pathway.objects.create(start=start, end=end, name=pathway_name, min_mins=min_mins) + if start >= end: + return self.get(request, group_id, "Start must be before end!", errcontext) + + if PathwayGroup.objects.filter(start__lt=end, end__gt=start).exclude(id=group_id).exists(): + return self.get( + request, + group_id, + "This time window overlaps with an existing pathway group!", + errcontext, + ) + + group.name = group_name + group.start = start + group.end = end + group.save() + + messages.success(request, f'Successfully updated pathway group "{group_name}"!') + + return redirect("admin.pathway_groups.detail", group_id=group_id) + + +class PathwayCreateView(AdminView): + def get( + self, + request: HttpRequest, + error: str | None = None, + extracontext: dict[str, Any] | None = None, # pyrefly: ignore[explicit-any] + ) -> HttpResponse: + if self.perms.manage_pathways: + self.allowed = True + else: + return HttpResponse("err") + + if extracontext is None: + extracontext = {} + + context = self.get_context_data(page="pathways", subpage="create") + context["groups"] = PathwayGroup.objects.order_by("-start") + context.update(extracontext) + + if error not in (None, ""): + messages.error(request, error) + + return render(request, "admin/pathways/create.html", context=context) + + def post(self, request: HttpRequest) -> HttpResponse: + if self.perms.manage_pathways: + self.allowed = True + else: + return HttpResponse("err") + + pathway_name: str | None = request.POST.get("name") + group_id: str | None = request.POST.get("group") + min_mins = int(request.POST.get("mins", "0")) + + errcontext: dict[str, Any] = { # pyrefly: ignore[explicit-any] + "pathway_name": pathway_name, + "group_id": group_id, + "min_mins": min_mins, + } + + if pathway_name in (None, ""): + return self.get(request, "No pathway name typed!", errcontext) + + if group_id in (None, ""): + return self.get(request, "No pathway group selected!", errcontext) + + group = PathwayGroup.objects.filter(id=group_id).first() + if group is None: + return self.get(request, "Selected pathway group does not exist!", errcontext) + + if min_mins <= 0: + return self.get(request, "Minimum minutes must be greater than zero!", errcontext) + + _ = Pathway.objects.create(group=group, name=pathway_name, min_mins=min_mins) messages.success(request, f'Successfully created Pathway for "{pathway_name}"!') diff --git a/twisted/twisted_site/views/client/__init__.py b/twisted/twisted_site/views/client/__init__.py index fcf47cc..bfc287d 100644 --- a/twisted/twisted_site/views/client/__init__.py +++ b/twisted/twisted_site/views/client/__init__.py @@ -7,7 +7,7 @@ NewProjectHackatimeJournal, NewProjectUntrackedJournal, ) -from .pathways import PathwaysView +from .pathways import PathwaysView, UnlockPathway from .project import ProjectDetail, ProjectSettings, SubmitProject from .projects import CreateProject, ListProjects from .referrals import ReferralsView @@ -28,4 +28,5 @@ "ProjectSettings", "ReferralsView", "SubmitProject", + "UnlockPathway", ] diff --git a/twisted/twisted_site/views/client/pathways.py b/twisted/twisted_site/views/client/pathways.py index 128e2d1..f6c69f4 100644 --- a/twisted/twisted_site/views/client/pathways.py +++ b/twisted/twisted_site/views/client/pathways.py @@ -1,10 +1,10 @@ from typing import TYPE_CHECKING, cast from django.http import HttpRequest, HttpResponse -from django.shortcuts import redirect, render +from django.shortcuts import get_object_or_404, redirect, render from django.views import View -from twisted_site.models import Pathway, Profile +from twisted_site.models import Pathway, PathwayTimeSpent, Profile if TYPE_CHECKING: from django.contrib.auth.base_user import AbstractBaseUser @@ -17,11 +17,17 @@ def get(self, request: HttpRequest) -> HttpResponse: return redirect("homepage") profile = cast("Profile", request.user.profile) # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-attribute] - pathways = Pathway.objects.order_by("start").all() + pathways = Pathway.objects.order_by("group__start").all() - current_pathways: list[dict[str, Pathway | int | bool]] = [] - past_pathways: list[dict[str, Pathway | int | bool]] = [] - future_pathways: list[dict[str, Pathway | int | bool]] = [] + time_spent_lookup = { + ts.pathway_id: ts + for ts in PathwayTimeSpent.objects.filter(user=request.user, pathway__in=pathways) + } + + current_group = None + current_pathways: list[dict[str, Pathway | int | bool | PathwayTimeSpent | None]] = [] + past_pathways: list[dict[str, Pathway | int | bool | PathwayTimeSpent | None]] = [] + future_pathways: list[dict[str, Pathway | int | bool | PathwayTimeSpent | None]] = [] for pathway in pathways: minutes_spent = pathway.mins_spent(cast("AbstractBaseUser", request.user)) @@ -29,8 +35,10 @@ def get(self, request: HttpRequest) -> HttpResponse: "pathway": pathway, "minutes_spent": minutes_spent, "unlocked": minutes_spent > pathway.min_mins, + "time_spent": time_spent_lookup.get(pathway.id), } if pathway.in_progress(): + current_group = pathway.group current_pathways.append(pathway_info) if pathway.ended(): past_pathways.append(pathway_info) @@ -46,7 +54,38 @@ def get(self, request: HttpRequest) -> HttpResponse: "profile": profile, "pathways": pathways, "current_pathways": current_pathways, + "current_group": current_group, + "unspent_mins": current_group.get_unspent_mins(request.user), "past_pathways": past_pathways, "future_pathways": future_pathways, }, ) + + +class UnlockPathway(View): + def post(self, request: HttpRequest, pathway_id: int) -> HttpResponse: + if request.user.is_anonymous: + return redirect("homepage") + + pathway = get_object_or_404(Pathway, id=pathway_id) + + if not pathway.in_progress(): + return redirect("fr.pathways") + + already_unlocked = PathwayTimeSpent.objects.filter( + pathway=pathway, user=request.user, unlocked=True, + ).exists() + if already_unlocked: + return redirect("fr.pathways") + + unspent_mins = pathway.group.get_unspent_mins(request.user) + if unspent_mins < pathway.min_mins: + return redirect("fr.pathways") + + PathwayTimeSpent.objects.update_or_create( + pathway=pathway, + user=request.user, + defaults={"unlocked": True, "minutes": pathway.min_mins}, + ) + + return redirect("fr.pathways") From 77f2abcd3cd1403e7fe54093f65891f404f28c32 Mon Sep 17 00:00:00 2001 From: KavyanshKhaitan2 Date: Sat, 19 Sep 2026 20:30:50 +0530 Subject: [PATCH 002/104] Remove pathway groups (claude) --- ...athwaytimespent_golden_twists_and_more.py} | 63 +++--- .../migrations/0035_pathwaygroup_name.py | 24 -- .../migrations/0035_remove_pathwaygroup.py | 47 ++++ ...pathwaytimespent_golden_twists_and_more.py | 23 -- twisted/twisted_site/models.py | 65 +----- .../templates/admin/pathways/create.html | 41 ++-- .../templates/admin/pathways/detail.html | 4 - .../admin/pathways/groups/create.html | 54 ----- .../admin/pathways/groups/detail.html | 103 --------- .../templates/admin/pathways/groups/list.html | 55 ----- .../templates/admin/pathways/list.html | 5 - .../templates/client/pathways.html | 6 +- twisted/twisted_site/urls.py | 15 -- twisted/twisted_site/views/admin/__init__.py | 6 - twisted/twisted_site/views/admin/pathways.py | 213 ++---------------- twisted/twisted_site/views/client/pathways.py | 11 +- 16 files changed, 142 insertions(+), 593 deletions(-) rename twisted/twisted_site/migrations/{0034_pathwaygroup.py => 0034_pathwaygroup_squashed_0036_pathwaytimespent_golden_twists_and_more.py} (72%) delete mode 100644 twisted/twisted_site/migrations/0035_pathwaygroup_name.py create mode 100644 twisted/twisted_site/migrations/0035_remove_pathwaygroup.py delete mode 100644 twisted/twisted_site/migrations/0036_pathwaytimespent_golden_twists_and_more.py delete mode 100644 twisted/twisted_site/templates/admin/pathways/groups/create.html delete mode 100644 twisted/twisted_site/templates/admin/pathways/groups/detail.html delete mode 100644 twisted/twisted_site/templates/admin/pathways/groups/list.html diff --git a/twisted/twisted_site/migrations/0034_pathwaygroup.py b/twisted/twisted_site/migrations/0034_pathwaygroup_squashed_0036_pathwaytimespent_golden_twists_and_more.py similarity index 72% rename from twisted/twisted_site/migrations/0034_pathwaygroup.py rename to twisted/twisted_site/migrations/0034_pathwaygroup_squashed_0036_pathwaytimespent_golden_twists_and_more.py index a2becc5..a46515a 100644 --- a/twisted/twisted_site/migrations/0034_pathwaygroup.py +++ b/twisted/twisted_site/migrations/0034_pathwaygroup_squashed_0036_pathwaytimespent_golden_twists_and_more.py @@ -1,3 +1,5 @@ +# Generated by Django 6.0.7 on 2026-09-19 04:56 + import django.contrib.postgres.constraints import django.db.models.deletion from django.conf import settings @@ -47,7 +49,21 @@ def migrate_pathways_to_groups(apps, schema_editor): pathway.save(update_fields=["group"]) +def backfill_group_names(apps, schema_editor): + PathwayGroup = apps.get_model("twisted_site", "PathwayGroup") + for group in PathwayGroup.objects.filter(name=""): + group.name = f"Group ({group.start.date()} - {group.end.date()})" + group.save(update_fields=["name"]) + + class Migration(migrations.Migration): + + replaces = [ + ("twisted_site", "0034_pathwaygroup"), + ("twisted_site", "0035_pathwaygroup_name"), + ("twisted_site", "0036_pathwaytimespent_golden_twists_and_more"), + ] + dependencies = [ ("twisted_site", "0033_project_hackatime_project_names"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), @@ -57,15 +73,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name="PathwayGroup", fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("start", models.DateTimeField()), ("end", models.DateTimeField()), ], @@ -87,24 +95,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name="PathwayTimeSpent", fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("minutes", models.IntegerField(default=0)), - ( - "pathway", - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="twisted_site.pathway"), - ), - ( - "user", - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), + ("pathway", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="twisted_site.pathway")), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ "unique_together": {("pathway", "user")}, @@ -138,4 +132,21 @@ class Migration(migrations.Migration): model_name="pathway", name="start", ), + migrations.AddField( + model_name="pathwaygroup", + name="name", + field=models.CharField(default="", max_length=200), + preserve_default=False, + ), + migrations.RunPython(backfill_group_names, migrations.RunPython.noop), + migrations.AddField( + model_name="pathwaytimespent", + name="golden_twists", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="pathwaytimespent", + name="unlocked", + field=models.BooleanField(default=False), + ), ] diff --git a/twisted/twisted_site/migrations/0035_pathwaygroup_name.py b/twisted/twisted_site/migrations/0035_pathwaygroup_name.py deleted file mode 100644 index 96bc1eb..0000000 --- a/twisted/twisted_site/migrations/0035_pathwaygroup_name.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.db import migrations, models - - -def backfill_group_names(apps, schema_editor): - PathwayGroup = apps.get_model("twisted_site", "PathwayGroup") - for group in PathwayGroup.objects.filter(name=""): - group.name = f"Group ({group.start.date()} - {group.end.date()})" - group.save(update_fields=["name"]) - - -class Migration(migrations.Migration): - dependencies = [ - ("twisted_site", "0034_pathwaygroup"), - ] - - operations = [ - migrations.AddField( - model_name="pathwaygroup", - name="name", - field=models.CharField(default="", max_length=200), - preserve_default=False, - ), - migrations.RunPython(backfill_group_names, migrations.RunPython.noop), - ] diff --git a/twisted/twisted_site/migrations/0035_remove_pathwaygroup.py b/twisted/twisted_site/migrations/0035_remove_pathwaygroup.py new file mode 100644 index 0000000..f2a15e4 --- /dev/null +++ b/twisted/twisted_site/migrations/0035_remove_pathwaygroup.py @@ -0,0 +1,47 @@ +from django.db import migrations, models + + +def copy_group_dates_to_pathway(apps, schema_editor): + Pathway = apps.get_model("twisted_site", "Pathway") + for pathway in Pathway.objects.select_related("group"): + pathway.start = pathway.group.start + pathway.end = pathway.group.end + pathway.save(update_fields=["start", "end"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("twisted_site", "0034_pathwaygroup_squashed_0036_pathwaytimespent_golden_twists_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="pathway", + name="start", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="pathway", + name="end", + field=models.DateTimeField(null=True), + ), + migrations.RunPython(copy_group_dates_to_pathway, migrations.RunPython.noop), + migrations.AlterField( + model_name="pathway", + name="start", + field=models.DateTimeField(), + ), + migrations.AlterField( + model_name="pathway", + name="end", + field=models.DateTimeField(), + ), + migrations.RemoveField( + model_name="pathway", + name="group", + ), + migrations.DeleteModel( + name="PathwayGroup", + ), + ] diff --git a/twisted/twisted_site/migrations/0036_pathwaytimespent_golden_twists_and_more.py b/twisted/twisted_site/migrations/0036_pathwaytimespent_golden_twists_and_more.py deleted file mode 100644 index 853fe8d..0000000 --- a/twisted/twisted_site/migrations/0036_pathwaytimespent_golden_twists_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 6.0.7 on 2026-09-18 10:25 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('twisted_site', '0035_pathwaygroup_name'), - ] - - operations = [ - migrations.AddField( - model_name='pathwaytimespent', - name='golden_twists', - field=models.IntegerField(default=0), - ), - migrations.AddField( - model_name='pathwaytimespent', - name='unlocked', - field=models.BooleanField(default=False), - ), - ] diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index e345651..d0b8bd1 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -1,21 +1,14 @@ -from django.db.models.query import QuerySet -from typing import TYPE_CHECKING, Any, ClassVar, cast, override +from typing import Any, cast, override from django.contrib.auth import get_user_model from django.contrib.auth.base_user import AbstractBaseUser -from django.contrib.postgres.constraints import ExclusionConstraint -from django.contrib.postgres.fields import RangeOperators -from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models -from django.db.models import CheckConstraint, F, Func, Q, TextField, Sum +from django.db.models import Sum, TextField from django.utils import timezone from . import hackatime -if TYPE_CHECKING: - from datetime import datetime - User = get_user_model() #: Template context dictionaries mix value types by design (mirroring @@ -252,50 +245,11 @@ class ProjectShip(models.Model): def __str__(self) -> str: return f"Ship created at {self.created_at} ({self.get_status_display()})" # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] -class PathwayGroup(models.Model): - name = models.CharField(max_length=200) - start = models.DateTimeField() - end = models.DateTimeField() - - def get_unspent_mins(self, user:User) -> float: - all_journals = Journal.objects.filter(project__user=user) - timed_journals = all_journals.filter(created_at__gte=self.start, created_at__lte=self.end) - earned = timed_journals.aggregate(total=Sum("reduced_minutes"))["total"] or 0 - spent = PathwayTimeSpent.objects.filter(pathway__group=self, user=user).aggregate(total=Sum("minutes"))["total"] or 0 # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] - return earned - spent - - - class Meta: - """Meta class for the PathwayGroup model.""" - - constraints: ClassVar[list[models.BaseConstraint]] = [ - CheckConstraint(condition=Q(start__lt=F("end")), name="pathwaygroup_start_before_end"), - ExclusionConstraint( - name="pathwaygroup_no_overlapping_ranges", - expressions=[(Func(F("start"), F("end"), function="tstzrange"), RangeOperators.OVERLAPS)], - ), - ] - - @override - def __str__(self) -> str: - return cast("str", self.name) # pyrefly: ignore[redundant-cast] - - def clean(self) -> None: - super().clean() - if self.start is not None and self.end is not None and self.start >= self.end: - msg = "Start must be before end." - raise ValidationError(msg) - overlapping = PathwayGroup.objects.filter(start__lt=self.end, end__gt=self.start).exclude(pk=self.pk) - if overlapping.exists(): - msg = "This time window overlaps with an existing pathway group." - raise ValidationError(msg) - - class Pathway(models.Model): - group = models.ForeignKey("twisted_site.PathwayGroup", on_delete=models.PROTECT, related_name="pathways") - name = models.CharField(max_length=200) min_mins = models.IntegerField(default=300) + start = models.DateTimeField() + end = models.DateTimeField() created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -304,13 +258,10 @@ class Pathway(models.Model): def __str__(self) -> str: return cast("str", self.name) # pyrefly: ignore[redundant-cast] - @property - def start(self) -> "datetime": - return self.group.start # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-attribute] - - @property - def end(self) -> "datetime": - return self.group.end # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-attribute] + def get_unspent_mins(self, user: User) -> float: + total_spent = cast("Profile", user.profile).time_logged() # pyrefly: ignore[missing-attribute] + spent_on_pathways = PathwayTimeSpent.objects.filter(user=user).aggregate(total=Sum("minutes"))["total"] or 0 # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] + return total_spent - spent_on_pathways def ended(self) -> bool: return timezone.now() > self.end diff --git a/twisted/twisted_site/templates/admin/pathways/create.html b/twisted/twisted_site/templates/admin/pathways/create.html index d097733..d5cbbc5 100644 --- a/twisted/twisted_site/templates/admin/pathways/create.html +++ b/twisted/twisted_site/templates/admin/pathways/create.html @@ -1,3 +1,5 @@ +{% load tz %} +{% get_current_timezone as TIMEZONE %}
@@ -16,24 +18,31 @@

Create Pathway

placeholder="CPU" required="True" value="{{ pathway_name }}" /> -
- - {% for group in groups %} - - {{ group.name }} ({{ group.start }} – {{ group.end }}) - - {% endfor %} - - {% if not groups %} - - No pathway groups exist yet. - - {% endif %} +
+
+ + Start + +
+ + +
+
+ +
+ + End + +
+ + +
+
- - Create new pathway group - + + Start/End is in {{ TIMEZONE }} (your local time). +
{{ pathway.name }}

End

{{ pathway.end }}

-
-

Group

-

{{ pathway.group.name }}

-

Minimum time required

{{ pathway.min_mins|minutes_to_hours_minutes }}

diff --git a/twisted/twisted_site/templates/admin/pathways/groups/create.html b/twisted/twisted_site/templates/admin/pathways/groups/create.html deleted file mode 100644 index 3e26f5a..0000000 --- a/twisted/twisted_site/templates/admin/pathways/groups/create.html +++ /dev/null @@ -1,54 +0,0 @@ -{% load tz %} -{% get_current_timezone as TIMEZONE %} - -
- - Admin - Pathways - Groups - - New - - -

Create Pathway Group

-
- {% csrf_token %} -
- -
-
- - Start - -
- - -
-
- -
- - End - -
- - -
-
-
-
- - Start/End is in {{ TIMEZONE }} (your local time). This window cannot overlap with an existing pathway group. - -
- - Create Group - -
-
-
-
diff --git a/twisted/twisted_site/templates/admin/pathways/groups/detail.html b/twisted/twisted_site/templates/admin/pathways/groups/detail.html deleted file mode 100644 index f9d3937..0000000 --- a/twisted/twisted_site/templates/admin/pathways/groups/detail.html +++ /dev/null @@ -1,103 +0,0 @@ -{% load tz %} -{% get_current_timezone as TIMEZONE %} - -
- - Admin - Pathways - Groups - - {{ group.name }} - - -

{{ group.name }}

-
- {% csrf_token %} -
- -
-
- - Start - -
- - -
-
- -
- - End - -
- - -
-
-
-
- - Start/End is in {{ TIMEZONE }} (your local time). This window cannot overlap with an existing pathway group. - -
- - Save changes - -
-
-
-

Pathways in this group

- {% if pathways %} - - - - - Name - Minimum time required - Status - - - - {% for pathway in pathways %} - - - {{ pathway.name }} - - {{ pathway.min_mins }} min - - {% if pathway.in_progress %} - - In progress - - {% elif pathway.didnt_start %} - - Not started - - {% elif pathway.ended %} - - Ended - - {% endif %} - - - {% endfor %} - - - - {% else %} - -

No pathways yet

-

- Create one - for this group. -

-
- {% endif %} -
-
-
diff --git a/twisted/twisted_site/templates/admin/pathways/groups/list.html b/twisted/twisted_site/templates/admin/pathways/groups/list.html deleted file mode 100644 index df815ed..0000000 --- a/twisted/twisted_site/templates/admin/pathways/groups/list.html +++ /dev/null @@ -1,55 +0,0 @@ - -
- - Admin - Pathways - - Groups - - -
-

Pathway Groups

- - - Create new - - -
- - - - - Name - Start - End - Pathways - - - - {% for group in groups %} - - - {{ group.name }} - - {{ group.start }} - {{ group.end }} - - {% if group.pathways.all %} - {% for pathway in group.pathways.all %} - {{ pathway.name }}{% if not forloop.last %}, {% endif %} - {% endfor %} - {% else %} - None yet - {% endif %} - - - {% empty %} - - No pathway groups yet. - - {% endfor %} - - - -
-
diff --git a/twisted/twisted_site/templates/admin/pathways/list.html b/twisted/twisted_site/templates/admin/pathways/list.html index 4502fed..f42094a 100644 --- a/twisted/twisted_site/templates/admin/pathways/list.html +++ b/twisted/twisted_site/templates/admin/pathways/list.html @@ -21,11 +21,6 @@

Pathway Create new - - - Manage groups - -

diff --git a/twisted/twisted_site/templates/client/pathways.html b/twisted/twisted_site/templates/client/pathways.html index e1ef5e8..e279b14 100644 --- a/twisted/twisted_site/templates/client/pathways.html +++ b/twisted/twisted_site/templates/client/pathways.html @@ -55,8 +55,10 @@

{% else %} -
unlock for {{ pathway_info.pathway.min_mins|minutes_to_hours_minutes }}
-
You need {{ pathway_info.pathway.min_mins|sub:unspent_mins|minutes_to_hours_minutes }} more to unlock
+ {% endif %}

diff --git a/twisted/twisted_site/urls.py b/twisted/twisted_site/urls.py index ef48f56..6fadb43 100644 --- a/twisted/twisted_site/urls.py +++ b/twisted/twisted_site/urls.py @@ -95,21 +95,6 @@ admin.PathwayCreateView.as_view(), name="admin.pathways.create", ), - path( - "admin/pathway-groups/", - admin.PathwayGroupListView.as_view(), - name="admin.pathway_groups", - ), - path( - "admin/pathway-groups/new/", - admin.PathwayGroupCreateView.as_view(), - name="admin.pathway_groups.create", - ), - path( - "admin/pathway-groups//", - admin.PathwayGroupDetailView.as_view(), - name="admin.pathway_groups.detail", - ), path("admin/fulfillment/", admin.FulfillmentView.as_view(), name="admin.fulfillment"), path("admin/shop/", admin.ShopView.as_view(), name="admin.shop"), path("admin/review/", admin.ReviewView.as_view(), name="admin.review"), diff --git a/twisted/twisted_site/views/admin/__init__.py b/twisted/twisted_site/views/admin/__init__.py index fceee7c..a31bf7e 100644 --- a/twisted/twisted_site/views/admin/__init__.py +++ b/twisted/twisted_site/views/admin/__init__.py @@ -5,9 +5,6 @@ from .pathways import ( PathwayCreateView, PathwayDetailView, - PathwayGroupCreateView, - PathwayGroupDetailView, - PathwayGroupListView, PathwayListView, ) from .review import ReviewView @@ -21,9 +18,6 @@ "FulfillmentView", "PathwayCreateView", "PathwayDetailView", - "PathwayGroupCreateView", - "PathwayGroupDetailView", - "PathwayGroupListView", "PathwayListView", "ReviewView", "ShopView", diff --git a/twisted/twisted_site/views/admin/pathways.py b/twisted/twisted_site/views/admin/pathways.py index 1a1d0d5..e4f88f9 100644 --- a/twisted/twisted_site/views/admin/pathways.py +++ b/twisted/twisted_site/views/admin/pathways.py @@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone -from twisted_site.models import Pathway, PathwayGroup, User +from twisted_site.models import Pathway, User from .admin import AdminView @@ -21,9 +21,9 @@ def get(self, request: HttpRequest) -> HttpResponse: context = self.get_context_data(page="pathways") - context["pathways"] = Pathway.objects.all().order_by("group__start") + context["pathways"] = Pathway.objects.all().order_by("start") - pathways = Pathway.objects.order_by("group__start").all() + pathways = Pathway.objects.order_by("start").all() current_pathways: list[Pathway] = [] past_pathways: list[Pathway] = [] @@ -45,20 +45,7 @@ def get(self, request: HttpRequest) -> HttpResponse: return render(request, "admin/pathways/list.html", context=context) -class PathwayGroupListView(AdminView): - def get(self, request: HttpRequest) -> HttpResponse: - if self.perms.view_pathways: - self.allowed = True - else: - return HttpResponse("err") - - context = self.get_context_data(page="pathways", subpage="groups") - context["groups"] = PathwayGroup.objects.order_by("-start").prefetch_related("pathways") - - return render(request, "admin/pathways/groups/list.html", context=context) - - -class PathwayGroupCreateView(AdminView): +class PathwayCreateView(AdminView): def get( self, request: HttpRequest, @@ -73,13 +60,13 @@ def get( if extracontext is None: extracontext = {} - context = self.get_context_data(page="pathways", subpage="groups.create") + context = self.get_context_data(page="pathways", subpage="create") context.update(extracontext) if error not in (None, ""): messages.error(request, error) - return render(request, "admin/pathways/groups/create.html", context=context) + return render(request, "admin/pathways/create.html", context=context) def post(self, request: HttpRequest) -> HttpResponse: if self.perms.manage_pathways: @@ -87,7 +74,8 @@ def post(self, request: HttpRequest) -> HttpResponse: else: return HttpResponse("err") - group_name: str | None = request.POST.get("name") + pathway_name: str | None = request.POST.get("name") + min_mins = int(request.POST.get("mins", "0")) start_date: str | None = request.POST.get("startDate") start_time: str | None = request.POST.get("startTime") @@ -96,15 +84,19 @@ def post(self, request: HttpRequest) -> HttpResponse: end_time: str | None = request.POST.get("endTime") errcontext: dict[str, Any] = { # pyrefly: ignore[explicit-any] - "group_name": group_name, + "pathway_name": pathway_name, + "min_mins": min_mins, "start_date": start_date, "start_time": start_time, "end_date": end_date, "end_time": end_time, } - if group_name in (None, ""): - return self.get(request, "No group name typed!", errcontext) + if pathway_name in (None, ""): + return self.get(request, "No pathway name typed!", errcontext) + + if min_mins <= 0: + return self.get(request, "Minimum minutes must be greater than zero!", errcontext) if start_date in (None, ""): return self.get(request, "No start date selected!", errcontext) @@ -130,180 +122,7 @@ def post(self, request: HttpRequest) -> HttpResponse: if start >= end: return self.get(request, "Start must be before end!", errcontext) - if PathwayGroup.objects.filter(start__lt=end, end__gt=start).exists(): - return self.get(request, "This time window overlaps with an existing pathway group!", errcontext) - - group = PathwayGroup.objects.create(name=group_name, start=start, end=end) - - messages.success(request, f'Successfully created pathway group "{group_name}"!') - - return redirect("admin.pathway_groups.detail", group_id=group.id) # pyright: ignore[reportAttributeAccessIssue] - - -class PathwayGroupDetailView(AdminView): - def get( - self, - request: HttpRequest, - group_id: int, - error: str | None = None, - extracontext: dict[str, Any] | None = None, # pyrefly: ignore[explicit-any] - ) -> HttpResponse: - if self.perms.view_pathways: - self.allowed = True - else: - return HttpResponse("err") - - group = get_object_or_404(PathwayGroup, id=group_id) - - if extracontext is None: - local_start = timezone.localtime(group.start) - local_end = timezone.localtime(group.end) - extracontext = { - "group_name": group.name, - "start_date": local_start.strftime("%Y-%m-%d"), - "start_time": local_start.strftime("%H:%M"), - "end_date": local_end.strftime("%Y-%m-%d"), - "end_time": local_end.strftime("%H:%M"), - } - - if not isinstance(self.audit_log.additional_context, dict): - self.audit_log.additional_context = {} - - self.audit_log.additional_context["pathway_group_name"] = group.name - - context = self.get_context_data(page="pathways", subpage="groups.detail") - context["group"] = group - context["pathways"] = group.pathways.order_by("name") # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-attribute] - context.update(extracontext) - - if error not in (None, ""): - messages.error(request, error) - - return render(request, "admin/pathways/groups/detail.html", context=context) - - def post(self, request: HttpRequest, group_id: int) -> HttpResponse: - if self.perms.manage_pathways: - self.allowed = True - else: - return HttpResponse("err") - - group = get_object_or_404(PathwayGroup, id=group_id) - - group_name: str | None = request.POST.get("name") - - start_date: str | None = request.POST.get("startDate") - start_time: str | None = request.POST.get("startTime") - - end_date: str | None = request.POST.get("endDate") - end_time: str | None = request.POST.get("endTime") - - errcontext: dict[str, Any] = { # pyrefly: ignore[explicit-any] - "group_name": group_name, - "start_date": start_date, - "start_time": start_time, - "end_date": end_date, - "end_time": end_time, - } - - if group_name in (None, ""): - return self.get(request, group_id, "No group name typed!", errcontext) - - if start_date in (None, ""): - return self.get(request, group_id, "No start date selected!", errcontext) - - if start_time in (None, ""): - return self.get(request, group_id, "No start time selected!", errcontext) - - if end_date in (None, ""): - return self.get(request, group_id, "No end date selected!", errcontext) - - if end_time in (None, ""): - return self.get(request, group_id, "No end time selected!", errcontext) - - current_tz_offset = datetime.now(timezone.get_current_timezone()).strftime("%z") - - start = datetime.strptime( - f"{start_date} {start_time} {current_tz_offset}", - "%Y-%m-%d %H:%M %z", - ) - - end = datetime.strptime(f"{end_date} {end_time} {current_tz_offset}", "%Y-%m-%d %H:%M %z") - - if start >= end: - return self.get(request, group_id, "Start must be before end!", errcontext) - - if PathwayGroup.objects.filter(start__lt=end, end__gt=start).exclude(id=group_id).exists(): - return self.get( - request, - group_id, - "This time window overlaps with an existing pathway group!", - errcontext, - ) - - group.name = group_name - group.start = start - group.end = end - group.save() - - messages.success(request, f'Successfully updated pathway group "{group_name}"!') - - return redirect("admin.pathway_groups.detail", group_id=group_id) - - -class PathwayCreateView(AdminView): - def get( - self, - request: HttpRequest, - error: str | None = None, - extracontext: dict[str, Any] | None = None, # pyrefly: ignore[explicit-any] - ) -> HttpResponse: - if self.perms.manage_pathways: - self.allowed = True - else: - return HttpResponse("err") - - if extracontext is None: - extracontext = {} - - context = self.get_context_data(page="pathways", subpage="create") - context["groups"] = PathwayGroup.objects.order_by("-start") - context.update(extracontext) - - if error not in (None, ""): - messages.error(request, error) - - return render(request, "admin/pathways/create.html", context=context) - - def post(self, request: HttpRequest) -> HttpResponse: - if self.perms.manage_pathways: - self.allowed = True - else: - return HttpResponse("err") - - pathway_name: str | None = request.POST.get("name") - group_id: str | None = request.POST.get("group") - min_mins = int(request.POST.get("mins", "0")) - - errcontext: dict[str, Any] = { # pyrefly: ignore[explicit-any] - "pathway_name": pathway_name, - "group_id": group_id, - "min_mins": min_mins, - } - - if pathway_name in (None, ""): - return self.get(request, "No pathway name typed!", errcontext) - - if group_id in (None, ""): - return self.get(request, "No pathway group selected!", errcontext) - - group = PathwayGroup.objects.filter(id=group_id).first() - if group is None: - return self.get(request, "Selected pathway group does not exist!", errcontext) - - if min_mins <= 0: - return self.get(request, "Minimum minutes must be greater than zero!", errcontext) - - _ = Pathway.objects.create(group=group, name=pathway_name, min_mins=min_mins) + _ = Pathway.objects.create(name=pathway_name, min_mins=min_mins, start=start, end=end) messages.success(request, f'Successfully created Pathway for "{pathway_name}"!') diff --git a/twisted/twisted_site/views/client/pathways.py b/twisted/twisted_site/views/client/pathways.py index f6c69f4..1cf98fe 100644 --- a/twisted/twisted_site/views/client/pathways.py +++ b/twisted/twisted_site/views/client/pathways.py @@ -17,14 +17,14 @@ def get(self, request: HttpRequest) -> HttpResponse: return redirect("homepage") profile = cast("Profile", request.user.profile) # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-attribute] - pathways = Pathway.objects.order_by("group__start").all() + pathways = Pathway.objects.order_by("start").all() time_spent_lookup = { ts.pathway_id: ts for ts in PathwayTimeSpent.objects.filter(user=request.user, pathway__in=pathways) } - current_group = None + current_pathway = None current_pathways: list[dict[str, Pathway | int | bool | PathwayTimeSpent | None]] = [] past_pathways: list[dict[str, Pathway | int | bool | PathwayTimeSpent | None]] = [] future_pathways: list[dict[str, Pathway | int | bool | PathwayTimeSpent | None]] = [] @@ -38,7 +38,7 @@ def get(self, request: HttpRequest) -> HttpResponse: "time_spent": time_spent_lookup.get(pathway.id), } if pathway.in_progress(): - current_group = pathway.group + current_pathway = pathway current_pathways.append(pathway_info) if pathway.ended(): past_pathways.append(pathway_info) @@ -54,8 +54,7 @@ def get(self, request: HttpRequest) -> HttpResponse: "profile": profile, "pathways": pathways, "current_pathways": current_pathways, - "current_group": current_group, - "unspent_mins": current_group.get_unspent_mins(request.user), + "unspent_mins": current_pathway.get_unspent_mins(request.user), "past_pathways": past_pathways, "future_pathways": future_pathways, }, @@ -78,7 +77,7 @@ def post(self, request: HttpRequest, pathway_id: int) -> HttpResponse: if already_unlocked: return redirect("fr.pathways") - unspent_mins = pathway.group.get_unspent_mins(request.user) + unspent_mins = pathway.get_unspent_mins(request.user) if unspent_mins < pathway.min_mins: return redirect("fr.pathways") From 059cf1e038c0bbce22fa153c9b38dc9ee1af6325 Mon Sep 17 00:00:00 2001 From: KavyanshKhaitan2 Date: Sun, 20 Sep 2026 09:02:01 +0530 Subject: [PATCH 003/104] Refactor the code and fix a few stuff --- .../templates/client/pathways.html | 77 ++----------------- .../templates/cotton/client/pathway_card.html | 64 +++++++++++++++ twisted/twisted_site/views/client/pathways.py | 1 + 3 files changed, 73 insertions(+), 69 deletions(-) create mode 100644 twisted/twisted_site/templates/cotton/client/pathway_card.html diff --git a/twisted/twisted_site/templates/client/pathways.html b/twisted/twisted_site/templates/client/pathways.html index e279b14..7d995cc 100644 --- a/twisted/twisted_site/templates/client/pathways.html +++ b/twisted/twisted_site/templates/client/pathways.html @@ -12,58 +12,18 @@ {% endif %} {% if current_pathways %}
-
+

Current Pathway{{ current_pathways|pluralize }}

-
+

You have {{ unspent_mins|minutes_to_hours_minutes }} to spend.

- {% for pathway_info in current_pathways %} -
-
-
-

- {{ pathway_info.pathway.name|default:"none! please dm @kavyansh." }} -

-
- Started - - {{ pathway_info.pathway.start|naturaltime }} - - • - Ends - - {{ pathway_info.pathway.end|naturaltime }} - -
-
-
- {% if pathway_info.time_spent.unlocked %} -
unlocked
-
spent {{ pathway_info.time_spent.minutes|minutes_to_hours_minutes }}
- {% elif unspent_mins >= pathway_info.pathway.min_mins %} -
- {% csrf_token %} - -
- {% else %} - - {% endif %} -
-
-
- {% endfor %} + {% for pathway_info in current_pathways %} + + {% endfor %}
{% endif %} @@ -76,30 +36,9 @@

Future Pathways

- {% for pathway_info in future_pathways %} -
-
-

{{ pathway_info.pathway.name }}

- - {{ pathway_info.pathway.end|sub:pathway_info.pathway.start|naturaldelta }} - long - -
-
- Starts - - {{ pathway_info.pathway.start|naturalday }} - - • - Ends - - {{ pathway_info.pathway.end|naturalday }} - -
-
- {% endfor %} + {% for pathway_info in future_pathways %} + + {% endfor %}
{% endif %} diff --git a/twisted/twisted_site/templates/cotton/client/pathway_card.html b/twisted/twisted_site/templates/cotton/client/pathway_card.html new file mode 100644 index 0000000..2d19c65 --- /dev/null +++ b/twisted/twisted_site/templates/cotton/client/pathway_card.html @@ -0,0 +1,64 @@ +{% load static time_filters humanize humanizelib maths mathfilters %} +
+
+
+

+ {{ pathway_info.pathway.name|default:"none! please dm @kavyansh." }} +

+
+ {% if pathway_info.pathway.in_progress or pathway_info.pathway.ended %} + Started + {% endif %} + + {{ pathway_info.pathway.start|naturaltime }} + + • + Ends + + {{ pathway_info.pathway.end|naturaltime }} + +
+
+
+
+ +
+ {% if not pathway_info.pathway.didnt_start %} +
+ {% if pathway_info.time_spent.unlocked %} +
+
unlocked
+
spent {{ pathway_info.time_spent.minutes|minutes_to_hours_minutes }}
+
+ {% elif unspent_mins >= pathway_info.pathway.min_mins %} +
+
+ {% csrf_token %} + +
+
+ {% else %} +
+ +
+ {% endif %} +
+ {% endif %} +
+
+
diff --git a/twisted/twisted_site/views/client/pathways.py b/twisted/twisted_site/views/client/pathways.py index 1cf98fe..d19490f 100644 --- a/twisted/twisted_site/views/client/pathways.py +++ b/twisted/twisted_site/views/client/pathways.py @@ -31,6 +31,7 @@ def get(self, request: HttpRequest) -> HttpResponse: for pathway in pathways: minutes_spent = pathway.mins_spent(cast("AbstractBaseUser", request.user)) + pathway_info = { "pathway": pathway, "minutes_spent": minutes_spent, From f964b3345f7ecbeb85e3d6190ae45b8cf4007edb Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Tue, 22 Sep 2026 13:35:00 -0400 Subject: [PATCH 004/104] Fix env example Hackatime redirect URI secure scheme --- twisted/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twisted/.env.example b/twisted/.env.example index 0c50b4d..fef04e8 100644 --- a/twisted/.env.example +++ b/twisted/.env.example @@ -15,7 +15,7 @@ HCA_REDIRECT_URI = http://localhost:8000/oauth/callback HACKATIME_CLIENT_ID = HACKATIME_CLIENT_SECRET = -HACKATIME_REDIRECT_URI = https://localhost:8000/oauth/hackatime_callback +HACKATIME_REDIRECT_URI = http://localhost:8000/oauth/hackatime_callback SLACK_TOKEN = xoxb-*** SLACK_LOG_CHANNEL = C*** From 94c34764d7a917ff9f299689405f4c033bea68fc Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Tue, 22 Sep 2026 13:36:56 -0400 Subject: [PATCH 005/104] Merge most recent migrations --- .../migrations/0036_merge_20260922_1730.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 twisted/twisted_site/migrations/0036_merge_20260922_1730.py diff --git a/twisted/twisted_site/migrations/0036_merge_20260922_1730.py b/twisted/twisted_site/migrations/0036_merge_20260922_1730.py new file mode 100644 index 0000000..3e3d9b3 --- /dev/null +++ b/twisted/twisted_site/migrations/0036_merge_20260922_1730.py @@ -0,0 +1,14 @@ +# Generated by Django 6.0.7 on 2026-09-22 17:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0034_profile_country_profile_country_cached_until'), + ('twisted_site', '0035_remove_pathwaygroup'), + ] + + operations = [ + ] From cc4db3d1effc324f8fd4476a508950cd506b8d2e Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Tue, 22 Sep 2026 13:39:53 -0400 Subject: [PATCH 006/104] Fix empty state when no pathways are available --- twisted/twisted_site/views/client/pathways.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twisted/twisted_site/views/client/pathways.py b/twisted/twisted_site/views/client/pathways.py index d19490f..54fd71b 100644 --- a/twisted/twisted_site/views/client/pathways.py +++ b/twisted/twisted_site/views/client/pathways.py @@ -55,7 +55,7 @@ def get(self, request: HttpRequest) -> HttpResponse: "profile": profile, "pathways": pathways, "current_pathways": current_pathways, - "unspent_mins": current_pathway.get_unspent_mins(request.user), + "unspent_mins": current_pathway.get_unspent_mins(request.user) if current_pathway else None, "past_pathways": past_pathways, "future_pathways": future_pathways, }, From 97124a65a8fec756ec527943638603e510e22cb4 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Tue, 22 Sep 2026 13:47:59 -0400 Subject: [PATCH 007/104] Fix chart data errors on admin dashboard --- .../templates/admin/dashboard.html | 24 +++++++++++++++++-- twisted/twisted_site/views/admin/dashboard.py | 2 +- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/twisted/twisted_site/templates/admin/dashboard.html b/twisted/twisted_site/templates/admin/dashboard.html index 2a3ed88..e674657 100644 --- a/twisted/twisted_site/templates/admin/dashboard.html +++ b/twisted/twisted_site/templates/admin/dashboard.html @@ -1,6 +1,20 @@ +

Welcome back, {{ user.profile.slack_username }}!

@@ -49,12 +63,15 @@

Welcome back, {{ user.profile.slack_usern google.charts.setOnLoadCallback(drawChart); function drawChart() { - var data = google.visualization.arrayToDataTable({{ hours_logged_chart|safe }}); + var data = hoursDateData({{ hours_logged_chart|safe }}); var options = { vAxis: { minValue: 0 }, + hAxis: { + format: 'EEE, d MMM' + }, animation: { duration: 250, startup: true @@ -165,12 +182,15 @@

Welcome back, {{ user.profile.slack_usern google.charts.setOnLoadCallback(drawChart); function drawChart() { - var data = google.visualization.arrayToDataTable({{ hours_shipped_chart|safe }}); + var data = hoursDateData({{ hours_shipped_chart|safe }}); var options = { vAxis: { minValue: 0 }, + hAxis: { + format: 'EEE, d MMM' + }, animation: { duration: 250, startup: true diff --git a/twisted/twisted_site/views/admin/dashboard.py b/twisted/twisted_site/views/admin/dashboard.py index 93ad6f4..d4b0f2c 100644 --- a/twisted/twisted_site/views/admin/dashboard.py +++ b/twisted/twisted_site/views/admin/dashboard.py @@ -28,7 +28,7 @@ def get(self, request: HttpRequest) -> HttpResponse: hours = journal.reduced_minutes / 60 hours_logged += hours - date = journal.created_at.date().strftime("%a, %d %b") + date = journal.created_at.date().isoformat() hours_logged_chart[date] = hours_logged_chart.get(date, 0) + hours logged_project_type[journal.project.get_project_type_display()] += hours From bafad492ad3a4d43b39f3ad5d562fa0f3e895ebd Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Tue, 22 Sep 2026 14:18:05 -0400 Subject: [PATCH 008/104] Improved typing and ignores for Pyrefly --- pyproject.toml | 2 + twisted/twisted_site/ari.py | 4 +- twisted/twisted_site/hca.py | 48 +++++++++++++++---- twisted/twisted_site/models.py | 6 +-- twisted/twisted_site/views/admin/dashboard.py | 4 +- twisted/twisted_site/views/admin/users.py | 10 ++-- twisted/twisted_site/views/client/journal.py | 15 ++++-- twisted/twisted_site/views/client/pathways.py | 6 +-- 8 files changed, 67 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 950ddf7..ec676d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,3 +47,5 @@ executionEnvironments = [{ root = "twisted" }] [tool.pyrefly] infer-with-first-use = false search-path = ["twisted"] +# Migrations are generated and excluded from ruff too (see per-file-ignores above). +project-excludes = ["**/migrations/**"] diff --git a/twisted/twisted_site/ari.py b/twisted/twisted_site/ari.py index 4456750..f7683ad 100644 --- a/twisted/twisted_site/ari.py +++ b/twisted/twisted_site/ari.py @@ -111,7 +111,7 @@ def send_ship(ship: ProjectShip) -> None: thumbnail_url = ship.project.screenshot_url - hackatime_projects = ship.project.hackatime_project_names # pyrefly: ignore[missing-attribute] + hackatime_projects = ship.project.hackatime_project_names meta = { "project_url": f"https://twisted.hackclub.com/dashboard/?project={ship.project.id}", @@ -125,7 +125,7 @@ def send_ship(ship: ProjectShip) -> None: journals.append( { "at": journal.created_at.isoformat(), # ty: ignore[unresolved-attribute] - "minutes": int(journal.reduced_minutes), # ty: ignore[invalid-argument-type] + "minutes": journal.reduced_minutes, "text": content, "markdown": content, }, diff --git a/twisted/twisted_site/hca.py b/twisted/twisted_site/hca.py index 38f35c1..e91c466 100644 --- a/twisted/twisted_site/hca.py +++ b/twisted/twisted_site/hca.py @@ -1,11 +1,38 @@ from dataclasses import dataclass -from typing import Literal +from typing import Literal, TypedDict, cast import requests HCA_BASE_URL = "https://auth.hackclub.com/api/v1" +class _AddressPayload(TypedDict, total=False): + id: str + first_name: str + last_name: str + line_1: str + line_2: str + city: str + state: str + postal_code: str + country: str + phone_number: str + primary: bool + + +class _IdentityPayload(TypedDict, total=False): + id: str + ysws_eligible: bool + verification_status: Literal["needs_submission", "pending", "verified", "ineligible"] + first_name: str + last_name: str + primary_email: str + slack_id: str + phone_number: str + birthday: str + addresses: list[_AddressPayload] + + @dataclass class Address: id: str @@ -25,7 +52,7 @@ class Address: class Identity: id: str ysws_eligible: bool - verification_status: Literal["needs_submission", "pending", "verified", "ineligible"] + verification_status: Literal["needs_submission", "pending", "verified", "ineligible"] | None first_name: str last_name: str primary_email: str @@ -48,11 +75,14 @@ def get_user_data(access_token: str) -> Identity: r = requests.get(HCA_BASE_URL + "/me", headers=headers, timeout=10) r.raise_for_status() - resp = r.json()["identity"] + resp = cast("_IdentityPayload", r.json()["identity"]) addresses: list[Address] = [] primary_address: Address | None = None - for address in resp.get("addresses", []): + addresses_payload: list[_AddressPayload] | None = resp.get("addresses") + if addresses_payload is None: + addresses_payload = [] + for address in addresses_payload: transformed_address = Address( id=address.get("id", "None"), first_name=address.get("first_name", "None"), @@ -64,20 +94,20 @@ def get_user_data(access_token: str) -> Identity: postal_code=address.get("postal_code", "None"), country=address.get("country", "None"), phone_number=address.get("phone_number", "None"), - primary=address.get("primary", "None"), + primary=address.get("primary", False), ) if transformed_address.primary: primary_address = transformed_address addresses.append(transformed_address) return Identity( - id=resp["id"], + id=resp.get("id", "None"), ysws_eligible=resp.get("ysws_eligible", False), - verification_status=resp.get("verification_status", None), + verification_status=resp.get("verification_status"), first_name=resp.get("first_name", "None"), last_name=resp.get("last_name", "None"), - primary_email=resp["primary_email"], - slack_id=resp["slack_id"], + primary_email=resp.get("primary_email", "None"), + slack_id=resp.get("slack_id", "None"), phone_number=resp.get("phone_number", "None"), birthday=resp.get("birthday", "0000-00-00"), addresses=addresses, diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index 803c7d9..cc8473b 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -74,10 +74,10 @@ def __str__(self) -> str: def get_country(self) -> str: if self.country_cached_until is not None and self.country_cached_until > timezone.now(): - return str(self.country) + return self.country # ty: ignore[unsound-return-statement] try: user_data = hca.get_user_data(self.hca_access_token) # ty: ignore[invalid-argument-type] - country = user_data.primary_address.country if user_data.primary_address else "Unknown" + country = user_data.primary_address.country if user_data.primary_address is not None else "Unknown" except HTTPError: country = "Unknown" @@ -302,7 +302,7 @@ def status(self) -> str | None: def mins_spent(self, user: AbstractBaseUser) -> int: time_spent = PathwayTimeSpent.objects.filter(pathway=self, user=user).first() # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] - return time_spent.minutes if time_spent else 0 + return time_spent.minutes if time_spent is not None else 0 def mins_spent_per_participant(self) -> dict[int, int]: """ diff --git a/twisted/twisted_site/views/admin/dashboard.py b/twisted/twisted_site/views/admin/dashboard.py index d4b0f2c..365631e 100644 --- a/twisted/twisted_site/views/admin/dashboard.py +++ b/twisted/twisted_site/views/admin/dashboard.py @@ -33,9 +33,9 @@ def get(self, request: HttpRequest) -> HttpResponse: logged_project_type[journal.project.get_project_type_display()] += hours - country = journal.project.user.profile.get_country() + country = journal.project.user.profile.get_country() # pyrefly: ignore[missing-attribute] - logged_region_hours.setdefault(country, 0) + _ = logged_region_hours.setdefault(country, 0) logged_region_hours[country] += hours if journal.project.is_shipped(): diff --git a/twisted/twisted_site/views/admin/users.py b/twisted/twisted_site/views/admin/users.py index 77ffa26..6398531 100644 --- a/twisted/twisted_site/views/admin/users.py +++ b/twisted/twisted_site/views/admin/users.py @@ -73,7 +73,7 @@ def get(self, request: HttpRequest, user_id: int) -> HttpResponse: context["user"] = user try: - context["staff_perms"] = model_to_dict(user.profile.staff_permissions) + context["staff_perms"] = model_to_dict(user.profile.staff_permissions) # pyrefly: ignore[missing-attribute] except AttributeError: context["staff_perms"] = None @@ -110,12 +110,12 @@ def post(self, request: HttpRequest, user_id: int) -> HttpResponse | None: ) return resp if request.POST.get("action") == "change_permissions": - if not request.user.profile.staff_permissions.superuser: + if not request.user.profile.staff_permissions.superuser: # pyrefly: ignore[missing-attribute] messages.error(request, "You are not allowed to change the permissions!") return redirect(self.request.path) key:str = request.POST["key"] # pyright: ignore[reportAssignmentType] value = request.POST.get("value") == "True" - perms = user.profile.staff_permissions + perms = user.profile.staff_permissions # pyrefly: ignore[missing-attribute] setattr(perms, key, value) perms.save() self.audit_log.pii = True @@ -124,12 +124,12 @@ def post(self, request: HttpRequest, user_id: int) -> HttpResponse | None: return redirect(self.request.path+"#adminperms") if request.POST.get("action") == "make_admin": - profile = user.profile + profile = user.profile # pyrefly: ignore[missing-attribute] profile.is_staff = True profile.staff_permissions = ProfileStaffPermissions.objects.create() self.audit_log.pii = True self.audit_log.additional_context["permission_changed"] = "Made user an admin" - messages.success(request, f"Made @{user.profile.slack_username} an admin.") + messages.success(request, f"Made @{user.profile.slack_username} an admin.") # pyrefly: ignore[missing-attribute] profile.save() return redirect(self.request.path) diff --git a/twisted/twisted_site/views/client/journal.py b/twisted/twisted_site/views/client/journal.py index d8d9606..0ed71e7 100644 --- a/twisted/twisted_site/views/client/journal.py +++ b/twisted/twisted_site/views/client/journal.py @@ -228,18 +228,25 @@ def post(self, request: HttpRequest, journal_id: int) -> HttpResponse: if journal.type != "untracked": return redirect("dashboard") - journal.delete() + _ = journal.delete() return self.get(request, journal_id=None, context={"success": True}) class EditJournal(View): - def get(self, request:HttpRequest, id:int, info:str|None=None, context:str|None=None) -> HttpResponse: + def get( + self, + request: HttpRequest, + id: int, + info: str | None = None, + context: TemplateContext | None = None, # pyrefly: ignore[explicit-any] + ) -> HttpResponse: journal = Journal.objects.get(id=id) if journal.project.user != request.user: return redirect("fr.projects.detail", journal.project.id) - context = context or {} - if info: + if context is None: + context = TemplateContext() + if info is not None: context["info"] = info context["journal"] = journal return render(request, "client/projects/journal/edit.html", context) diff --git a/twisted/twisted_site/views/client/pathways.py b/twisted/twisted_site/views/client/pathways.py index 54fd71b..a90d65d 100644 --- a/twisted/twisted_site/views/client/pathways.py +++ b/twisted/twisted_site/views/client/pathways.py @@ -55,7 +55,7 @@ def get(self, request: HttpRequest) -> HttpResponse: "profile": profile, "pathways": pathways, "current_pathways": current_pathways, - "unspent_mins": current_pathway.get_unspent_mins(request.user) if current_pathway else None, + "unspent_mins": current_pathway.get_unspent_mins(cast("AbstractBaseUser", request.user)) if current_pathway is not None else None, "past_pathways": past_pathways, "future_pathways": future_pathways, }, @@ -78,11 +78,11 @@ def post(self, request: HttpRequest, pathway_id: int) -> HttpResponse: if already_unlocked: return redirect("fr.pathways") - unspent_mins = pathway.get_unspent_mins(request.user) + unspent_mins = pathway.get_unspent_mins(cast("AbstractBaseUser", request.user)) # ty: ignore[redundant-cast] if unspent_mins < pathway.min_mins: return redirect("fr.pathways") - PathwayTimeSpent.objects.update_or_create( + _ = PathwayTimeSpent.objects.update_or_create( pathway=pathway, user=request.user, defaults={"unlocked": True, "minutes": pathway.min_mins}, From f2a1ea234d0819cf440a481f8ee987769ab3fe67 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Tue, 22 Sep 2026 14:26:19 -0400 Subject: [PATCH 009/104] Fixes for linter issues --- twisted/twisted_site/ari.py | 2 +- twisted/twisted_site/models.py | 27 +++++++++++-------- .../twisted_site/templates/admin/user.html | 4 +-- .../templates/client/dashboard.html | 13 ++++----- .../templates/cotton/client/pathway_card.html | 4 +-- twisted/twisted_site/views/admin/admin.py | 9 ++++--- twisted/twisted_site/views/admin/users.py | 10 +++---- twisted/twisted_site/views/client/journal.py | 6 ++--- twisted/twisted_site/views/client/pathways.py | 4 +-- 9 files changed, 43 insertions(+), 36 deletions(-) diff --git a/twisted/twisted_site/ari.py b/twisted/twisted_site/ari.py index f7683ad..8bb35ff 100644 --- a/twisted/twisted_site/ari.py +++ b/twisted/twisted_site/ari.py @@ -124,7 +124,7 @@ def send_ship(ship: ProjectShip) -> None: content = f"# Journal type: {journal.get_type_display()}\n\n{journal.content}" # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] journals.append( { - "at": journal.created_at.isoformat(), # ty: ignore[unresolved-attribute] + "at": journal.created_at.isoformat(), "minutes": journal.reduced_minutes, "text": content, "markdown": content, diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index cc8473b..15bbec7 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -1,3 +1,4 @@ +from datetime import timedelta from typing import Any, cast, override from django.contrib.auth import get_user_model @@ -70,19 +71,19 @@ class Profile(models.Model): @override def __str__(self) -> str: - return cast("str", self.user.username) # pyrefly: ignore[missing-attribute] # ty: ignore[unresolved-attribute] + return cast("str", self.user.username) # pyrefly: ignore[missing-attribute] def get_country(self) -> str: if self.country_cached_until is not None and self.country_cached_until > timezone.now(): return self.country # ty: ignore[unsound-return-statement] try: - user_data = hca.get_user_data(self.hca_access_token) # ty: ignore[invalid-argument-type] + user_data = hca.get_user_data(self.hca_access_token) country = user_data.primary_address.country if user_data.primary_address is not None else "Unknown" except HTTPError: country = "Unknown" - self.country = country # ty: ignore[invalid-assignment] - self.country_cached_until = timezone.now() + timezone.timedelta(hours=3) + self.country = country + self.country_cached_until = timezone.now() + timedelta(hours=3) self.save() return country @@ -277,16 +278,16 @@ class Pathway(models.Model): def __str__(self) -> str: return cast("str", self.name) # pyrefly: ignore[redundant-cast] - def get_unspent_mins(self, user: User) -> float: - total_spent = cast("Profile", user.profile).time_logged() # pyrefly: ignore[missing-attribute] - spent_on_pathways = PathwayTimeSpent.objects.filter(user=user).aggregate(total=Sum("minutes"))["total"] or 0 # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] + def get_unspent_mins(self, user: AbstractBaseUser) -> float: + total_spent = cast("Profile", user.profile).time_logged() # ty: ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-attribute] + spent_on_pathways = PathwayTimeSpent.objects.filter(user=user).aggregate(total=Sum("minutes"))["total"] or 0 # pyright: ignore[reportAttributeAccessIssue] return total_spent - spent_on_pathways def ended(self) -> bool: return timezone.now() > self.end def didnt_start(self) -> bool: - return self.start > timezone.now() + return self.start > timezone.now() # ty: ignore[unsound-return-statement] def in_progress(self) -> bool: return not self.ended() and not self.didnt_start() @@ -301,8 +302,8 @@ def status(self) -> str | None: return None def mins_spent(self, user: AbstractBaseUser) -> int: - time_spent = PathwayTimeSpent.objects.filter(pathway=self, user=user).first() # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] - return time_spent.minutes if time_spent is not None else 0 + time_spent = PathwayTimeSpent.objects.filter(pathway=self, user=user).first() # pyright: ignore[reportAttributeAccessIssue] + return time_spent.minutes if time_spent is not None else 0 # ty: ignore[unsound-return-statement] def mins_spent_per_participant(self) -> dict[int, int]: """ @@ -313,7 +314,7 @@ def mins_spent_per_participant(self) -> dict[int, int]: """ return dict( - PathwayTimeSpent.objects.filter(pathway=self).values_list("user_id", "minutes"), # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] + PathwayTimeSpent.objects.filter(pathway=self).values_list("user_id", "minutes"), # pyright: ignore[reportAttributeAccessIssue] ) def qualified_participants(self) -> list[AbstractBaseUser]: @@ -338,6 +339,10 @@ class Meta: #meta :loll: unique_together = ("pathway", "user") + @override + def __str__(self) -> str: + return f"{self.user} - {self.pathway}" + class AuditLog(models.Model): timestamp = models.DateTimeField(auto_now_add=True) user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="audit_logs") diff --git a/twisted/twisted_site/templates/admin/user.html b/twisted/twisted_site/templates/admin/user.html index d3c8f6b..9586547 100644 --- a/twisted/twisted_site/templates/admin/user.html +++ b/twisted/twisted_site/templates/admin/user.html @@ -186,7 +186,7 @@

Admin True False - + Save

@@ -212,7 +212,7 @@

Not an admin!

- + + {% block body %} meow :3 {% endblock body %} + + +
+
+ Your Region +
+ {% csrf_token %} + + + +
+
+
+
+
+ {% if user.profile.region %} +
+ {% for project in projects %} + +
+
+

+ {{ project.project_name }} +

+
+ {% if project.is_approved %} + shipped + {% endif %} + + {{ project.time_logged|minutes_to_hours_minutes }} + + + {{ project.get_project_type_display }} + +
+
+

+ {{ project.project_description }} +

+
+ + + {{ project.user.profile.slack_username|default:project.user.username }} + +
+
+ {% endfor %} +
+ {% else %} +
+
+
+
+ ^^^^ +
+
+ select a region here! +
+
+
+
+
+
No region selected
+
Please select a region to get started.
+
+
+
+ {% endif %} +
+
+ +{% endblock body %} diff --git a/twisted/twisted_site/urls.py b/twisted/twisted_site/urls.py index 6fadb43..ccaaa7a 100644 --- a/twisted/twisted_site/urls.py +++ b/twisted/twisted_site/urls.py @@ -77,6 +77,8 @@ name="fr.referrals", ), path("dashboard/frame/discover/", client.DiscoverView.as_view(), name="fr.discover"), + path("dashboard/frame/shop/", client.ShopView.as_view(), name="fr.shop"), + path("admin/", admin.DashboardView.as_view(), name="admin.dash"), path("admin/users/", admin.UsersView.as_view(), name="admin.users"), path( @@ -97,6 +99,7 @@ ), path("admin/fulfillment/", admin.FulfillmentView.as_view(), name="admin.fulfillment"), path("admin/shop/", admin.ShopView.as_view(), name="admin.shop"), + path("admin/shop/regions/", admin.ShopRegionsView.as_view(), name="admin.shop.regions"), path("admin/review/", admin.ReviewView.as_view(), name="admin.review"), path( "admin/announcements/", diff --git a/twisted/twisted_site/views/admin/__init__.py b/twisted/twisted_site/views/admin/__init__.py index a31bf7e..56748f0 100644 --- a/twisted/twisted_site/views/admin/__init__.py +++ b/twisted/twisted_site/views/admin/__init__.py @@ -8,7 +8,7 @@ PathwayListView, ) from .review import ReviewView -from .shop import ShopView +from .shop import ShopRegionsView, ShopView from .users import UserDetailView, UsersView __all__ = [ @@ -20,6 +20,7 @@ "PathwayDetailView", "PathwayListView", "ReviewView", + "ShopRegionsView", "ShopView", "UserDetailView", "UsersView", diff --git a/twisted/twisted_site/views/admin/admin.py b/twisted/twisted_site/views/admin/admin.py index 0afe32b..773292f 100644 --- a/twisted/twisted_site/views/admin/admin.py +++ b/twisted/twisted_site/views/admin/admin.py @@ -23,10 +23,13 @@ class AdminView(View): perms: ProfileStaffPermissions # pyright: ignore[reportUninitializedInstanceVariable] allowed = False - def get_context_data(self, page: str, subpage: str | None = None) -> dict[str, Any]: # pyrefly: ignore[explicit-any] + page = None + subpage = None + + def get_context_data(self, page: str | None = None, subpage: str | None = None) -> dict[str, Any]: # pyrefly: ignore[explicit-any] context: dict[str, Any] = {} # pyrefly: ignore[explicit-any] - context["page"] = page - context["subpage"] = subpage + context["page"] = page or self.page + context["subpage"] = subpage or self.subpage sidebar_links = [ SidebarLink( name="dashboard", diff --git a/twisted/twisted_site/views/admin/dashboard.py b/twisted/twisted_site/views/admin/dashboard.py index a6e5f33..c55cf6c 100644 --- a/twisted/twisted_site/views/admin/dashboard.py +++ b/twisted/twisted_site/views/admin/dashboard.py @@ -12,9 +12,9 @@ # Create your views here. class DashboardView(AdminView): allowed = True - + page = "dashboard" def get(self, request: HttpRequest) -> HttpResponse: - context = self.get_context_data(page="dashboard") + context = self.get_context_data() if self.request.user.is_anonymous: return redirect("homepage") hours_logged = 0 diff --git a/twisted/twisted_site/views/admin/shop.py b/twisted/twisted_site/views/admin/shop.py index 6e7a5a0..aa86451 100644 --- a/twisted/twisted_site/views/admin/shop.py +++ b/twisted/twisted_site/views/admin/shop.py @@ -1,5 +1,9 @@ +from django.contrib import messages +from django.db.models import ProtectedError from django.http import HttpRequest, HttpResponse -from django.shortcuts import render +from django.shortcuts import get_object_or_404, redirect, render + +from twisted_site.models import ShopRegion from .admin import AdminView @@ -14,3 +18,74 @@ def get(self, request: HttpRequest) -> HttpResponse: context = self.get_context_data(page="shop") return render(request, "admin/shop.html", context=context) + + +class ShopRegionsView(AdminView): + def get(self, request: HttpRequest) -> HttpResponse: + if self.perms.manage_shop: + self.allowed = True + else: + return HttpResponse("err") + + context = self.get_context_data(page="shop", subpage="regions") + context["regions"] = ShopRegion.objects.order_by("name").all() + return render(request, "admin/shop_regions.html", context=context) + + def post(self, request: HttpRequest) -> HttpResponse: + if self.perms.manage_shop: + self.allowed = True + else: + return HttpResponse("err") + + if not isinstance(self.audit_log.additional_context, dict): + self.audit_log.additional_context = {} + + action = request.POST.get("action") + + if action == "create": + name = (request.POST.get("name") or "").strip() + if name == "": + messages.error(request, "Region name cannot be empty!") + return redirect("admin.shop.regions") + + region = ShopRegion.objects.create(name=name) + self.audit_log.additional_context["action"] = "create_region" + self.audit_log.additional_context["region_id"] = region.id + self.audit_log.additional_context["region_name"] = region.name + messages.success(request, f'Successfully created region "{region.name}"!') + + elif action == "update": + region = get_object_or_404(ShopRegion, id=request.POST.get("region_id")) + name = (request.POST.get("name") or "").strip() + if name == "": + messages.error(request, "Region name cannot be empty!") + return redirect("admin.shop.regions") + + self.audit_log.additional_context["action"] = "update_region" + self.audit_log.additional_context["region_id"] = region.id + self.audit_log.additional_context["old_name"] = region.name + self.audit_log.additional_context["new_name"] = name + + region.name = name + region.save() + messages.success(request, f'Successfully renamed region to "{region.name}"!') + + elif action == "delete": + region = get_object_or_404(ShopRegion, id=request.POST.get("region_id")) + + self.audit_log.additional_context["action"] = "delete_region" + self.audit_log.additional_context["region_id"] = region.id + self.audit_log.additional_context["region_name"] = region.name + + try: + region.delete() + except ProtectedError: + messages.error( + request, + f'Cannot delete region "{region.name}" because it still has pricing attached to it!', + ) + return redirect("admin.shop.regions") + + messages.success(request, f'Successfully deleted region "{region.name}"!') + + return redirect("admin.shop.regions") diff --git a/twisted/twisted_site/views/client/__init__.py b/twisted/twisted_site/views/client/__init__.py index bfc287d..e43a8c3 100644 --- a/twisted/twisted_site/views/client/__init__.py +++ b/twisted/twisted_site/views/client/__init__.py @@ -11,6 +11,7 @@ from .project import ProjectDetail, ProjectSettings, SubmitProject from .projects import CreateProject, ListProjects from .referrals import ReferralsView +from .shop import ShopView __all__ = [ "CreateProject", @@ -27,6 +28,7 @@ "ProjectDetail", "ProjectSettings", "ReferralsView", + "ShopView", "SubmitProject", "UnlockPathway", ] diff --git a/twisted/twisted_site/views/client/shop.py b/twisted/twisted_site/views/client/shop.py new file mode 100644 index 0000000..977d449 --- /dev/null +++ b/twisted/twisted_site/views/client/shop.py @@ -0,0 +1,42 @@ +from django.urls import reverse +from django.core.paginator import Paginator +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect, render +from django.utils import timezone +from django.views import View + +from twisted_site.models import Pathway, Profile, Project, ShopRegion + +PROJECTS_PER_PAGE = 120 + + +class ShopView(View): + def get(self, request: HttpRequest) -> HttpResponse: + if self.request.user.is_anonymous: + return redirect("homepage") + + context = {} + + regions = ShopRegion.objects.all() + context["regions"] = regions + pathways = Pathway.objects.filter(start__lt=timezone.now(), pathwaytimespent__user=request.user, pathwaytimespent__unlocked=True) + context["pathways"] = pathways + + return render( + request, + "client/shop.html", + context, + ) + + def post(self, request: HttpRequest) -> HttpResponse: + if self.request.user.is_anonymous: + return redirect("homepage") + + if request.POST.get("action") == "setRegion": + profile: Profile = self.request.user.profile + profile.region = ShopRegion.objects.get(id=request.POST["region"]) + profile.save() + + return redirect(request.path_info) + + return redirect(request.path_info) From 2b107e56696373d727d36ee2aa6cebbf177f039f Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 12:20:08 -0400 Subject: [PATCH 039/104] Journal creation, update, and Hackatime tracking tests --- twisted/twisted_site/tests/test_journals.py | 277 +++++++++++++++++++ twisted/twisted_site/views/client/journal.py | 4 +- 2 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 twisted/twisted_site/tests/test_journals.py diff --git a/twisted/twisted_site/tests/test_journals.py b/twisted/twisted_site/tests/test_journals.py new file mode 100644 index 0000000..e26360c --- /dev/null +++ b/twisted/twisted_site/tests/test_journals.py @@ -0,0 +1,277 @@ +from datetime import UTC, datetime +from typing import cast, override +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.http import HttpResponse +from django.test import Client, TestCase +from django.urls import reverse + +from twisted_site.hackatime import HackatimeProject +from twisted_site.models import Journal, Profile, Project, ProjectShip + + +class JournalContentMixin: + def content(self, *, words: int, images: int) -> str: + prose = " ".join(["word"] * words) + evidence = " ".join( + f"![Evidence {index}](https://example.com/{index}.png)" for index in range(images) + ) + return f"{prose}\n{evidence}" + + +class HackatimeJournalWorkflowTests(JournalContentMixin, TestCase): + user: User # pyright: ignore[reportUninitializedInstanceVariable] + profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + project: Project # pyright: ignore[reportUninitializedInstanceVariable] + other_user: User # pyright: ignore[reportUninitializedInstanceVariable] + other_profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + hackatime_projects: list[HackatimeProject] # pyright: ignore[reportUninitializedInstanceVariable] + + @override + def setUp(self) -> None: + self.user = User.objects.create_user(username="journal-owner") + self.profile = Profile.objects.create(user=self.user) + self.project = Project.objects.create( + user=self.user, + project_name="Hackatime Journal", + project_description="Description", + project_type="software", + ) + self.other_user = User.objects.create_user(username="other-user") + self.other_profile = Profile.objects.create(user=self.other_user) + self.hackatime_projects = [ + HackatimeProject( + name="Hackatime Journal", + total_seconds=181 * 60, + most_recent_heartbeat=datetime(2026, 1, 1, tzinfo=UTC), + languages=[], + ), + ] + self.client = Client() + self.client.force_login(self.user) + + def post_journal(self, content: str) -> HttpResponse: + with ( + patch.object( + Project, + "get_hackatime_projects", + return_value=self.hackatime_projects, + ), + patch("twisted_site.views.client.journal.log_to_channel"), + ): + return cast( + "HttpResponse", + cast( + "object", + self.client.post( + reverse( + "fr.projects.journals.new.hackatime", + kwargs={"project_id": self.project.pk}, + ), + {"content": content}, + ), + ), + ) + + def test_valid_hackatime_journal_is_saved(self) -> None: + response = self.post_journal(self.content(words=60, images=2)) + + self.assertEqual(response.status_code, 200) + journal = Journal.objects.get(project=self.project) + self.assertEqual(journal.type, "hackatime") + self.assertEqual(journal.minutes_worked, 181) + self.assertEqual(journal.reduced_minutes, 181) + + def test_hackatime_journal_requires_enough_images(self) -> None: + response = self.post_journal(self.content(words=60, images=1)) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Journal.objects.exists()) + + def test_hackatime_journal_requires_enough_prose(self) -> None: + response = self.post_journal(self.content(words=5, images=2)) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Journal.objects.exists()) + + def test_non_owner_cannot_create_hackatime_journal(self) -> None: + self.client.force_login(self.other_user) + + response = self.post_journal(self.content(words=60, images=2)) + + self.assertRedirects(response, reverse("dashboard")) + self.assertFalse(Journal.objects.exists()) + + def test_shipped_project_cannot_create_hackatime_journal(self) -> None: + _ = ProjectShip.objects.create(project=self.project) + + response = self.post_journal(self.content(words=60, images=2)) + + self.assertRedirects( + response, + reverse("fr.projects.detail", kwargs={"project_id": self.project.pk}), + ) + self.assertFalse(Journal.objects.exists()) + + +class UntrackedJournalWorkflowTests(JournalContentMixin, TestCase): + user: User # pyright: ignore[reportUninitializedInstanceVariable] + profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + project: Project # pyright: ignore[reportUninitializedInstanceVariable] + + @override + def setUp(self) -> None: + self.user = User.objects.create_user(username="hardware-owner") + self.profile = Profile.objects.create(user=self.user) + self.project = Project.objects.create( + user=self.user, + project_name="Hardware Journal", + project_description="Description", + project_type="hardware", + ) + self.client = Client() + self.client.force_login(self.user) + + def post_journal(self, time_logged: int, content: str) -> HttpResponse: + with patch.object(Project, "get_hackatime_projects", return_value=[]): + return cast( + "HttpResponse", + cast( + "object", + self.client.post( + reverse( + "fr.projects.journals.new.untracked", + kwargs={"project_id": self.project.pk}, + ), + { + "content": content, + "time_logged": str(time_logged), + }, + ), + ), + ) + + def test_valid_untracked_journal_is_saved(self) -> None: + response = self.post_journal(30, self.content(words=60, images=0)) + + self.assertEqual(response.status_code, 200) + journal = Journal.objects.get(project=self.project) + self.assertEqual(journal.type, "untracked") + self.assertEqual(journal.minutes_worked, 30) + self.assertEqual(journal.reduced_minutes, 30) + + def test_untracked_journal_cannot_exceed_sixty_minutes(self) -> None: + response = self.post_journal(61, self.content(words=100, images=0)) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Journal.objects.exists()) + + def test_untracked_journal_cannot_be_negative(self) -> None: + response = self.post_journal(-1, self.content(words=100, images=0)) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Journal.objects.exists()) + + def test_software_project_is_redirected_to_hackatime_journal(self) -> None: + _ = Project.objects.filter(pk=self.project.pk).update(project_type="software") + + response = self.post_journal(30, self.content(words=60, images=0)) + + self.assertRedirects( + response, + reverse( + "fr.projects.journals.new.hackatime", + kwargs={"project_id": self.project.pk}, + ), + ) + self.assertFalse(Journal.objects.exists()) + + +class JournalMutationTests(JournalContentMixin, TestCase): + user: User # pyright: ignore[reportUninitializedInstanceVariable] + profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + other_user: User # pyright: ignore[reportUninitializedInstanceVariable] + other_profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + project: Project # pyright: ignore[reportUninitializedInstanceVariable] + journal: Journal # pyright: ignore[reportUninitializedInstanceVariable] + + @override + def setUp(self) -> None: + self.user = User.objects.create_user(username="mutation-owner") + self.profile = Profile.objects.create(user=self.user) + self.other_user = User.objects.create_user(username="mutation-other") + self.other_profile = Profile.objects.create(user=self.other_user) + self.project = Project.objects.create( + user=self.user, + project_name="Mutable Journal", + project_description="Description", + project_type="software", + ) + self.journal = Journal.objects.create( + project=self.project, + type="untracked", + content="Original content", + minutes_worked=30, + reduced_minutes=30, + ) + self.client = Client() + self.client.force_login(self.user) + + def delete_url(self) -> str: + return reverse( + "fr.projects.journals.delete", + kwargs={"journal_id": self.journal.pk}, + ) + + def edit_url(self) -> str: + return reverse( + "fr.projects.journals.edit", + kwargs={"id": self.journal.pk}, + ) + + def test_owner_can_delete_untracked_journal(self) -> None: + response = self.client.post(self.delete_url()) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Journal.objects.filter(pk=self.journal.pk).exists()) + + def test_non_owner_cannot_delete_journal(self) -> None: + self.client.force_login(self.other_user) + + response = self.client.post(self.delete_url()) + + self.assertRedirects(response, reverse("dashboard")) + self.assertTrue(Journal.objects.filter(pk=self.journal.pk).exists()) + + def test_shipped_project_journal_cannot_be_deleted(self) -> None: + _ = ProjectShip.objects.create(project=self.project) + + response = self.client.post(self.delete_url()) + + self.assertRedirects( + response, + reverse("fr.projects.detail", kwargs={"project_id": self.project.pk}), + ) + self.assertTrue(Journal.objects.filter(pk=self.journal.pk).exists()) + + def test_edit_still_requires_evidence_and_prose(self) -> None: + with patch("twisted_site.views.client.journal.log_to_channel"): + response = self.client.post( + self.edit_url(), + {"content": "Too short without evidence"}, + ) + + self.assertEqual(response.status_code, 200) + self.journal.refresh_from_db() + self.assertEqual(self.journal.content, "Original content") + + def test_owner_can_edit_untracked_journal(self) -> None: + new_content = self.content(words=20, images=1) + with patch("twisted_site.views.client.journal.log_to_channel") as log_to_channel: + response = self.client.post(self.edit_url(), {"content": new_content}) + + self.assertEqual(response.status_code, 200) + self.journal.refresh_from_db() + self.assertEqual(self.journal.content, new_content) + log_to_channel.assert_called_once() diff --git a/twisted/twisted_site/views/client/journal.py b/twisted/twisted_site/views/client/journal.py index b9a9748..ba8f728 100644 --- a/twisted/twisted_site/views/client/journal.py +++ b/twisted/twisted_site/views/client/journal.py @@ -119,7 +119,7 @@ def get( return redirect("dashboard") if project.project_type == "software": - return redirect("fr.projects.journals.new.hackatime") + return redirect("fr.projects.journals.new.hackatime", project_id=project_id) context["project"] = project @@ -143,7 +143,7 @@ def post(self, request: HttpRequest, project_id: int) -> HttpResponse: return redirect("dashboard") if project.project_type == "software": - return redirect("fr.projects.journals.new.hackatime") + return redirect("fr.projects.journals.new.hackatime", project_id=project_id) content = request.POST["content"] time_logged = int(request.POST["time_logged"]) From 5d90e599dfa40144e7e80ae2d6f37eb636f1a267 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 12:20:18 -0400 Subject: [PATCH 040/104] Merge leaf node migrations --- ...0036_merge_20260922_1730_0038_profile_region.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 twisted/twisted_site/migrations/0039_merge_0036_merge_20260922_1730_0038_profile_region.py diff --git a/twisted/twisted_site/migrations/0039_merge_0036_merge_20260922_1730_0038_profile_region.py b/twisted/twisted_site/migrations/0039_merge_0036_merge_20260922_1730_0038_profile_region.py new file mode 100644 index 0000000..abf4af9 --- /dev/null +++ b/twisted/twisted_site/migrations/0039_merge_0036_merge_20260922_1730_0038_profile_region.py @@ -0,0 +1,14 @@ +# Generated by Django 6.0.7 on 2026-09-23 16:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0036_merge_20260922_1730'), + ('twisted_site', '0038_profile_region'), + ] + + operations = [ + ] From 6e883128b6c29f3b7e27413ebeb9d3d98d4b8ac8 Mon Sep 17 00:00:00 2001 From: KavyanshKhaitan2 Date: Wed, 23 Sep 2026 22:27:09 +0530 Subject: [PATCH 041/104] Add something :idkman: --- .claude/worktrees/fix-docker-build | 1 + .../templates/admin/pathways/detail.html | 116 ++++++++++++++++++ .../templates/admin/pathways/listing.html | 2 + twisted/twisted_site/urls.py | 5 + twisted/twisted_site/views/admin/__init__.py | 2 + twisted/twisted_site/views/admin/pathways.py | 39 +++++- 6 files changed, 164 insertions(+), 1 deletion(-) create mode 160000 .claude/worktrees/fix-docker-build create mode 100644 twisted/twisted_site/templates/admin/pathways/listing.html diff --git a/.claude/worktrees/fix-docker-build b/.claude/worktrees/fix-docker-build new file mode 160000 index 0000000..290daf5 --- /dev/null +++ b/.claude/worktrees/fix-docker-build @@ -0,0 +1 @@ +Subproject commit 290daf51e269597b56ca6aa03d98a1b75b34fb9b diff --git a/twisted/twisted_site/templates/admin/pathways/detail.html b/twisted/twisted_site/templates/admin/pathways/detail.html index 9ec294a..fbc02ea 100644 --- a/twisted/twisted_site/templates/admin/pathways/detail.html +++ b/twisted/twisted_site/templates/admin/pathways/detail.html @@ -96,5 +96,121 @@

No participants yet

{% endif %} + +
+

Shop listings

+ {% if shop_items %} + + Create new listing + + + + Name + Description + Last updated + + + +
+ {% for item in shop_items %} + + + {{ item.item_name }} + + {{ item.item_description }} + + {{ item.updated_at }} + + + +
+
+
+
+ {% endfor %} + +
+
+ {% else %} + +

No shop items yet

+

Create a new shop item.

+ Create new listing +
+ {% endif %} +
+
+ +
+
+ New shop listing for {{ pathway.name }} +
+ + × + +
+
+
+ {% csrf_token %} + +
+ +
+
+ +
+
+ Regions and availability + + + Available + Region + Price + + + {% for region in shop_regions %} + + + + + + {{ region.name }} + + + + + + {% endfor %} + + +
+ + Submit + +
+
+ +
+
+ +
diff --git a/twisted/twisted_site/templates/admin/pathways/listing.html b/twisted/twisted_site/templates/admin/pathways/listing.html new file mode 100644 index 0000000..494c52b --- /dev/null +++ b/twisted/twisted_site/templates/admin/pathways/listing.html @@ -0,0 +1,2 @@ +{% load time_filters %} +faa \ No newline at end of file diff --git a/twisted/twisted_site/urls.py b/twisted/twisted_site/urls.py index ccaaa7a..1a78153 100644 --- a/twisted/twisted_site/urls.py +++ b/twisted/twisted_site/urls.py @@ -92,6 +92,11 @@ admin.PathwayDetailView.as_view(), name="admin.pathways.detail", ), + path( + "admin/pathways/_shopitems//", + admin.PathwayShopItemDetailView.as_view(), + name="admin.pathways.shopitems", + ), path( "admin/pathways/new/", admin.PathwayCreateView.as_view(), diff --git a/twisted/twisted_site/views/admin/__init__.py b/twisted/twisted_site/views/admin/__init__.py index 56748f0..dc64db2 100644 --- a/twisted/twisted_site/views/admin/__init__.py +++ b/twisted/twisted_site/views/admin/__init__.py @@ -6,6 +6,7 @@ PathwayCreateView, PathwayDetailView, PathwayListView, + PathwayShopItemDetailView, ) from .review import ReviewView from .shop import ShopRegionsView, ShopView @@ -19,6 +20,7 @@ "PathwayCreateView", "PathwayDetailView", "PathwayListView", + "PathwayShopItemDetailView", "ReviewView", "ShopRegionsView", "ShopView", diff --git a/twisted/twisted_site/views/admin/pathways.py b/twisted/twisted_site/views/admin/pathways.py index e37e08b..3103d5f 100644 --- a/twisted/twisted_site/views/admin/pathways.py +++ b/twisted/twisted_site/views/admin/pathways.py @@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone -from twisted_site.models import Pathway, User +from twisted_site.models import Pathway, User, ShopItem, ShopRegion from .admin import AdminView @@ -166,4 +166,41 @@ def get(self, request: HttpRequest, pathway_id: int) -> HttpResponse: context["participants"] = participants context["qualified_count"] = sum(1 for p in participants if p["qualified"]) + context["shop_items"] = ShopItem.objects.filter(pathway=pathway) + context["shop_regions"] = ShopRegion.objects.all() + return render(request, "admin/pathways/detail.html", context=context) + + def post(self, request:HttpRequest, pathway_id) -> HttpResponse: + if self.perms.manage_shop: + self.allowed = True + else: + return HttpResponse("err") + + pathway = get_object_or_404(Pathway, id=pathway_id) + + if request.POST.get("action") == "new_listing": + item_name = request.POST["name"] + item_description = request.POST["description"] + ShopItem.objects.create( + pathway = pathway, + item_name = item_name, + item_description = item_description, + ) + messages.success(request, f"Created new shop listing for {item_name}") + return redirect(request.path_info) + + return redirect(request.path_info) + +class PathwayShopItemDetailView(AdminView): + def get(self, request: HttpRequest, listing_id: int) -> HttpResponse: + context = self.get_context_data() + if self.perms.view_pathways: + self.allowed = True + else: + return HttpResponse("err") + + item = ShopItem.objects.get(id=listing_id) + context["item"] = item + + return render(request, "admin/pathways/listing.html", context) \ No newline at end of file From f4cba700b5681820d109a882bedb726b4cd56dbd Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 14:59:02 -0400 Subject: [PATCH 042/104] More edge case tests for existing test files --- twisted/twisted_site/tests/test_admin.py | 32 +++++ twisted/twisted_site/tests/test_ari.py | 114 +++++++++++++++++- twisted/twisted_site/tests/test_auth.py | 26 +++- twisted/twisted_site/tests/test_journals.py | 66 ++++++++++ twisted/twisted_site/tests/test_submission.py | 25 ++++ 5 files changed, 260 insertions(+), 3 deletions(-) diff --git a/twisted/twisted_site/tests/test_admin.py b/twisted/twisted_site/tests/test_admin.py index bd4665f..7ab7f77 100644 --- a/twisted/twisted_site/tests/test_admin.py +++ b/twisted/twisted_site/tests/test_admin.py @@ -46,6 +46,38 @@ def test_staff_permissions_are_created_on_first_admin_request(self) -> None: profile.refresh_from_db() self.assertIsNotNone(profile.staff_permissions) + def test_staff_without_granular_permissions_cannot_open_admin_pages(self) -> None: + protected_pages = ( + "admin.users", + "admin.pathways", + "admin.review", + "admin.announcements", + "admin.logs", + "admin.fulfillment", + "admin.shop", + ) + for page in protected_pages: + with self.subTest(page=page): + response = self.client.get(reverse(page)) + + self.assertRedirects(response, reverse("admin.dash")) + + def test_view_users_permission_grants_user_administration_access(self) -> None: + self.permissions.view_users = True + self.permissions.save(update_fields=("view_users",)) + + response = self.client.get(reverse("admin.users")) + + self.assertEqual(response.status_code, 200) + + def test_view_audit_logs_permission_grants_audit_access(self) -> None: + self.permissions.view_auditlogs = True + self.permissions.save(update_fields=("view_auditlogs",)) + + response = self.client.get(f"{reverse('admin.logs')}?page=1") + + self.assertEqual(response.status_code, 200) + def test_logout_all_requires_superuser_and_does_not_delete_sessions(self) -> None: session = SessionStore() _ = session.create() diff --git a/twisted/twisted_site/tests/test_ari.py b/twisted/twisted_site/tests/test_ari.py index c6b846b..a497514 100644 --- a/twisted/twisted_site/tests/test_ari.py +++ b/twisted/twisted_site/tests/test_ari.py @@ -190,8 +190,7 @@ def setUp(self) -> None: ) self.ship = ProjectShip.objects.create(project=self.project) - def post_webhook(self, payload: dict[str, object]) -> HttpResponse: - body = json.dumps(payload).encode() + def post_raw_webhook(self, body: bytes) -> HttpResponse: timestamp = str(_NOW) delivery_id = "delivery-webhook-test" expected_signature = _webhook_signature(body, timestamp, delivery_id) @@ -217,6 +216,9 @@ def post_webhook(self, payload: dict[str, object]) -> HttpResponse: ), ) + def post_webhook(self, payload: dict[str, object]) -> HttpResponse: + return self.post_raw_webhook(json.dumps(payload).encode()) + def test_rejects_invalid_signature_without_changing_ship(self) -> None: with ( patch("twisted_site.ari.ARI_WEBHOOK_SECRET", "outbound-secret"), @@ -282,6 +284,114 @@ def test_review_changes_ignores_mismatched_decision(self) -> None: self.assertEqual(self.ship.status, "pending") send_blocks.assert_not_called() + def test_ship_updated_event_updates_project_fields(self) -> None: + with patch("twisted_site.views.ari.send_blocks") as send_blocks: + response = self.post_webhook( + { + "external_id": f"twisted-{self.project.pk}", + "event": "ship.updated", + "ship": { + "title": "Reviewed title", + "description": "Reviewed description", + "track": "hardware", + "thumbnail_url": "https://example.com/new.png", + "repo_url": "https://github.com/example/repo", + "demo_url": "https://example.com/new-demo", + "hackatime_projects": ["New Project"], + }, + "changes": [], + }, + ) + + self.assertEqual(response.status_code, 200) + self.project.refresh_from_db() + self.assertEqual(self.project.project_name, "Reviewed title") + self.assertEqual(self.project.project_type, "hardware") + self.assertEqual(self.project.hackatime_project_names, ["New Project"]) + send_blocks.assert_called_once() + + def test_review_changes_event_requests_changes(self) -> None: + with patch("twisted_site.views.ari.send_blocks") as send_blocks: + response = self.post_webhook( + { + "external_id": f"twisted-{self.project.pk}", + "event": "review.changes", + "decision": "changes", + "review": {"note_to_maker": "Please revise"}, + }, + ) + + self.assertEqual(response.status_code, 200) + self.ship.refresh_from_db() + self.assertEqual(self.ship.status, "requested_changes") + self.assertEqual(self.ship.note_to_maker, "Please revise") + send_blocks.assert_called_once() + + def test_review_rejected_event_records_rejection(self) -> None: + with patch("twisted_site.views.ari.send_blocks"): + response = self.post_webhook( + { + "external_id": f"twisted-{self.project.pk}", + "event": "review.rejected", + "review": { + "note_to_maker": "Not eligible", + "audit_note": "Requirements not met", + "justification": {}, + }, + }, + ) + + self.assertEqual(response.status_code, 200) + self.ship.refresh_from_db() + self.assertEqual(self.ship.status, "rejected") + self.assertEqual(self.ship.note_to_maker, "Not eligible") + + def test_reverted_and_requeued_events_reset_pending_status(self) -> None: + for event in ("review.reverted", "review.requeued"): + with self.subTest(event=event): + self.ship.status = "approved" + self.ship.save(update_fields=("status",)) + with patch("twisted_site.views.ari.send_blocks"): + response = self.post_webhook( + { + "external_id": f"twisted-{self.project.pk}", + "event": event, + }, + ) + + self.assertEqual(response.status_code, 200) + self.ship.refresh_from_db() + self.assertEqual(self.ship.status, "pending") + + def test_event_without_ship_returns_bad_request(self) -> None: + _ = self.ship.delete() + + response = self.post_webhook( + { + "external_id": f"twisted-{self.project.pk}", + "event": "review.changes", + "decision": "changes", + "review": {"note_to_maker": "Cannot process"}, + }, + ) + + self.assertEqual(response.status_code, 400) + + def test_malformed_signed_json_returns_bad_request(self) -> None: + response = self.post_raw_webhook(b"not-json") + + self.assertEqual(response.status_code, 400) + + def test_invalid_external_id_returns_bad_request(self) -> None: + response = self.post_webhook( + { + "external_id": "twisted-not-an-id", + "event": "review.approved", + }, + ) + + self.assertEqual(response.status_code, 400) + def test_unknown_event_is_ignored(self) -> None: with patch("twisted_site.views.ari.send_blocks") as send_blocks: response = self.post_webhook( diff --git a/twisted/twisted_site/tests/test_auth.py b/twisted/twisted_site/tests/test_auth.py index 70f593e..8184c70 100644 --- a/twisted/twisted_site/tests/test_auth.py +++ b/twisted/twisted_site/tests/test_auth.py @@ -35,13 +35,37 @@ def setUp(self) -> None: def test_state_mismatch_is_rejected_and_state_is_cleared(self) -> None: response = self.client.get( reverse("hackatime_callback"), - {"state": "wrong-state"}, + {"state": "wrong-state", "code": "unused-code"}, ) self.assertEqual(response.status_code, 200) self.profile.refresh_from_db() self.assertEqual(self.profile.hackatime_state, "") + def test_anonymous_callback_redirects_to_login(self) -> None: + self.client.logout() + + response = self.client.get( + reverse("hackatime_callback"), + {"state": "expected-state", "code": "authorization-code"}, + ) + + self.assertRedirects(response, reverse("login"), fetch_redirect_response=False) + + def test_missing_oauth_parameters_return_bad_request(self) -> None: + invalid_parameters: tuple[dict[str, str], ...] = ( + {}, + {"state": "expected-state"}, + {"code": "authorization-code"}, + ) + for parameters in invalid_parameters: + with self.subTest(parameters=parameters): + response = self.client.get(reverse("hackatime_callback"), parameters) + + self.assertEqual(response.status_code, 400) + self.profile.refresh_from_db() + self.assertEqual(self.profile.hackatime_state, "expected-state") + def test_valid_callback_stores_token_and_cannot_be_replayed(self) -> None: me = MeResponse( id=123, diff --git a/twisted/twisted_site/tests/test_journals.py b/twisted/twisted_site/tests/test_journals.py index e26360c..ba9891d 100644 --- a/twisted/twisted_site/tests/test_journals.py +++ b/twisted/twisted_site/tests/test_journals.py @@ -95,6 +95,28 @@ def test_hackatime_journal_requires_enough_prose(self) -> None: self.assertEqual(response.status_code, 200) self.assertFalse(Journal.objects.exists()) + def test_hackatime_journal_requires_available_unlogged_time(self) -> None: + _ = Journal.objects.create( + project=self.project, + type="hackatime", + content="Existing work", + minutes_worked=181, + reduced_minutes=181, + ) + self.hackatime_projects = [ + HackatimeProject( + name="Hackatime Journal", + total_seconds=0, + most_recent_heartbeat=datetime(2026, 1, 1, tzinfo=UTC), + languages=[], + ), + ] + + response = self.post_journal(self.content(words=60, images=2)) + + self.assertEqual(response.status_code, 200) + self.assertEqual(Journal.objects.filter(project=self.project).count(), 1) + def test_non_owner_cannot_create_hackatime_journal(self) -> None: self.client.force_login(self.other_user) @@ -173,6 +195,16 @@ def test_untracked_journal_cannot_be_negative(self) -> None: self.assertEqual(response.status_code, 200) self.assertFalse(Journal.objects.exists()) + def test_non_owner_cannot_create_untracked_journal(self) -> None: + other_user = User.objects.create_user(username="untracked-other") + _ = Profile.objects.create(user=other_user) + self.client.force_login(other_user) + + response = self.post_journal(30, self.content(words=60, images=0)) + + self.assertRedirects(response, reverse("dashboard")) + self.assertFalse(Journal.objects.exists()) + def test_software_project_is_redirected_to_hackatime_journal(self) -> None: _ = Project.objects.filter(pk=self.project.pk).update(project_type="software") @@ -255,6 +287,40 @@ def test_shipped_project_journal_cannot_be_deleted(self) -> None: ) self.assertTrue(Journal.objects.filter(pk=self.journal.pk).exists()) + def test_hackatime_journal_cannot_be_deleted(self) -> None: + journal = Journal.objects.create( + project=self.project, + type="hackatime", + content="Hackatime evidence", + minutes_worked=30, + reduced_minutes=30, + ) + delete_url = reverse( + "fr.projects.journals.delete", + kwargs={"journal_id": journal.pk}, + ) + + response = self.client.post(delete_url) + + self.assertRedirects(response, reverse("dashboard")) + self.assertTrue(Journal.objects.filter(pk=journal.pk).exists()) + + def test_shipped_project_journal_cannot_be_edited(self) -> None: + _ = ProjectShip.objects.create(project=self.project) + new_content = self.content(words=20, images=1) + + get_response = self.client.get(self.edit_url()) + post_response = self.client.post(self.edit_url(), {"content": new_content}) + + detail_url = reverse( + "fr.projects.detail", + kwargs={"project_id": self.project.pk}, + ) + self.assertRedirects(get_response, detail_url) + self.assertRedirects(post_response, detail_url) + self.journal.refresh_from_db() + self.assertEqual(self.journal.content, "Original content") + def test_edit_still_requires_evidence_and_prose(self) -> None: with patch("twisted_site.views.client.journal.log_to_channel"): response = self.client.post( diff --git a/twisted/twisted_site/tests/test_submission.py b/twisted/twisted_site/tests/test_submission.py index 8bd07b0..18a301f 100644 --- a/twisted/twisted_site/tests/test_submission.py +++ b/twisted/twisted_site/tests/test_submission.py @@ -76,6 +76,31 @@ def test_non_owner_cannot_submit_another_users_project(self) -> None: self.assertFalse(ProjectShip.objects.filter(project=self.project).exists()) send_ship.assert_not_called() + def test_ineligible_user_cannot_submit(self) -> None: + self.profile.ysws_eligible = False + self.profile.save(update_fields=("ysws_eligible",)) + with ( + patch("twisted_site.views.client.project.ari.send_ship") as send_ship, + patch("twisted_site.views.client.project.log_to_channel"), + ): + response = self.client.post(self.ship_url()) + + self.assertEqual(response.status_code, 200) + self.assertFalse(ProjectShip.objects.filter(project=self.project).exists()) + send_ship.assert_not_called() + + def test_shipped_project_cannot_be_submitted_twice(self) -> None: + _ = ProjectShip.objects.create(project=self.project) + with ( + patch("twisted_site.views.client.project.ari.send_ship") as send_ship, + patch("twisted_site.views.client.project.log_to_channel"), + ): + response = self.client.post(self.ship_url()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(ProjectShip.objects.filter(project=self.project).count(), 1) + send_ship.assert_not_called() + def test_submission_requires_playable_and_screenshot_urls(self) -> None: invalid_values = ( ("", "https://example.com/screenshot.png"), From 1d48409d2a824c5b33078e598621795f9c0837b6 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:07:04 -0400 Subject: [PATCH 043/104] Test bad image content rejection with image construction --- pyproject.toml | 3 +- .../twisted_site/tests/test_image_upload.py | 47 ++++++++++++-- uv.lock | 64 +++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b4f3734..815a1ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "django-tailwind[cookiecutter,honcho,reload]>=4.5.0", "gunicorn>=26.0.0", "psycopg[binary]>=3.3.4", + "pillow>=11.3.0", "python-dotenv>=1.2.2", "requests>=2.34.2", "slack-bolt>=1.30.0", @@ -38,7 +39,7 @@ ignore = ["A002", "C901", "CPY001", "D100", "D101", "D102", "D103", "D104", "D10 [tool.ruff.lint.per-file-ignores] "*/migrations/*.py" = ["RUF012", "ALL"] -"*/tests/*.py" = ["PT009", "PT027"] +"*/tests/*.py" = ["PT009", "PT027", "S106"] [tool.pyright] venvPath = "." diff --git a/twisted/twisted_site/tests/test_image_upload.py b/twisted/twisted_site/tests/test_image_upload.py index fe78beb..21c370d 100644 --- a/twisted/twisted_site/tests/test_image_upload.py +++ b/twisted/twisted_site/tests/test_image_upload.py @@ -1,4 +1,5 @@ import json +from io import BytesIO from typing import cast, override from unittest.mock import patch @@ -8,6 +9,7 @@ from django.http import HttpResponse from django.test import Client, TestCase from django.urls import reverse +from PIL import Image from twisted_site.models import Profile, UploadedFile @@ -21,7 +23,7 @@ class ImageUploadTests(TestCase): @override def setUp(self) -> None: self.user = User.objects.create_user(username="uploader") - self.profile = Profile.objects.create(user=self.user) + self.profile = Profile.objects.create(user=self.user, slack_username="Uploader") self.client = Client() self.client.force_login(self.user) @@ -40,9 +42,15 @@ def upload(self, file: SimpleUploadedFile) -> HttpResponse: def response_json(self, response: HttpResponse) -> dict[str, object]: return cast("dict[str, object]", json.loads(response.content)) + def png_bytes(self) -> bytes: + output = BytesIO() + image = Image.new("RGB", (2, 2), color="red") + _ = image.save(output, format="PNG") + return output.getvalue() + def test_anonymous_upload_is_rejected(self) -> None: self.client.logout() - file = SimpleUploadedFile("proof.png", b"image", content_type="image/png") + file = SimpleUploadedFile("proof.png", self.png_bytes(), content_type="image/png") response = self.upload(file) @@ -73,8 +81,38 @@ def test_file_over_ten_megabytes_is_rejected(self) -> None: uploader.assert_not_called() self.assertFalse(UploadedFile.objects.exists()) + def test_spoofed_image_content_is_rejected(self) -> None: + file = SimpleUploadedFile( + "proof.png", + b"", + content_type="image/png", + ) + with patch("twisted_site.views.image_upload.file_uploader") as uploader: + response = self.upload(file) + + body = self.response_json(response) + self.assertEqual(body["status"], "error") + self.assertIn("not a valid image", str(body["reason"])) + uploader.assert_not_called() + self.assertFalse(UploadedFile.objects.exists()) + + def test_image_extension_must_match_detected_format(self) -> None: + file = SimpleUploadedFile( + "proof.svg", + self.png_bytes(), + content_type="image/png", + ) + with patch("twisted_site.views.image_upload.file_uploader") as uploader: + response = self.upload(file) + + body = self.response_json(response) + self.assertEqual(body["status"], "error") + self.assertIn("extension", str(body["reason"])) + uploader.assert_not_called() + self.assertFalse(UploadedFile.objects.exists()) + def test_successful_upload_is_recorded(self) -> None: - file = SimpleUploadedFile("proof.png", b"image", content_type="image/png") + file = SimpleUploadedFile("proof.png", self.png_bytes(), content_type="image/png") uploader_result = { "status": "ok", "link": "https://uploads.example/proof.png", @@ -98,9 +136,10 @@ def test_successful_upload_is_recorded(self) -> None: self.assertEqual(uploaded_file.link, uploader_result["link"]) self.assertEqual(uploaded_file.filesize, len(file)) self.assertEqual(uploaded_file.uploaded_thru, "test") + self.assertEqual(str(uploaded_file), "proof uploaded by Uploader") def test_uploader_failure_does_not_create_database_record(self) -> None: - file = SimpleUploadedFile("proof.png", b"image", content_type="image/png") + file = SimpleUploadedFile("proof.png", self.png_bytes(), content_type="image/png") with patch( "twisted_site.views.image_upload.file_uploader", return_value={"status": "error", "error": "R2 unavailable"}, diff --git a/uv.lock b/uv.lock index 78ce76d..619a833 100644 --- a/uv.lock +++ b/uv.lock @@ -972,6 +972,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "propcache" version = "0.5.2" @@ -1299,6 +1361,7 @@ dependencies = [ { name = "django-qsstats-magic" }, { name = "django-tailwind", extra = ["cookiecutter", "honcho", "reload"] }, { name = "gunicorn" }, + { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "python-dotenv" }, { name = "requests" }, @@ -1331,6 +1394,7 @@ requires-dist = [ { name = "django-qsstats-magic", specifier = ">=1.1.0" }, { name = "django-tailwind", extras = ["cookiecutter", "honcho", "reload"], specifier = ">=4.5.0" }, { name = "gunicorn", specifier = ">=26.0.0" }, + { name = "pillow", specifier = ">=11.3.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "requests", specifier = ">=2.34.2" }, From 81680e399de80e265d82acab33a8717303795a90 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:08:21 -0400 Subject: [PATCH 044/104] Improve uploaded file model CDN response typing --- twisted/twisted_site/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index 675f401..66f72db 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -27,9 +27,9 @@ class UploadedFile(models.Model): @override def __str__(self) -> str: - return ( - f"{self.cdn_response['filename']} uploaded by {as_user(self.uploaded_by).profile.slack_username}" - ) + cdn_response = cast("dict[str, str]", self.cdn_response) + filename = cdn_response.get("name", "upload") + return f"{filename} uploaded by {as_user(self.uploaded_by).profile.slack_username}" # Create your models here. From 01d9e7efd83de8114dc168efc926d99480ccedde Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:09:06 -0400 Subject: [PATCH 045/104] Add missing str metamethods I'M CALLING THEM METAMETHODS IDFK WHAT THEY'RE CALLED IN PYTHON --- twisted/twisted_site/models.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index 66f72db..c84511c 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -354,6 +354,11 @@ class ShopRegion(models.Model): name = models.CharField(max_length=200) + @override + def __str__(self) -> str: + return self.name # ty: ignore[unsound-return-statement] + + class ShopItemRegionalPricing(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -363,6 +368,11 @@ class ShopItemRegionalPricing(models.Model): price = models.IntegerField() + @override + def __str__(self) -> str: + return f"{self.item} in {self.region}: {self.price}" + + class ShopItem(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -372,6 +382,11 @@ class ShopItem(models.Model): item_name = models.CharField(max_length=500) item_description = models.TextField() + @override + def __str__(self) -> str: + return self.item_name # ty: ignore[unsound-return-statement] + + class AuditLog(models.Model): timestamp = models.DateTimeField(auto_now_add=True) user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="audit_logs") From 5452f4207cc9c9db9eabc45dec0d9f7a51f2d4bf Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:13:12 -0400 Subject: [PATCH 046/104] More typing improvements --- twisted/twisted_site/views/client/shop.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/twisted/twisted_site/views/client/shop.py b/twisted/twisted_site/views/client/shop.py index 977d449..345be93 100644 --- a/twisted/twisted_site/views/client/shop.py +++ b/twisted/twisted_site/views/client/shop.py @@ -5,7 +5,7 @@ from django.utils import timezone from django.views import View -from twisted_site.models import Pathway, Profile, Project, ShopRegion +from twisted_site.models import Pathway, Profile, ShopRegion, as_user PROJECTS_PER_PAGE = 120 @@ -15,7 +15,7 @@ def get(self, request: HttpRequest) -> HttpResponse: if self.request.user.is_anonymous: return redirect("homepage") - context = {} + context: dict[str, object] = {} regions = ShopRegion.objects.all() context["regions"] = regions @@ -33,7 +33,7 @@ def post(self, request: HttpRequest) -> HttpResponse: return redirect("homepage") if request.POST.get("action") == "setRegion": - profile: Profile = self.request.user.profile + profile: Profile = as_user(request.user).profile profile.region = ShopRegion.objects.get(id=request.POST["region"]) profile.save() From 456f510bfd922df43fb5626632c04b8ec2ebb84d Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:13:22 -0400 Subject: [PATCH 047/104] Formatting fixes --- twisted/twisted_site/models.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index c84511c..5eb7c67 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -363,8 +363,16 @@ class ShopItemRegionalPricing(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - region = models.ForeignKey('twisted_site.ShopRegion', related_name="prices", on_delete=models.PROTECT) - item = models.ForeignKey('twisted_site.ShopItem', related_name="prices", on_delete=models.PROTECT) + region = models.ForeignKey( + "twisted_site.ShopRegion", + related_name="prices", + on_delete=models.PROTECT, + ) + item = models.ForeignKey( + "twisted_site.ShopItem", + related_name="prices", + on_delete=models.PROTECT, + ) price = models.IntegerField() From 088b3235f6c3dd6e826ec266749e2b03373ec98a Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:13:38 -0400 Subject: [PATCH 048/104] Fix Ari ingest URL --- twisted/twisted_site/ari.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/twisted/twisted_site/ari.py b/twisted/twisted_site/ari.py index d504d83..1e13d45 100644 --- a/twisted/twisted_site/ari.py +++ b/twisted/twisted_site/ari.py @@ -74,9 +74,11 @@ def send_request( else: message_bytes = None headers = {"Authorization": f"Bearer {ARI_SIGNING_SECRET}"} + base_url = ARI_INGEST_ENDPOINT.rstrip("/") + url = f"{base_url}/{endpoint.lstrip('/')}" if endpoint != "" else base_url return requests.request( method, - f"{ARI_INGEST_ENDPOINT}{endpoint}", + url, data=message_bytes, headers=headers, timeout=10, From a35d4bf7c85c1adc42a241b4e26365a248f0c6cf Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:14:39 -0400 Subject: [PATCH 049/104] Add missing ID field to ShopRegion --- twisted/twisted_site/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index 5eb7c67..c0f0e09 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -349,6 +349,8 @@ def __str__(self) -> str: class ShopRegion(models.Model): + id: int # pyright: ignore[reportUninitializedInstanceVariable] + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) From 716841e9c10eee7ae6ec6399fc186e4bf7f76eed Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:15:15 -0400 Subject: [PATCH 050/104] Improve handling for possibly None variables none variables with left beef --- twisted/twisted_site/views/admin/admin.py | 8 ++++---- twisted/twisted_site/views/admin/shop.py | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/twisted/twisted_site/views/admin/admin.py b/twisted/twisted_site/views/admin/admin.py index 773292f..d54b4a3 100644 --- a/twisted/twisted_site/views/admin/admin.py +++ b/twisted/twisted_site/views/admin/admin.py @@ -23,13 +23,13 @@ class AdminView(View): perms: ProfileStaffPermissions # pyright: ignore[reportUninitializedInstanceVariable] allowed = False - page = None - subpage = None + page: str | None = None + subpage: str | None = None def get_context_data(self, page: str | None = None, subpage: str | None = None) -> dict[str, Any]: # pyrefly: ignore[explicit-any] context: dict[str, Any] = {} # pyrefly: ignore[explicit-any] - context["page"] = page or self.page - context["subpage"] = subpage or self.subpage + context["page"] = page if page is not None else self.page + context["subpage"] = subpage if subpage is not None else self.subpage sidebar_links = [ SidebarLink( name="dashboard", diff --git a/twisted/twisted_site/views/admin/shop.py b/twisted/twisted_site/views/admin/shop.py index aa86451..aae2938 100644 --- a/twisted/twisted_site/views/admin/shop.py +++ b/twisted/twisted_site/views/admin/shop.py @@ -43,7 +43,8 @@ def post(self, request: HttpRequest) -> HttpResponse: action = request.POST.get("action") if action == "create": - name = (request.POST.get("name") or "").strip() + submitted_name = request.POST.get("name") + name = submitted_name.strip() if submitted_name is not None else "" if name == "": messages.error(request, "Region name cannot be empty!") return redirect("admin.shop.regions") @@ -56,7 +57,8 @@ def post(self, request: HttpRequest) -> HttpResponse: elif action == "update": region = get_object_or_404(ShopRegion, id=request.POST.get("region_id")) - name = (request.POST.get("name") or "").strip() + submitted_name = request.POST.get("name") + name = submitted_name.strip() if submitted_name is not None else "" if name == "": messages.error(request, "Region name cannot be empty!") return redirect("admin.shop.regions") From d6319104fcba9766fa1fedb1548cb7cc70ec1531 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:15:44 -0400 Subject: [PATCH 051/104] Prevent journalling negative Hackatime time --- twisted/twisted_site/views/client/journal.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/twisted/twisted_site/views/client/journal.py b/twisted/twisted_site/views/client/journal.py index ba8f728..a483ff0 100644 --- a/twisted/twisted_site/views/client/journal.py +++ b/twisted/twisted_site/views/client/journal.py @@ -50,7 +50,15 @@ def post(self, request: HttpRequest, project_id: int) -> HttpResponse: if project.user != request.user: return redirect("dashboard") - reduced_minutes = min(project.hackatime_time_unjournaled(), HACKATIME_MAX_LOGGABLE_MINUTES) + available_minutes = project.hackatime_time_unjournaled() + if available_minutes <= 0: + return self.get( + request, + project_id, + info="There is no unjournaled Hackatime time available.", + ) + + reduced_minutes = min(available_minutes, HACKATIME_MAX_LOGGABLE_MINUTES) if project.is_shipped(): return redirect("fr.projects.detail", project_id) @@ -83,7 +91,7 @@ def post(self, request: HttpRequest, project_id: int) -> HttpResponse: project=project, type="hackatime", content=content, - minutes_worked=project.hackatime_time_unjournaled(), + minutes_worked=available_minutes, reduced_minutes=reduced_minutes, ) journal.save() From 20c21d9f230f374e786397245cc315d41de52880 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:16:20 -0400 Subject: [PATCH 052/104] Fix comparison for pathway unlocking finally a commit that actually fits with this branch --- twisted/twisted_site/views/client/pathways.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twisted/twisted_site/views/client/pathways.py b/twisted/twisted_site/views/client/pathways.py index 7183d1a..1e3eb55 100644 --- a/twisted/twisted_site/views/client/pathways.py +++ b/twisted/twisted_site/views/client/pathways.py @@ -35,7 +35,7 @@ def get(self, request: HttpRequest) -> HttpResponse: pathway_info = { "pathway": pathway, "minutes_spent": minutes_spent, - "unlocked": minutes_spent > pathway.min_mins, + "unlocked": minutes_spent >= pathway.min_mins, "time_spent": time_spent_lookup.get(pathway.id), } if pathway.in_progress(): From b019c47c134db3bd602b8fd3d541d0eca9b427b1 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:17:17 -0400 Subject: [PATCH 053/104] Validate images uploaded to site --- twisted/twisted_site/views/image_upload.py | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/twisted/twisted_site/views/image_upload.py b/twisted/twisted_site/views/image_upload.py index 5035f63..38a6cf0 100644 --- a/twisted/twisted_site/views/image_upload.py +++ b/twisted/twisted_site/views/image_upload.py @@ -16,6 +16,44 @@ logger = logging.getLogger(__name__) ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"} +CONTENT_TYPE_FORMATS = { + "PNG": "image/png", + "JPEG": "image/jpeg", + "WEBP": "image/webp", + "GIF": "image/gif", +} +CONTENT_TYPE_EXTENSIONS = { + "image/png": {".png"}, + "image/jpeg": {".jpg", ".jpeg"}, + "image/webp": {".webp"}, + "image/gif": {".gif"}, +} + + +def validate_image(file: "DjangoUploadedFile[bytes]") -> str | None: + try: + file.seek(0) + with Image.open(file) as image: + image_format = image.format + image.verify() + except (UnidentifiedImageError, OSError, SyntaxError, ValueError): + file.seek(0) + return "File contents are not a valid image" + finally: + file.seek(0) + + normalized_format = image_format if image_format is not None else "" + expected_content_type = CONTENT_TYPE_FORMATS.get(normalized_format) + content_type = file.content_type if file.content_type is not None else "" + filename = file.name if file.name is not None else "" + extension = Path(filename).suffix.lower() + if expected_content_type != content_type: + return "Image contents do not match the declared content type" + allowed_extensions = CONTENT_TYPE_EXTENSIONS.get(content_type, set()) + if extension not in allowed_extensions: + return "Image extension does not match its content type" + return None + s3 = boto3.client( "s3", @@ -56,6 +94,10 @@ def upload_file(request: HttpRequest) -> JsonResponse: if file_size_mb > max_file_mb: return JsonResponse({"status": "error", "reason": f"File size exceeds {max_file_mb}MB!"}) + validation_error = validate_image(file) + if validation_error is not None: + return JsonResponse({"status": "error", "reason": validation_error}) + response_data = file_uploader(file) # Handle upload errors if response_data.get("status") == "error": From cb47c9187bda8c21802633ffbbfc104adb4a6911 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:18:05 -0400 Subject: [PATCH 054/104] Tests for communication with Ari --- twisted/twisted_site/tests/test_ari_client.py | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 twisted/twisted_site/tests/test_ari_client.py diff --git a/twisted/twisted_site/tests/test_ari_client.py b/twisted/twisted_site/tests/test_ari_client.py new file mode 100644 index 0000000..ed8c610 --- /dev/null +++ b/twisted/twisted_site/tests/test_ari_client.py @@ -0,0 +1,130 @@ +import hashlib +import hmac +from typing import TYPE_CHECKING, cast, override +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import SimpleTestCase, TestCase, override_settings + +from twisted_site import ari +from twisted_site.models import Journal, Profile, Project, ProjectShip + +if TYPE_CHECKING: + import requests + + +class _Response: + content = b"{}" + + def raise_for_status(self) -> None: + pass + + def json(self) -> dict[str, str]: + return {"phase": "reviewed", "decision": "approved"} + + +class AriRequestTests(SimpleTestCase): + def test_post_request_jsonifies_and_signs_payload(self) -> None: + response = cast("requests.Response", cast("object", _Response())) + with ( + patch("twisted_site.ari.ARI_INGEST_ENDPOINT", "https://ari.example/"), + patch("twisted_site.ari.ARI_SIGNING_SECRET", "outbound-secret"), + patch("twisted_site.ari.requests.request", return_value=response) as request, + ): + result = ari.send_request("POST", {"hello": "world"}, "/submit") + + self.assertIs(result, response) + data = b'{"hello": "world"}' + expected_signature = hmac.new(b"outbound-secret", data, hashlib.sha256).hexdigest() + request.assert_called_once_with( + "POST", + "https://ari.example/submit", + data=data, + headers={ + "X-Ari-Signature": expected_signature, + "Content-Type": "application/json", + }, + timeout=10, + ) + + def test_get_request_uses_bearer_authorization(self) -> None: + response = cast("requests.Response", cast("object", _Response())) + with ( + patch("twisted_site.ari.ARI_INGEST_ENDPOINT", "https://ari.example/"), + patch("twisted_site.ari.ARI_SIGNING_SECRET", "outbound-secret"), + patch("twisted_site.ari.requests.request", return_value=response) as request, + ): + _ = ari.send_request("GET", endpoint="/status?external_id=twisted-1") + + request.assert_called_once_with( + "GET", + "https://ari.example/status?external_id=twisted-1", + data=None, + headers={"Authorization": "Bearer outbound-secret"}, + timeout=10, + ) + + def test_get_project_status_returns_parsed_response(self) -> None: + project = Project(pk=42) + response = cast("requests.Response", cast("object", _Response())) + with patch("twisted_site.ari.send_request", return_value=response): + status = ari.get_project_status(project) + + self.assertEqual(status, {"phase": "reviewed", "decision": "approved"}) + + +class AriSendShipTests(TestCase): + user: User # pyright: ignore[reportUninitializedInstanceVariable] + profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + project: Project # pyright: ignore[reportUninitializedInstanceVariable] + ship: ProjectShip # pyright: ignore[reportUninitializedInstanceVariable] + + @override + def setUp(self) -> None: + self.user = User.objects.create_user(username="ari-maker", email="maker@example.com") + self.profile = Profile.objects.create( + user=self.user, + slack_id="U-MAKER", + slack_username="Maker", + ) + self.project = Project.objects.create( + user=self.user, + project_name="Outbound project", + project_description="Description", + project_type="hardware", + repo_url="https://github.com/example/repo", + playable_url="https://example.com/play", + screenshot_url="https://example.com/image.png", + hackatime_project_names=["Hackatime project"], + ) + _ = Journal.objects.create( + project=self.project, + type="untracked", + content="Journal", + minutes_worked=30, + reduced_minutes=20, + ) + self.ship = ProjectShip.objects.create(project=self.project) + + def test_send_ship_includes_project_evidence_payload(self) -> None: + response = cast("requests.Response", cast("object", _Response())) + with patch("twisted_site.ari.send_request", return_value=response) as send_request: + ari.send_ship(self.ship) + + method = cast("str", send_request.call_args.args[0]) + payload = cast("dict[str, object]", send_request.call_args.args[1]) + self.assertEqual(method, "POST") + self.assertEqual(payload["external_id"], f"twisted-{self.project.pk}") + self.assertEqual(payload["title"], "Outbound project") + self.assertEqual(payload["track"], "hardware") + self.assertEqual(payload["repo_url"], "https://github.com/example/repo") + self.assertEqual(payload["hackatime_projects"], ["Hackatime project"]) + journals = cast("list[dict[str, str | int]]", payload["journals"]) + self.assertEqual(journals[0]["minutes"], 20) + + @override_settings(DEBUG_REVIEW=True) + def test_debug_review_skips_outbound_delivery(self) -> None: + with patch("twisted_site.ari.send_request") as send_request: + ari.send_ship(self.ship) + + send_request.assert_not_called() From be7ca01b48d1b3767861a149353235b88f1b31d6 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:18:27 -0400 Subject: [PATCH 055/104] Auth callback tests --- .../twisted_site/tests/test_auth_callbacks.py | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 twisted/twisted_site/tests/test_auth_callbacks.py diff --git a/twisted/twisted_site/tests/test_auth_callbacks.py b/twisted/twisted_site/tests/test_auth_callbacks.py new file mode 100644 index 0000000..2af0f4d --- /dev/null +++ b/twisted/twisted_site/tests/test_auth_callbacks.py @@ -0,0 +1,185 @@ +import os +from typing import override +from unittest.mock import patch + +from authlib.integrations.base_client import ( # pyrefly: ignore[untyped-import] + MismatchingStateError, +) +from django.contrib.auth.models import User +from django.http import HttpResponseRedirect +from django.test import Client, TestCase +from django.urls import reverse + +from twisted_site.models import Profile + + +class HCAIdentityCallbackTests(TestCase): + @override + def setUp(self) -> None: + self.client = Client() + + def token(self, *, slack_id: str = "U-MAKER") -> dict[str, object]: + return { + "access_token": "hca-access-token", + "userinfo": { + "sub": "hca!maker", + "email": "maker@example.com", + "name": "Maker Name", + "given_name": "Maker", + "family_name": "Name", + "slack_id": slack_id, + "verification_status": "verified", + "ysws_eligible": True, + }, + } + + def slack_user(self) -> dict[str, object]: + return { + "user": { + "profile": { + "display_name": "Maker", + "image_512": "https://example.com/avatar.png", + }, + }, + } + + def test_successful_callback_creates_user_profile_and_starts_hackatime_login(self) -> None: + referrer = Profile.objects.create( + user=User.objects.create_user(username="referrer"), + my_referral_code="REFER", + ) + self.client.cookies["referral"] = "REFER" + with ( + patch( + "twisted_site.views.client.auth.oauth.hca.authorize_access_token", + return_value=self.token(), + ), + patch( + "twisted_site.views.client.auth.slack_bot.users_info", + return_value=self.slack_user(), + ), + patch( + "twisted_site.views.client.auth.secrets.token_urlsafe", + return_value="generated-state", + ), + patch("twisted_site.views.client.auth.log_to_channel"), + ): + response = self.client.get(reverse("auth_callback")) + + self.assertEqual(response.status_code, 302) + self.assertIn("https://hackatime.hackclub.com/oauth/authorize?", response["Location"]) + self.assertIn("state=generated-state", response["Location"]) + user = User.objects.get(username="hca_maker") + profile = Profile.objects.get(user=user) + self.assertEqual(profile.slack_id, "U-MAKER") + self.assertEqual(profile.hca_access_token, "hca-access-token") + self.assertEqual(profile.hackatime_state, "generated-state") + self.assertEqual(profile.referred_by, referrer) + self.assertEqual(int(self.client.session["_auth_user_id"]), user.pk) + + def test_missing_linked_slack_id_rejects_identity(self) -> None: + with patch( + "twisted_site.views.client.auth.oauth.hca.authorize_access_token", + return_value=self.token(slack_id=""), + ): + response = self.client.get(reverse("auth_callback")) + + self.assertEqual(response.status_code, 200) + self.assertFalse(User.objects.exists()) + + def test_login_disabled_rejects_callback_before_oauth_exchange(self) -> None: + with ( + patch.dict(os.environ, {"LOGIN_ENABLED": "false"}), + patch("twisted_site.views.client.auth.oauth.hca.authorize_access_token") as exchange, + ): + response = self.client.get(reverse("auth_callback")) + + self.assertEqual(response.status_code, 200) + exchange.assert_not_called() + + def test_maybe_login_mode_rejects_unapproved_user(self) -> None: + with ( + patch.dict(os.environ, {"LOGIN_ENABLED": "maybe"}), + patch( + "twisted_site.views.client.auth.oauth.hca.authorize_access_token", + return_value=self.token(), + ), + patch( + "twisted_site.views.client.auth.slack_bot.users_info", + return_value=self.slack_user(), + ), + ): + response = self.client.get(reverse("auth_callback")) + + self.assertEqual(response.status_code, 200) + self.assertTrue(Profile.objects.filter(user__username="hca_maker").exists()) + self.assertNotIn("_auth_user_id", self.client.session) + + def test_oauth_state_mismatch_is_rejected(self) -> None: + with patch( + "twisted_site.views.client.auth.oauth.hca.authorize_access_token", + side_effect=MismatchingStateError(), + ): + response = self.client.get(reverse("auth_callback")) + + self.assertEqual(response.status_code, 200) + self.assertFalse(User.objects.exists()) + + def test_slack_lookup_failure_uses_identity_fallback(self) -> None: + with ( + patch( + "twisted_site.views.client.auth.oauth.hca.authorize_access_token", + return_value=self.token(), + ), + patch( + "twisted_site.views.client.auth.slack_bot.users_info", + side_effect=RuntimeError("Slack unavailable"), + ), + patch( + "twisted_site.views.client.auth.secrets.token_urlsafe", + return_value="generated-state", + ), + patch("twisted_site.views.client.auth.log_to_channel"), + self.assertLogs("twisted_site.views.client.auth", level="ERROR"), + ): + response = self.client.get(reverse("auth_callback")) + + self.assertEqual(response.status_code, 302) + profile = Profile.objects.get(user__username="hca_maker") + self.assertEqual(profile.slack_username, "Maker Name") + self.assertEqual(profile.slack_pfp_url, "https://example.invalid/avatar.png") + + +class LoginLogoutTests(TestCase): + def test_existing_hackatime_user_is_redirected_away_from_login(self) -> None: + user = User.objects.create_user(username="linked") + _ = Profile.objects.create(user=user, hackatime_access_token="token") + client = Client() + client.force_login(user) + + response = client.post(reverse("login")) + + self.assertRedirects(response, reverse("dashboard")) + + def test_login_delegates_to_hca_authorize_redirect(self) -> None: + client = Client() + response_value = HttpResponseRedirect("https://identity.example/authorize") + with patch( + "twisted_site.views.client.auth.oauth.hca.authorize_redirect", + return_value=response_value, + ) as authorize_redirect: + response = client.post(reverse("login")) + + self.assertEqual(response, response_value) + authorize_redirect.assert_called_once() + + def test_logout_clears_authenticated_session(self) -> None: + user = User.objects.create_user(username="logout") + _ = Profile.objects.create(user=user) + client = Client() + client.force_login(user) + + response = client.post(reverse("logout")) + + self.assertRedirects(response, reverse("homepage")) + self.assertNotIn("_auth_user_id", client.session) From 5ea9c0baffbe16b45b014ef689373dd29968d5a9 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:18:57 -0400 Subject: [PATCH 056/104] Tests for integration with Hackatime and HCA --- .../twisted_site/tests/test_integrations.py | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 twisted/twisted_site/tests/test_integrations.py diff --git a/twisted/twisted_site/tests/test_integrations.py b/twisted/twisted_site/tests/test_integrations.py new file mode 100644 index 0000000..b241a57 --- /dev/null +++ b/twisted/twisted_site/tests/test_integrations.py @@ -0,0 +1,176 @@ +from datetime import UTC, datetime +from typing import cast +from unittest.mock import patch + +from django.test import SimpleTestCase +from requests import HTTPError + +from twisted_site import hackatime, hca + + +class _Response: + def __init__(self, payload: dict[str, object]) -> None: + self.payload = payload + + def raise_for_status(self) -> None: + pass + + def json(self) -> dict[str, object]: + return self.payload + + +class HackatimeClientTests(SimpleTestCase): + def test_authhelper_merges_headers_with_bearer_token(self) -> None: + self.assertEqual( + hackatime.authhelper("token"), + {"Authorization": "Bearer token"}, + ) + self.assertEqual( + hackatime.authhelper("token", {"X-Test": "value"}), + {"Authorization": "Bearer token", "X-Test": "value"}, + ) + self.assertEqual( + hackatime.authhelper("token", {"Authorization": "Custom"}), + {"Authorization": "Custom"}, + ) + + def test_me_parses_authenticated_identity(self) -> None: + response = _Response( + { + "id": 42, + "emails": ["maker@example.com"], + "slack_id": "U-MAKER", + "github_username": "maker", + "trust_factor": { + "trust_level": "trusted", + "trust_value": 0.75, + }, + }, + ) + with patch("twisted_site.hackatime.requests.get", return_value=response) as get: + identity = hackatime.me("token") + + get.assert_called_once_with( + "https://hackatime.hackclub.com/api/v1/authenticated/me", + headers={"Authorization": "Bearer token"}, + timeout=10, + ) + self.assertEqual(identity.id, 42) + self.assertEqual(identity.slack_id, "U-MAKER") + self.assertEqual(identity.trust_level, "trusted") + self.assertEqual(identity.trust_value, 0.75) + + def test_projects_sends_filters_and_parses_heartbeat(self) -> None: + response = _Response( + { + "projects": [ + { + "name": "Example", + "total_seconds": 3600, + "most_recent_heartbeat": "2026-01-01T12:00:00+00:00", + "languages": ["Python"], + }, + ], + }, + ) + with patch("twisted_site.hackatime.requests.get", return_value=response) as get: + projects = hackatime.projects( + "token", + include_archived=True, + start=datetime(2026, 1, 1, tzinfo=UTC), + projects=["Example", "Other"], + ) + + get.assert_called_once_with( + "https://hackatime.hackclub.com/api/v1/authenticated/projects", + params={ + "include_archived": "true", + "start": "2026-01-01T00:00:00+00:00", + "projects": "Example,Other", + }, + headers={"Authorization": "Bearer token"}, + timeout=10, + ) + self.assertEqual(projects[0].name, "Example") + self.assertEqual(projects[0].total_seconds, 3600) + self.assertEqual(projects[0].languages, ["Python"]) + + def test_http_errors_are_propagated(self) -> None: + with ( + patch( + "twisted_site.hackatime.requests.get", + side_effect=HTTPError("unavailable"), + ), + self.assertRaises(HTTPError), + ): + _ = hackatime.me("token") + + +class HCAClientTests(SimpleTestCase): + def identity_payload(self) -> dict[str, object]: + return { + "identity": { + "id": "identity-1", + "ysws_eligible": True, + "verification_status": "verified", + "first_name": "Maker", + "primary_email": "maker@example.com", + "slack_id": "U-MAKER", + "phone_number": "", + "birthday": "2008-01-01", + "addresses": [ + { + "id": "secondary", + "primary": False, + "country": "CA", + }, + { + "id": "primary", + "primary": True, + "country": "US", + }, + ], + }, + } + + def test_auth_headers_include_bearer_token(self) -> None: + self.assertEqual( + hca.get_auth_headers("token"), + {"Authorization": "Bearer token"}, + ) + self.assertEqual( + hca.get_auth_headers("token", {"X-Test": "value"}), + {"Authorization": "Bearer token", "X-Test": "value"}, + ) + + def test_get_user_data_selects_primary_address(self) -> None: + response = _Response(self.identity_payload()) + with patch("twisted_site.hca.requests.get", return_value=response) as get: + identity = hca.get_user_data("token") + + get.assert_called_once_with( + "https://auth.hackclub.com/api/v1/me", + headers={"Authorization": "Bearer token"}, + timeout=10, + ) + self.assertEqual(identity.id, "identity-1") + self.assertTrue(identity.ysws_eligible) + self.assertEqual(len(identity.addresses), 2) + primary_address = identity.primary_address + if primary_address is None: + self.fail("Expected a primary address") + self.assertEqual(primary_address.id, "primary") + self.assertEqual(primary_address.country, "US") + + def test_identity_without_addresses_is_supported(self) -> None: + payload = self.identity_payload() + identity_payload = cast("dict[str, object]", payload["identity"]) + empty_addresses: list[dict[str, object]] = [] + identity_payload["addresses"] = empty_addresses + response = _Response(payload) + + with patch("twisted_site.hca.requests.get", return_value=response): + identity = hca.get_user_data("token") + + self.assertEqual(identity.addresses, []) + self.assertIsNone(identity.primary_address) From 5c5b81f50cb19f1a23f438d8daa6dccb6a8b3c56 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:27:28 -0400 Subject: [PATCH 057/104] Test pathway display for unlocking and grouping --- .../twisted_site/tests/test_pathways_view.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 twisted/twisted_site/tests/test_pathways_view.py diff --git a/twisted/twisted_site/tests/test_pathways_view.py b/twisted/twisted_site/tests/test_pathways_view.py new file mode 100644 index 0000000..8874d2e --- /dev/null +++ b/twisted/twisted_site/tests/test_pathways_view.py @@ -0,0 +1,79 @@ +from datetime import timedelta +from typing import cast, override + +from django.contrib.auth.models import User +from django.test import Client, TestCase +from django.urls import reverse +from django.utils import timezone + +from twisted_site.models import Pathway, PathwayTimeSpent, Profile + + +class PathwaysViewTests(TestCase): + user: User # pyright: ignore[reportUninitializedInstanceVariable] + profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + + @override + def setUp(self) -> None: + self.user = User.objects.create_user(username="pathway-viewer") + self.profile = Profile.objects.create(user=self.user) + self.client = Client() + self.client.force_login(self.user) + + def test_anonymous_user_is_redirected_home(self) -> None: + self.client.logout() + + response = self.client.get(reverse("fr.pathways")) + + self.assertRedirects(response, reverse("homepage")) + + def test_exact_threshold_is_reported_as_unlocked(self) -> None: + now = timezone.now() + pathway = Pathway.objects.create( + name="Current", + min_mins=60, + start=now - timedelta(hours=1), + end=now + timedelta(hours=1), + ) + _ = PathwayTimeSpent.objects.create( + pathway=pathway, + user=self.user, + unlocked=True, + minutes=pathway.min_mins, + ) + + response = self.client.get(reverse("fr.pathways")) + + self.assertEqual(response.status_code, 200) + current_pathways = cast( + "list[dict[str, object]]", + response.context["current_pathways"], + ) + self.assertTrue(current_pathways[0]["unlocked"]) + + def test_pathways_are_grouped_by_lifecycle_state(self) -> None: + now = timezone.now() + _ = Pathway.objects.create( + name="Past", + min_mins=10, + start=now - timedelta(days=2), + end=now - timedelta(days=1), + ) + _ = Pathway.objects.create( + name="Current", + min_mins=10, + start=now - timedelta(hours=1), + end=now + timedelta(hours=1), + ) + _ = Pathway.objects.create( + name="Future", + min_mins=10, + start=now + timedelta(days=1), + end=now + timedelta(days=2), + ) + + response = self.client.get(reverse("fr.pathways")) + + self.assertEqual(len(response.context["past_pathways"]), 1) + self.assertEqual(len(response.context["current_pathways"]), 1) + self.assertEqual(len(response.context["future_pathways"]), 1) From 42ad16bf277061aa1c904e8a76896d849bce81cd Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:28:04 -0400 Subject: [PATCH 058/104] Test updating and reading project settings --- .../tests/test_project_settings.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 twisted/twisted_site/tests/test_project_settings.py diff --git a/twisted/twisted_site/tests/test_project_settings.py b/twisted/twisted_site/tests/test_project_settings.py new file mode 100644 index 0000000..535d7a3 --- /dev/null +++ b/twisted/twisted_site/tests/test_project_settings.py @@ -0,0 +1,100 @@ +from typing import override +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import Client, TestCase +from django.urls import reverse +from requests import HTTPError + +from twisted_site.models import Profile, Project, ProjectShip + + +class ProjectSettingsTests(TestCase): + user: User # pyright: ignore[reportUninitializedInstanceVariable] + profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + other_user: User # pyright: ignore[reportUninitializedInstanceVariable] + other_profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + project: Project # pyright: ignore[reportUninitializedInstanceVariable] + + @override + def setUp(self) -> None: + self.user = User.objects.create_user(username="settings-owner") + self.profile = Profile.objects.create(user=self.user, hackatime_access_token="token") + self.other_user = User.objects.create_user(username="settings-other") + self.other_profile = Profile.objects.create(user=self.other_user) + self.project = Project.objects.create( + user=self.user, + project_name="Original", + project_description="Original description", + project_type="software", + ) + self.client = Client() + self.client.force_login(self.user) + + def settings_url(self) -> str: + return reverse("fr.projects.settings", kwargs={"project_id": self.project.pk}) + + def detail_url(self) -> str: + return reverse("fr.projects.detail", kwargs={"project_id": self.project.pk}) + + def valid_post_data(self) -> dict[str, object]: + return { + "name": "Updated", + "description": "Updated description", + "type": "hardware", + "hackatime": ["First", "Second"], + "repo": "https://github.com/example/repo", + "playable_url": "https://example.com/play", + "screenshot_url": "https://example.com/image.png", + } + + def test_owner_can_update_project_settings(self) -> None: + with patch("twisted_site.views.client.project.log_to_channel") as log_to_channel: + response = self.client.post(self.settings_url(), self.valid_post_data()) + + self.assertRedirects(response, self.detail_url(), fetch_redirect_response=False) + self.project.refresh_from_db() + self.assertEqual(self.project.project_name, "Updated") + self.assertEqual(self.project.project_type, "hardware") + self.assertEqual(self.project.hackatime_project_names, ["First", "Second"]) + self.assertEqual(self.project.repo_url, "https://github.com/example/repo") + log_to_channel.assert_called_once() + + def test_non_owner_cannot_read_or_update_settings(self) -> None: + self.client.force_login(self.other_user) + + get_response = self.client.get(self.settings_url()) + post_response = self.client.post(self.settings_url(), self.valid_post_data()) + + self.assertRedirects(get_response, reverse("dashboard")) + self.assertRedirects(post_response, reverse("dashboard")) + self.project.refresh_from_db() + self.assertEqual(self.project.project_name, "Original") + + def test_shipped_project_settings_redirect_to_detail(self) -> None: + _ = ProjectShip.objects.create(project=self.project) + + response = self.client.get(self.settings_url()) + + self.assertRedirects(response, self.detail_url()) + + def test_invalid_project_type_is_rejected_without_changes(self) -> None: + data = self.valid_post_data() + data["type"] = "invalid" + + with patch("twisted_site.views.client.project.log_to_channel") as log_to_channel: + response = self.client.post(self.settings_url(), data) + + self.assertEqual(response.status_code, 200) + self.project.refresh_from_db() + self.assertEqual(self.project.project_type, "software") + log_to_channel.assert_not_called() + + def test_hackatime_failure_renders_settings_with_no_projects(self) -> None: + with patch( + "twisted_site.views.client.project.hackatime.projects", + side_effect=HTTPError("Hackatime unavailable"), + ): + response = self.client.get(self.settings_url()) + + self.assertEqual(response.status_code, 200) From a21e57608302f6c65fe1dba95096c55006c0aaca Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:28:24 -0400 Subject: [PATCH 059/104] Import fixes --- twisted/twisted_site/views/client/shop.py | 2 -- twisted/twisted_site/views/image_upload.py | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/twisted/twisted_site/views/client/shop.py b/twisted/twisted_site/views/client/shop.py index 345be93..9272f7f 100644 --- a/twisted/twisted_site/views/client/shop.py +++ b/twisted/twisted_site/views/client/shop.py @@ -1,5 +1,3 @@ -from django.urls import reverse -from django.core.paginator import Paginator from django.http import HttpRequest, HttpResponse from django.shortcuts import redirect, render from django.utils import timezone diff --git a/twisted/twisted_site/views/image_upload.py b/twisted/twisted_site/views/image_upload.py index 38a6cf0..1d2a82a 100644 --- a/twisted/twisted_site/views/image_upload.py +++ b/twisted/twisted_site/views/image_upload.py @@ -10,6 +10,7 @@ from django.core.files.uploadedfile import UploadedFile as DjangoUploadedFile from django.http import HttpRequest, JsonResponse from django.utils.text import slugify +from PIL import Image, UnidentifiedImageError from twisted_site.models import UploadedFile From 985f58234e0ec07a51ac735a64d9700471826d37 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:28:29 -0400 Subject: [PATCH 060/104] Remove commented template code --- twisted/twisted_site/templates/client/dashboard.html | 1 - 1 file changed, 1 deletion(-) diff --git a/twisted/twisted_site/templates/client/dashboard.html b/twisted/twisted_site/templates/client/dashboard.html index a219e4b..652964e 100644 --- a/twisted/twisted_site/templates/client/dashboard.html +++ b/twisted/twisted_site/templates/client/dashboard.html @@ -55,7 +55,6 @@ 🛒 - Shop From 7d9f8ab39aba7fee73cb05157846de7a6436c45f Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:29:00 -0400 Subject: [PATCH 061/104] Unused variable fix --- twisted/twisted_site/views/client/auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/twisted/twisted_site/views/client/auth.py b/twisted/twisted_site/views/client/auth.py index 7f5ed83..12b3ee8 100644 --- a/twisted/twisted_site/views/client/auth.py +++ b/twisted/twisted_site/views/client/auth.py @@ -173,7 +173,6 @@ def get(self, request: HttpRequest) -> HttpResponse: profile.hackatime_state = "" profile.save() - code = request.GET["code"] hackatime_client_id = os.environ["HACKATIME_CLIENT_ID"] hackatime_client_secret = os.environ["HACKATIME_CLIENT_SECRET"] hackatime_redirect_uri = os.environ["HACKATIME_REDIRECT_URI"] From e234feeb4b9159c0d549279e85883e1752203521 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:29:17 -0400 Subject: [PATCH 062/104] Unused return value fix --- twisted/twisted_site/views/admin/shop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twisted/twisted_site/views/admin/shop.py b/twisted/twisted_site/views/admin/shop.py index aae2938..5ecc45b 100644 --- a/twisted/twisted_site/views/admin/shop.py +++ b/twisted/twisted_site/views/admin/shop.py @@ -80,7 +80,7 @@ def post(self, request: HttpRequest) -> HttpResponse: self.audit_log.additional_context["region_name"] = region.name try: - region.delete() + _ = region.delete() except ProtectedError: messages.error( request, From bd34210a75e56bc140d6657ad715e8ce9fa18fbe Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:30:22 -0400 Subject: [PATCH 063/104] More robust checking for Ari request payloads --- twisted/twisted_site/views/ari.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/twisted/twisted_site/views/ari.py b/twisted/twisted_site/views/ari.py index f87a44e..cfa068b 100644 --- a/twisted/twisted_site/views/ari.py +++ b/twisted/twisted_site/views/ari.py @@ -194,7 +194,12 @@ def post(self, request: HttpRequest) -> HttpResponse: ): return HttpResponse(status=401) - data = json.loads(body) + try: + data = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError): + return HttpResponseBadRequest("Malformed JSON payload") + if not isinstance(data, dict): + return HttpResponseBadRequest("JSON payload must be an object") external_id = cast("str | None", data.get("external_id")) if external_id in (None, ""): From d50612ff89f226e6e11c514119e21b8082c6db09 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:31:31 -0400 Subject: [PATCH 064/104] Hackatime login & state auth checking --- twisted/twisted_site/views/client/auth.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/twisted/twisted_site/views/client/auth.py b/twisted/twisted_site/views/client/auth.py index 12b3ee8..b2368b2 100644 --- a/twisted/twisted_site/views/client/auth.py +++ b/twisted/twisted_site/views/client/auth.py @@ -159,9 +159,19 @@ def get(self, request: HttpRequest) -> HttpResponse: if os.environ.get("LOGIN_ENABLED") == "false": return JsonResponse("not allowed!") + if request.user.is_anonymous: + return redirect("login") + profile = as_user(request.user).profile - state = request.GET["state"] + state = request.GET.get("state") + code = request.GET.get("code") + if state in (None, "") or code in (None, ""): + return JsonResponse( + {"error": "Missing OAuth state or authorization code"}, + status=400, + ) + if not hmac.compare_digest(state, profile.hackatime_state): profile.hackatime_state = "" profile.save() From c71d75cf389d1b5e7b77873f339e7cfae42cfca3 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:31:44 -0400 Subject: [PATCH 065/104] Prevent editing journals for shipped projects --- twisted/twisted_site/views/client/journal.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/twisted/twisted_site/views/client/journal.py b/twisted/twisted_site/views/client/journal.py index a483ff0..56ae29a 100644 --- a/twisted/twisted_site/views/client/journal.py +++ b/twisted/twisted_site/views/client/journal.py @@ -252,6 +252,8 @@ def get( context: TemplateContext | None = None, # pyrefly: ignore[explicit-any] ) -> HttpResponse: journal = Journal.objects.get(id=id) + if journal.project.is_shipped(): + return redirect("fr.projects.detail", project_id=journal.project.id) if journal.project.user != request.user: return redirect("fr.projects.detail", journal.project.id) if context is None: @@ -264,6 +266,8 @@ def get( def post(self, request: HttpRequest, id: int) -> HttpResponse: journal = Journal.objects.get(id=id) + if journal.project.is_shipped(): + return redirect("fr.projects.detail", project_id=journal.project.id) if journal.project.user != request.user: return redirect("fr.projects.detail", journal.project.id) From ed5a4363a9d91aa395491cbdff6cb579dc7ab930 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:41:30 -0400 Subject: [PATCH 066/104] Fix datepicker start/end date values when not defined --- twisted/twisted_site/templates/admin/pathways/create.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/twisted/twisted_site/templates/admin/pathways/create.html b/twisted/twisted_site/templates/admin/pathways/create.html index d5cbbc5..f973446 100644 --- a/twisted/twisted_site/templates/admin/pathways/create.html +++ b/twisted/twisted_site/templates/admin/pathways/create.html @@ -24,7 +24,7 @@

Create Pathway

Start
- +
@@ -34,7 +34,7 @@

Create Pathway

End
- +
From 9fa4cc4be871e890589b06fab603d5f4c3ff8cce Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:48:02 -0400 Subject: [PATCH 067/104] Fix some markup issues, remove debug logging --- .../twisted_site/templates/admin/pathways/detail.html | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/twisted/twisted_site/templates/admin/pathways/detail.html b/twisted/twisted_site/templates/admin/pathways/detail.html index fbc02ea..d31c3df 100644 --- a/twisted/twisted_site/templates/admin/pathways/detail.html +++ b/twisted/twisted_site/templates/admin/pathways/detail.html @@ -151,7 +151,7 @@

No shop items yet

New shop listing for {{ pathway.name }} - × + ×
@@ -194,21 +194,19 @@

No shop items yet

- + - From 1462eb4c3f3f7955a3b3d91bd40909491dc6cdd3 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 15:48:29 -0400 Subject: [PATCH 068/104] Linting & formatting fixes --- twisted/twisted_site/views/admin/pathways.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/twisted/twisted_site/views/admin/pathways.py b/twisted/twisted_site/views/admin/pathways.py index 3103d5f..2e4a761 100644 --- a/twisted/twisted_site/views/admin/pathways.py +++ b/twisted/twisted_site/views/admin/pathways.py @@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone -from twisted_site.models import Pathway, User, ShopItem, ShopRegion +from twisted_site.models import Pathway, ShopItem, ShopRegion, User from .admin import AdminView @@ -171,7 +171,7 @@ def get(self, request: HttpRequest, pathway_id: int) -> HttpResponse: return render(request, "admin/pathways/detail.html", context=context) - def post(self, request:HttpRequest, pathway_id) -> HttpResponse: + def post(self, request: HttpRequest, pathway_id: int) -> HttpResponse: if self.perms.manage_shop: self.allowed = True else: @@ -182,7 +182,7 @@ def post(self, request:HttpRequest, pathway_id) -> HttpResponse: if request.POST.get("action") == "new_listing": item_name = request.POST["name"] item_description = request.POST["description"] - ShopItem.objects.create( + _ = ShopItem.objects.create( pathway = pathway, item_name = item_name, item_description = item_description, @@ -203,4 +203,4 @@ def get(self, request: HttpRequest, listing_id: int) -> HttpResponse: item = ShopItem.objects.get(id=listing_id) context["item"] = item - return render(request, "admin/pathways/listing.html", context) \ No newline at end of file + return render(request, "admin/pathways/listing.html", context) From 9cbe357efe998e0dc4ab005d36943850d8e34b5f Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 16:51:54 -0400 Subject: [PATCH 069/104] Handle all request failures in country lookup --- twisted/twisted_site/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index c0f0e09..493cfc6 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -7,7 +7,7 @@ from django.db import models from django.db.models import QuerySet, Sum, TextField from django.utils import timezone -from requests import HTTPError +from requests import RequestException from . import hackatime, hca @@ -85,7 +85,7 @@ def get_country(self) -> str: if user_data.primary_address is not None else "Unknown" ) - except HTTPError: + except RequestException: country = "Unknown" self.country = country From 167e9c7432e6682c85033dee347734f35c91237f Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 16:52:28 -0400 Subject: [PATCH 070/104] Explicitly support None arguments to minutes formatter --- twisted/twisted_site/templatetags/time_filters.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/twisted/twisted_site/templatetags/time_filters.py b/twisted/twisted_site/templatetags/time_filters.py index 0891f94..15667a1 100644 --- a/twisted/twisted_site/templatetags/time_filters.py +++ b/twisted/twisted_site/templatetags/time_filters.py @@ -4,7 +4,9 @@ @register.filter -def minutes_to_hours_minutes(minutes: int | str) -> str | int: +def minutes_to_hours_minutes(minutes: int | str | None) -> str | int | None: + if minutes is None: + return None try: total_minutes = int(minutes) except (ValueError, TypeError): @@ -17,4 +19,4 @@ def minutes_to_hours_minutes(minutes: int | str) -> str | int: if remaining_minutes == 0: return f"{hours}h" return f"{hours}h {remaining_minutes}m" - return f"{int(minutes)}m" + return f"{total_minutes}m" From 0a473d39a8fe4e1dd200873d55d3f98b270df2d7 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 16:55:27 -0400 Subject: [PATCH 071/104] Return 404 Not Found upon unknown shop regions --- twisted/twisted_site/views/client/shop.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/twisted/twisted_site/views/client/shop.py b/twisted/twisted_site/views/client/shop.py index 9272f7f..c4a0035 100644 --- a/twisted/twisted_site/views/client/shop.py +++ b/twisted/twisted_site/views/client/shop.py @@ -1,5 +1,5 @@ from django.http import HttpRequest, HttpResponse -from django.shortcuts import redirect, render +from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.views import View @@ -32,7 +32,7 @@ def post(self, request: HttpRequest) -> HttpResponse: if request.POST.get("action") == "setRegion": profile: Profile = as_user(request.user).profile - profile.region = ShopRegion.objects.get(id=request.POST["region"]) + profile.region = get_object_or_404(ShopRegion, id=request.POST.get("region")) profile.save() return redirect(request.path_info) From 137aa83bdae31cc4f7053e0806200f73b53e7e8f Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 16:56:10 -0400 Subject: [PATCH 072/104] Check pathway minimum minutes is a whole number --- twisted/twisted_site/views/admin/pathways.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/twisted/twisted_site/views/admin/pathways.py b/twisted/twisted_site/views/admin/pathways.py index 2e4a761..c86db83 100644 --- a/twisted/twisted_site/views/admin/pathways.py +++ b/twisted/twisted_site/views/admin/pathways.py @@ -74,7 +74,7 @@ def post(self, request: HttpRequest) -> HttpResponse: return HttpResponse("err") pathway_name: str | None = request.POST.get("name") - min_mins = int(request.POST.get("mins", "0")) + min_mins_raw = request.POST.get("mins", "0") start_date: str | None = request.POST.get("startDate") start_time: str | None = request.POST.get("startTime") @@ -84,7 +84,7 @@ def post(self, request: HttpRequest) -> HttpResponse: errcontext: dict[str, Any] = { # pyrefly: ignore[explicit-any] "pathway_name": pathway_name, - "min_mins": min_mins, + "min_mins": min_mins_raw, "start_date": start_date, "start_time": start_time, "end_date": end_date, @@ -94,6 +94,11 @@ def post(self, request: HttpRequest) -> HttpResponse: if pathway_name in (None, ""): return self.get(request, "No pathway name typed!", errcontext) + try: + min_mins = int(min_mins_raw) + except ValueError: + return self.get(request, "Minimum minutes must be a whole number!", errcontext) + if min_mins <= 0: return self.get(request, "Minimum minutes must be greater than zero!", errcontext) From b0d0f6f9b27c6d10d28f807239cbe0664fac739e Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 16:56:37 -0400 Subject: [PATCH 073/104] Ensure validity of new pathway start/end times --- twisted/twisted_site/views/admin/pathways.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/twisted/twisted_site/views/admin/pathways.py b/twisted/twisted_site/views/admin/pathways.py index c86db83..8f2cdbe 100644 --- a/twisted/twisted_site/views/admin/pathways.py +++ b/twisted/twisted_site/views/admin/pathways.py @@ -116,12 +116,17 @@ def post(self, request: HttpRequest) -> HttpResponse: current_tz_offset = datetime.now(timezone.get_current_timezone()).strftime("%z") - start = datetime.strptime( - f"{start_date} {start_time} {current_tz_offset}", - "%Y-%m-%d %H:%M %z", - ) - - end = datetime.strptime(f"{end_date} {end_time} {current_tz_offset}", "%Y-%m-%d %H:%M %z") + try: + start = datetime.strptime( + f"{start_date} {start_time} {current_tz_offset}", + "%Y-%m-%d %H:%M %z", + ) + end = datetime.strptime( + f"{end_date} {end_time} {current_tz_offset}", + "%Y-%m-%d %H:%M %z", + ) + except ValueError: + return self.get(request, "Start and end must be valid dates and times!", errcontext) if start >= end: return self.get(request, "Start must be before end!", errcontext) From d2161aaf86b3fd620beb5945c0df057a8441aff6 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 17:06:04 -0400 Subject: [PATCH 074/104] Raise 400 Bad Request errors on bad project data --- twisted/twisted_site/views/client/projects.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/twisted/twisted_site/views/client/projects.py b/twisted/twisted_site/views/client/projects.py index d8175b0..a0fe1fc 100644 --- a/twisted/twisted_site/views/client/projects.py +++ b/twisted/twisted_site/views/client/projects.py @@ -1,5 +1,5 @@ -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest from django.shortcuts import redirect, render, resolve_url from django.views import View @@ -35,9 +35,17 @@ def post(self, request: HttpRequest) -> HttpResponse: if self.request.user.is_anonymous: return redirect("homepage") - project_name: str = request.POST["name"] - project_description: str = request.POST["description"] - project_type: str = request.POST["type"] + submitted_name = request.POST.get("name") + submitted_description = request.POST.get("description") + project_name = submitted_name.strip() if submitted_name is not None else "" + project_description = ( + submitted_description.strip() if submitted_description is not None else "" + ) + project_type = request.POST.get("type") + project_type = project_type if project_type is not None else "" + + if project_name == "" or project_description == "" or project_type == "": + return HttpResponseBadRequest("Name, description, and type are required") if project_type not in PROJECT_TYPE_CHOICES: return HttpResponse("naughty! you arent supposed to do this!") From ea708e18d8e68209e3f701e718f01632e7aebf90 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 17:07:06 -0400 Subject: [PATCH 075/104] Admin superuser permissions tests --- twisted/twisted_site/tests/test_admin.py | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/twisted/twisted_site/tests/test_admin.py b/twisted/twisted_site/tests/test_admin.py index 7ab7f77..8fbc8d8 100644 --- a/twisted/twisted_site/tests/test_admin.py +++ b/twisted/twisted_site/tests/test_admin.py @@ -78,6 +78,69 @@ def test_view_audit_logs_permission_grants_audit_access(self) -> None: self.assertEqual(response.status_code, 200) + def test_non_superuser_cannot_change_user_permissions(self) -> None: + target_user = User.objects.create_user(username="target") + target_permissions = ProfileStaffPermissions.objects.create() + target_profile = Profile.objects.create( + user=target_user, staff_permissions=target_permissions, + ) + detail_url = reverse( + "admin.users.detail", + kwargs={"user_id": target_user.pk}, + ) + + response = self.client.post( + detail_url, + {"action": "change_permissions", "key": "view_users", "value": "True"}, + ) + + self.assertRedirects(response, reverse("admin.dash")) + target_profile.refresh_from_db() + target_permissions.refresh_from_db() + self.assertFalse(target_permissions.view_users) + self.assertIsNotNone(target_profile.staff_permissions) + + def test_superuser_can_toggle_user_allowlist(self) -> None: + self.permissions.superuser = True + self.permissions.save(update_fields=("superuser",)) + target_user = User.objects.create_user(username="allow-target") + target_profile = Profile.objects.create(user=target_user, is_allowed=False) + detail_url = reverse( + "admin.users.detail", + kwargs={"user_id": target_user.pk}, + ) + + response = self.client.post(detail_url, {"action": "toggle_is_allowed"}) + + self.assertEqual(response.status_code, 302) + target_profile.refresh_from_db() + self.assertTrue(target_profile.is_allowed) + + def test_superuser_can_change_permission_and_make_admin(self) -> None: + self.permissions.superuser = True + self.permissions.save(update_fields=("superuser",)) + target_user = User.objects.create_user(username="permission-target") + target_permissions = ProfileStaffPermissions.objects.create() + target_profile = Profile.objects.create( + user=target_user, staff_permissions=target_permissions, + ) + detail_url = reverse( + "admin.users.detail", + kwargs={"user_id": target_user.pk}, + ) + + _ = self.client.post( + detail_url, + {"action": "change_permissions", "key": "view_users", "value": "True"}, + ) + target_permissions.refresh_from_db() + self.assertTrue(target_permissions.view_users) + _ = self.client.post(detail_url, {"action": "make_admin"}) + + target_profile.refresh_from_db() + self.assertTrue(target_profile.is_staff) + self.assertIsNotNone(target_profile.staff_permissions) + def test_logout_all_requires_superuser_and_does_not_delete_sessions(self) -> None: session = SessionStore() _ = session.create() From 5ff14c417398e20027e2a7180878a4b6d994c2a2 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 17:07:40 -0400 Subject: [PATCH 076/104] Tests for example R2 bucket uploading --- .../twisted_site/tests/test_image_upload.py | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/twisted/twisted_site/tests/test_image_upload.py b/twisted/twisted_site/tests/test_image_upload.py index 21c370d..6b49e4e 100644 --- a/twisted/twisted_site/tests/test_image_upload.py +++ b/twisted/twisted_site/tests/test_image_upload.py @@ -3,15 +3,17 @@ from typing import cast, override from unittest.mock import patch +from botocore.exceptions import ClientError from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import UploadedFile as DjangoUploadedFile from django.http import HttpResponse -from django.test import Client, TestCase +from django.test import Client, SimpleTestCase, TestCase from django.urls import reverse from PIL import Image from twisted_site.models import Profile, UploadedFile +from twisted_site.views import image_upload _MAX_FILE_BYTES = 10 * 1024 * 1024 @@ -148,3 +150,48 @@ def test_uploader_failure_does_not_create_database_record(self) -> None: self.assertEqual(self.response_json(response)["status"], "error") self.assertFalse(UploadedFile.objects.exists()) + + +class R2UploaderTests(SimpleTestCase): + def test_upload_fileobj_uses_safe_generated_key(self) -> None: + file = SimpleUploadedFile("My Proof.PNG", b"png", content_type="image/png") + with ( + patch.dict( + "os.environ", + {"R2_BUCKET": "test-bucket", "R2_PUBLIC_URL": "https://cdn.example"}, + ), + patch("twisted_site.views.image_upload.uuid4", return_value="fixed-id"), + patch("twisted_site.views.image_upload.s3") as s3, + ): + result = image_upload.file_uploader(file) + + self.assertEqual(result["status"], "ok") + self.assertEqual(result["name"], "My Proof") + self.assertEqual(result["link"], "https://cdn.example/fixed-id-3/my-proof.png") + s3.upload_fileobj.assert_called_once_with( + file, + "test-bucket", + "fixed-id-3/my-proof.png", + ExtraArgs={"ContentType": "image/png"}, + ) + + def test_r2_client_error_is_returned_as_upload_error(self) -> None: + error = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "Denied"}}, + "PutObject", + ) + file = SimpleUploadedFile("proof.png", b"png", content_type="image/png") + with patch("twisted_site.views.image_upload.s3.upload_fileobj", side_effect=error): + result = image_upload.file_uploader(file) + + self.assertEqual(result["status"], "error") + self.assertEqual(result["error"], "Could not upload file") + + def test_missing_filename_is_rejected(self) -> None: + file = SimpleUploadedFile("proof.png", b"png", content_type="image/png") + file.name = None + + result = image_upload.file_uploader(file) + + self.assertEqual(result["status"], "error") + self.assertEqual(result["error"], "Uploaded file is missing a filename") From 9ed71438d7bc4979d8b6942097842be7ea29405e Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 17:10:08 -0400 Subject: [PATCH 077/104] Test pathway unlocking --- .../twisted_site/tests/test_pathways_view.py | 105 +++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/twisted/twisted_site/tests/test_pathways_view.py b/twisted/twisted_site/tests/test_pathways_view.py index 8874d2e..70497ee 100644 --- a/twisted/twisted_site/tests/test_pathways_view.py +++ b/twisted/twisted_site/tests/test_pathways_view.py @@ -6,7 +6,7 @@ from django.urls import reverse from django.utils import timezone -from twisted_site.models import Pathway, PathwayTimeSpent, Profile +from twisted_site.models import Journal, Pathway, PathwayTimeSpent, Profile, Project class PathwaysViewTests(TestCase): @@ -77,3 +77,106 @@ def test_pathways_are_grouped_by_lifecycle_state(self) -> None: self.assertEqual(len(response.context["past_pathways"]), 1) self.assertEqual(len(response.context["current_pathways"]), 1) self.assertEqual(len(response.context["future_pathways"]), 1) + + +class UnlockPathwayTests(TestCase): + user: User # pyright: ignore[reportUninitializedInstanceVariable] + profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + project: Project # pyright: ignore[reportUninitializedInstanceVariable] + journal: Journal # pyright: ignore[reportUninitializedInstanceVariable] + pathway: Pathway # pyright: ignore[reportUninitializedInstanceVariable] + + @override + def setUp(self) -> None: + self.user = User.objects.create_user(username="pathway-unlocker") + self.profile = Profile.objects.create(user=self.user) + self.project = Project.objects.create( + user=self.user, + project_name="Unlock project", + project_description="Description", + project_type="software", + ) + self.journal = Journal.objects.create( + project=self.project, + type="untracked", + content="Work", + minutes_worked=100, + reduced_minutes=100, + ) + now = timezone.now() + self.pathway = Pathway.objects.create( + name="Unlockable", + min_mins=60, + start=now - timedelta(hours=1), + end=now + timedelta(hours=1), + ) + self.client = Client() + self.client.force_login(self.user) + + def unlock_url(self) -> str: + return reverse( + "fr.pathways.unlock", + kwargs={"pathway_id": self.pathway.pk}, + ) + + def test_sufficient_time_unlocks_pathway(self) -> None: + response = self.client.post(self.unlock_url()) + + self.assertRedirects(response, reverse("fr.pathways")) + time_spent = PathwayTimeSpent.objects.get(pathway=self.pathway, user=self.user) + self.assertTrue(time_spent.unlocked) + self.assertEqual(time_spent.minutes, self.pathway.min_mins) + + def test_insufficient_time_does_not_create_spending_record(self) -> None: + self.journal.minutes_worked = 30 + self.journal.reduced_minutes = 30 + self.journal.save() + + response = self.client.post(self.unlock_url()) + + self.assertRedirects(response, reverse("fr.pathways")) + self.assertFalse( + PathwayTimeSpent.objects.filter(pathway=self.pathway, user=self.user).exists(), + ) + + def test_already_unlocked_pathway_is_not_modified(self) -> None: + existing = PathwayTimeSpent.objects.create( + pathway=self.pathway, + user=self.user, + unlocked=True, + minutes=60, + ) + + response = self.client.post(self.unlock_url()) + + self.assertRedirects(response, reverse("fr.pathways")) + existing.refresh_from_db() + self.assertEqual(existing.minutes, 60) + + def test_inactive_pathway_cannot_be_unlocked(self) -> None: + now = timezone.now() + future_pathway = Pathway.objects.create( + name="Future", + min_mins=60, + start=now + timedelta(hours=1), + end=now + timedelta(hours=2), + ) + + response = self.client.post( + reverse("fr.pathways.unlock", kwargs={"pathway_id": future_pathway.pk}), + ) + + self.assertRedirects(response, reverse("fr.pathways")) + self.assertFalse( + PathwayTimeSpent.objects.filter(pathway=future_pathway, user=self.user).exists(), + ) + + def test_anonymous_user_cannot_unlock_pathway(self) -> None: + self.client.logout() + + response = self.client.post(self.unlock_url()) + + self.assertRedirects(response, reverse("homepage")) + self.assertFalse( + PathwayTimeSpent.objects.filter(pathway=self.pathway, user=self.user).exists(), + ) From f46eb6576b4d72facad7a6d9c69abc4f690c8418 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 17:16:17 -0400 Subject: [PATCH 078/104] Admin updating and permission tests --- .../tests/test_admin_workflows.py | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 twisted/twisted_site/tests/test_admin_workflows.py diff --git a/twisted/twisted_site/tests/test_admin_workflows.py b/twisted/twisted_site/tests/test_admin_workflows.py new file mode 100644 index 0000000..860886d --- /dev/null +++ b/twisted/twisted_site/tests/test_admin_workflows.py @@ -0,0 +1,268 @@ +from datetime import UTC, datetime +from typing import override + +from django.contrib.auth.models import User +from django.test import Client, TestCase, override_settings +from django.urls import reverse + +from twisted_site.models import ( + Pathway, + Profile, + ProfileStaffPermissions, + Project, + ProjectShip, + ShopItem, + ShopItemRegionalPricing, + ShopRegion, +) + + +class AdminWorkflowTests(TestCase): + user: User # pyright: ignore[reportUninitializedInstanceVariable] + profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + permissions: ProfileStaffPermissions # pyright: ignore[reportUninitializedInstanceVariable] + + @override + def setUp(self) -> None: + self.user = User.objects.create_user(username="admin-workflow") + self.permissions = ProfileStaffPermissions.objects.create( + manage_pathways=True, + view_pathways=True, + view_review=True, + manage_review=True, + manage_shop=True, + ) + self.profile = Profile.objects.create( + user=self.user, + is_staff=True, + staff_permissions=self.permissions, + ) + self.client = Client() + self.client.force_login(self.user) + + def pathway_data(self) -> dict[str, str]: + return { + "name": "Browser Pathway", + "mins": "300", + "startDate": "2026-10-01", + "startTime": "09:00", + "endDate": "2026-10-08", + "endTime": "17:00", + } + + def test_pathway_can_be_created(self) -> None: + response = self.client.post(reverse("admin.pathways.create"), self.pathway_data()) + + self.assertRedirects(response, reverse("admin.pathways")) + pathway = Pathway.objects.get(name="Browser Pathway") + self.assertEqual(pathway.min_mins, 300) + self.assertLess(pathway.start, pathway.end) + + def test_pathway_rejects_non_numeric_minutes(self) -> None: + data = self.pathway_data() + data["mins"] = "many" + + response = self.client.post(reverse("admin.pathways.create"), data) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Pathway.objects.exists()) + + def test_pathway_rejects_malformed_dates(self) -> None: + data = self.pathway_data() + data["startDate"] = "not-a-date" + + response = self.client.post(reverse("admin.pathways.create"), data) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Pathway.objects.exists()) + + def test_pathway_rejects_reversed_dates(self) -> None: + data = self.pathway_data() + data["startDate"], data["endDate"] = data["endDate"], data["startDate"] + + response = self.client.post(reverse("admin.pathways.create"), data) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Pathway.objects.exists()) + + def test_pathway_detail_can_create_shop_listing(self) -> None: + pathway = Pathway.objects.create( + name="Listing pathway", + min_mins=60, + start=datetime(2026, 10, 1, 9, tzinfo=UTC), + end=datetime(2026, 10, 8, 17, tzinfo=UTC), + ) + detail_url = reverse( + "admin.pathways.detail", + kwargs={"pathway_id": pathway.pk}, + ) + + response = self.client.post( + detail_url, + { + "action": "new_listing", + "name": "Sticker", + "description": "A sticker", + }, + ) + + self.assertRedirects(response, detail_url) + self.assertTrue( + ShopItem.objects.filter(pathway=pathway, item_name="Sticker").exists(), + ) + + def test_pathway_detail_listing_creation_requires_shop_permission(self) -> None: + self.permissions.manage_shop = False + self.permissions.save(update_fields=("manage_shop",)) + pathway = Pathway.objects.create( + name="Protected listing pathway", + min_mins=60, + start=datetime(2026, 10, 1, 9, tzinfo=UTC), + end=datetime(2026, 10, 8, 17, tzinfo=UTC), + ) + + response = self.client.post( + reverse("admin.pathways.detail", kwargs={"pathway_id": pathway.pk}), + { + "action": "new_listing", + "name": "Unauthorized", + "description": "Should not be created", + }, + ) + + self.assertRedirects(response, reverse("admin.dash")) + self.assertFalse(ShopItem.objects.exists()) + + def test_shop_listing_detail_requires_pathway_view_permission(self) -> None: + pathway = Pathway.objects.create( + name="Visible listing pathway", + min_mins=60, + start=datetime(2026, 10, 1, 9, tzinfo=UTC), + end=datetime(2026, 10, 8, 17, tzinfo=UTC), + ) + item = ShopItem.objects.create( + pathway=pathway, + item_name="Visible item", + item_description="Description", + ) + detail_url = reverse( + "admin.pathways.shopitems", + kwargs={"listing_id": item.pk}, + ) + + visible_response = self.client.get(detail_url) + self.permissions.view_pathways = False + self.permissions.save(update_fields=("view_pathways",)) + denied_response = self.client.get(detail_url) + + self.assertEqual(visible_response.status_code, 200) + self.assertRedirects(denied_response, reverse("admin.dash")) + + @override_settings(DEBUG_REVIEW=True) + def test_debug_review_updates_ship_fields(self) -> None: + user = User.objects.create_user(username="reviewed-maker") + _ = Profile.objects.create(user=user) + project = Project.objects.create( + user=user, + project_name="Review project", + project_description="Description", + project_type="software", + ) + ship = ProjectShip.objects.create(project=project) + + response = self.client.post( + reverse("admin.review"), + { + "id": ship.pk, + "status": "approved", + "note_to_maker": "Looks good", + "audit_note": "Verified", + "technical_features": "Canvas", + "deflation_reason": "None", + "final_status": "approved", + "final_note_to_maker": "Final approval", + "final_audit_note": "Complete", + }, + ) + + self.assertRedirects(response, reverse("admin.review")) + ship.refresh_from_db() + self.assertEqual(ship.status, "approved") + self.assertEqual(ship.final_status, "approved") + self.assertEqual(ship.final_note_to_maker, "Final approval") + + def test_shop_region_can_be_created(self) -> None: + response = self.client.post( + reverse("admin.shop.regions"), + {"action": "create", "name": "Europe"}, + ) + + self.assertRedirects(response, reverse("admin.shop.regions")) + self.assertTrue(ShopRegion.objects.filter(name="Europe").exists()) + + def test_shop_region_can_be_renamed(self) -> None: + region = ShopRegion.objects.create(name="Old name") + + response = self.client.post( + reverse("admin.shop.regions"), + {"action": "update", "region_id": region.pk, "name": "New name"}, + ) + + self.assertRedirects(response, reverse("admin.shop.regions")) + region.refresh_from_db() + self.assertEqual(region.name, "New name") + + def test_unused_shop_region_can_be_deleted(self) -> None: + region = ShopRegion.objects.create(name="Temporary") + + response = self.client.post( + reverse("admin.shop.regions"), + {"action": "delete", "region_id": region.pk}, + ) + + self.assertRedirects(response, reverse("admin.shop.regions")) + self.assertFalse(ShopRegion.objects.filter(pk=region.pk).exists()) + + def test_region_with_shop_pricing_cannot_be_deleted(self) -> None: + region = ShopRegion.objects.create(name="Protected") + pathway = Pathway.objects.create( + name="Shop pathway", + min_mins=60, + start=datetime(2026, 10, 1, 9, tzinfo=UTC), + end=datetime(2026, 10, 8, 17, tzinfo=UTC), + ) + item = ShopItem.objects.create( + pathway=pathway, + item_name="Sticker", + item_description="Sticker", + ) + _ = ShopItemRegionalPricing.objects.create(region=region, item=item, price=5) + + response = self.client.post( + reverse("admin.shop.regions"), + {"action": "delete", "region_id": region.pk}, + ) + + self.assertRedirects(response, reverse("admin.shop.regions")) + self.assertTrue(ShopRegion.objects.filter(pk=region.pk).exists()) + + def test_shop_region_creation_requires_permission(self) -> None: + self.permissions.manage_shop = False + self.permissions.save(update_fields=("manage_shop",)) + + response = self.client.post( + reverse("admin.shop.regions"), + {"action": "create", "name": "Unauthorized"}, + ) + + self.assertRedirects(response, reverse("admin.dash")) + self.assertFalse(ShopRegion.objects.exists()) + + def test_empty_shop_region_is_rejected(self) -> None: + response = self.client.post( + reverse("admin.shop.regions"), + {"action": "create", "name": " "}, + ) + + self.assertRedirects(response, reverse("admin.shop.regions")) + self.assertFalse(ShopRegion.objects.exists()) From bc3ce57960d638cb117ccdb0ce1c4b10c3acb88b Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 17:17:14 -0400 Subject: [PATCH 079/104] Template tag tests for converting time and divide operation --- .../twisted_site/tests/test_template_tags.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 twisted/twisted_site/tests/test_template_tags.py diff --git a/twisted/twisted_site/tests/test_template_tags.py b/twisted/twisted_site/tests/test_template_tags.py new file mode 100644 index 0000000..fff8820 --- /dev/null +++ b/twisted/twisted_site/tests/test_template_tags.py @@ -0,0 +1,32 @@ +from django.test import SimpleTestCase + +from twisted_site.templatetags.maths import divide +from twisted_site.templatetags.time_filters import minutes_to_hours_minutes + + +class TemplateTagTests(SimpleTestCase): + def test_minutes_to_hours_minutes_formats_boundaries(self) -> None: + cases = ( + (0, "0m"), + (1, "1m"), + (59, "59m"), + (60, "1h"), + (61, "1h 1m"), + (125, "2h 5m"), + ("90", "1h 30m"), + ) + for value, expected in cases: + with self.subTest(value=value): + self.assertEqual(minutes_to_hours_minutes(value), expected) + + def test_minutes_filter_preserves_invalid_values(self) -> None: + self.assertEqual(minutes_to_hours_minutes("invalid"), "invalid") + self.assertIsNone(minutes_to_hours_minutes(None)) + + def test_divide_returns_float_ratio(self) -> None: + self.assertEqual(divide(3.0, 2.0), 1.5) + self.assertEqual(divide(0.0, 2.0), 0.0) + + def test_divide_by_zero_raises(self) -> None: + with self.assertRaises(ZeroDivisionError): + _ = divide(1.0, 0.0) From b68821d6bf7a8d89f4f4ebb021dae76576c52407 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 17:18:07 -0400 Subject: [PATCH 080/104] Projcet creation and other client workflow tests --- .../tests/test_client_workflows.py | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 twisted/twisted_site/tests/test_client_workflows.py diff --git a/twisted/twisted_site/tests/test_client_workflows.py b/twisted/twisted_site/tests/test_client_workflows.py new file mode 100644 index 0000000..d8bfbfa --- /dev/null +++ b/twisted/twisted_site/tests/test_client_workflows.py @@ -0,0 +1,117 @@ +from typing import TYPE_CHECKING, cast, override +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import Client, TestCase +from django.urls import reverse + +from twisted_site.models import Profile, Project, ShopRegion + +if TYPE_CHECKING: + from django.db.models import QuerySet + + +class ClientWorkflowTests(TestCase): + user: User # pyright: ignore[reportUninitializedInstanceVariable] + profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + + @override + def setUp(self) -> None: + self.user = User.objects.create_user(username="client-workflow") + self.profile = Profile.objects.create( + user=self.user, + slack_username="Workflow User", + ) + self.client = Client() + self.client.force_login(self.user) + + def test_project_creation_persists_and_notifies_slack(self) -> None: + with patch("twisted_site.views.client.projects.log_to_channel") as log_to_channel: + response = self.client.post( + reverse("fr.projects.create"), + { + "name": "Browser Project", + "description": "Created by a test", + "type": "software", + }, + ) + + project = Project.objects.get(user=self.user) + self.assertRedirects( + response, + reverse("fr.projects.detail", kwargs={"project_id": project.pk}), + fetch_redirect_response=False, + ) + self.assertEqual(project.project_name, "Browser Project") + self.assertEqual(project.project_type, "software") + log_to_channel.assert_called_once() + + def test_project_creation_requires_all_fields(self) -> None: + response = self.client.post(reverse("fr.projects.create"), {"name": "Incomplete"}) + + self.assertEqual(response.status_code, 400) + self.assertFalse(Project.objects.exists()) + + def test_project_creation_rejects_unknown_type(self) -> None: + with patch("twisted_site.views.client.projects.log_to_channel"): + response = self.client.post( + reverse("fr.projects.create"), + { + "name": "Invalid", + "description": "Invalid type", + "type": "other", + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Project.objects.exists()) + + def test_project_list_only_contains_owned_projects(self) -> None: + owned = Project.objects.create( + user=self.user, + project_name="Owned", + project_description="Description", + project_type="software", + ) + other_user = User.objects.create_user(username="other-owner") + _ = Profile.objects.create(user=other_user) + _ = Project.objects.create( + user=other_user, + project_name="Other", + project_description="Description", + project_type="software", + ) + + response = self.client.get(reverse("fr.projects")) + + projects = cast("QuerySet[Project]", response.context["projects"]) + self.assertEqual(list(projects), [owned]) + + def test_referral_code_is_generated_once_and_preserved(self) -> None: + with patch("twisted_site.views.client.referrals.secrets.choice", return_value="a"): + first_response = self.client.get(reverse("fr.referrals")) + second_response = self.client.get(reverse("fr.referrals")) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + self.profile.refresh_from_db() + self.assertEqual(self.profile.my_referral_code, "a" * 12) + + def test_shop_region_can_be_selected(self) -> None: + region = ShopRegion.objects.create(name="North America") + url = reverse("fr.shop") + + response = self.client.post(url, {"action": "setRegion", "region": region.pk}) + + self.assertRedirects(response, url) + self.profile.refresh_from_db() + self.assertEqual(self.profile.region, region) + + def test_unknown_shop_region_returns_not_found(self) -> None: + url = reverse("fr.shop") + + response = self.client.post(url, {"action": "setRegion", "region": 999}) + + self.assertEqual(response.status_code, 404) + self.profile.refresh_from_db() + self.assertIsNone(self.profile.region) From 3a367938a5d6adb71a47a05e26dbb71cb47c4b5f Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 17:18:17 -0400 Subject: [PATCH 081/104] Middleware cookie tests --- twisted/twisted_site/tests/test_middleware.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 twisted/twisted_site/tests/test_middleware.py diff --git a/twisted/twisted_site/tests/test_middleware.py b/twisted/twisted_site/tests/test_middleware.py new file mode 100644 index 0000000..855e3ad --- /dev/null +++ b/twisted/twisted_site/tests/test_middleware.py @@ -0,0 +1,56 @@ +from typing import override + +from django.http import HttpRequest, HttpResponse +from django.test import RequestFactory, SimpleTestCase +from django.utils import timezone +from mysite.middleware import TimezoneMiddleware + + +class TimezoneMiddlewareTests(SimpleTestCase): + factory: RequestFactory # pyright: ignore[reportUninitializedInstanceVariable] + + @override + def setUp(self) -> None: + self.factory = RequestFactory() + + @override + def tearDown(self) -> None: + timezone.deactivate() + + def test_valid_cookie_activates_timezone_during_request(self) -> None: + request = self.factory.get("/", headers={"cookie": "django_timezone=America/New_York"}) + expected_response = HttpResponse("ok") + + def get_response(_request: HttpRequest) -> HttpResponse: + self.assertEqual(timezone.get_current_timezone_name(), "America/New_York") + return expected_response + + response = TimezoneMiddleware(get_response)(request) + + self.assertIs(response, expected_response) + + def test_missing_cookie_deactivates_timezone(self) -> None: + request = self.factory.get("/") + expected_response = HttpResponse("ok") + + def get_response(_request: HttpRequest) -> HttpResponse: + self.assertEqual(timezone.get_current_timezone_name(), "UTC") + return expected_response + + response = TimezoneMiddleware(get_response)(request) + + self.assertIs(response, expected_response) + + def test_invalid_cookie_is_logged_and_ignored(self) -> None: + request = self.factory.get("/", headers={"cookie": "django_timezone=Not/AZone"}) + expected_response = HttpResponse("ok") + + def get_response(_request: HttpRequest) -> HttpResponse: + self.assertEqual(timezone.get_current_timezone_name(), "UTC") + return expected_response + + with self.assertLogs("mysite.middleware", level="WARNING") as logs: + response = TimezoneMiddleware(get_response)(request) + + self.assertIs(response, expected_response) + self.assertIn("Invalid django_timezone cookie value", logs.output[0]) From 620af423b4092892c1f82a50e87e1cb85de3698a Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 17:18:39 -0400 Subject: [PATCH 082/104] Profile country checking tests --- twisted/twisted_site/tests/test_profile.py | 101 +++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 twisted/twisted_site/tests/test_profile.py diff --git a/twisted/twisted_site/tests/test_profile.py b/twisted/twisted_site/tests/test_profile.py new file mode 100644 index 0000000..d5a9973 --- /dev/null +++ b/twisted/twisted_site/tests/test_profile.py @@ -0,0 +1,101 @@ +from datetime import timedelta +from typing import override +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils import timezone +from requests import Timeout + +from twisted_site.hca import Address, Identity +from twisted_site.models import Profile + + +class ProfileCountryTests(TestCase): + profile: Profile # pyright: ignore[reportUninitializedInstanceVariable] + + @override + def setUp(self) -> None: + user = User.objects.create_user(username="country-user") + self.profile = Profile.objects.create(user=user, hca_access_token="token") + + def identity(self, country: str | None) -> Identity: + primary_address = ( + Address( + id="primary", + first_name="Maker", + last_name="Name", + line_1="", + line_2="", + city="", + state="", + postal_code="", + country=country, + phone_number="", + primary=True, + ) + if country is not None + else None + ) + return Identity( + id="identity", + ysws_eligible=True, + verification_status="verified", + first_name="Maker", + last_name="Name", + primary_email="maker@example.com", + slack_id="U-MAKER", + phone_number="", + birthday="2008-01-01", + addresses=[primary_address] if primary_address is not None else [], + primary_address=primary_address, + ) + + def test_cached_country_avoids_external_request(self) -> None: + self.profile.country = "CA" + self.profile.country_cached_until = timezone.now() + timedelta(hours=1) + self.profile.save() + + with patch("twisted_site.models.hca.get_user_data") as get_user_data: + country = self.profile.get_country() + + self.assertEqual(country, "CA") + get_user_data.assert_not_called() + + def test_expired_cache_refreshes_primary_country(self) -> None: + self.profile.country = "Old" + self.profile.country_cached_until = timezone.now() - timedelta(seconds=1) + self.profile.save() + + with patch( + "twisted_site.models.hca.get_user_data", + return_value=self.identity("US"), + ) as get_user_data: + country = self.profile.get_country() + + self.assertEqual(country, "US") + get_user_data.assert_called_once_with("token") + self.profile.refresh_from_db() + self.assertEqual(self.profile.country, "US") + self.assertGreater(self.profile.country_cached_until, timezone.now()) + + def test_missing_primary_address_returns_unknown(self) -> None: + with patch( + "twisted_site.models.hca.get_user_data", + return_value=self.identity(None), + ): + country = self.profile.get_country() + + self.assertEqual(country, "Unknown") + + def test_network_timeout_is_cached_as_unknown(self) -> None: + with patch( + "twisted_site.models.hca.get_user_data", + side_effect=Timeout("HCA timed out"), + ): + country = self.profile.get_country() + + self.assertEqual(country, "Unknown") + self.profile.refresh_from_db() + self.assertEqual(self.profile.country, "Unknown") + self.assertIsNotNone(self.profile.country_cached_until) From 10fc5c8b76acf01fed199dc8382f04d8dd43d972 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Wed, 23 Sep 2026 17:18:52 -0400 Subject: [PATCH 083/104] Tests for Slack bot logging --- twisted/twisted_site/tests/test_slack.py | 57 ++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 twisted/twisted_site/tests/test_slack.py diff --git a/twisted/twisted_site/tests/test_slack.py b/twisted/twisted_site/tests/test_slack.py new file mode 100644 index 0000000..92b4287 --- /dev/null +++ b/twisted/twisted_site/tests/test_slack.py @@ -0,0 +1,57 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase, override_settings +from slack_sdk.errors import SlackApiError + +from twisted_site import slack + + +class SlackClientTests(SimpleTestCase): + def test_send_blocks_forwards_payload(self) -> None: + blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": "Hello"}}] + with patch("twisted_site.slack.slack_bot.chat_postMessage") as post_message: + _ = slack.send_blocks( + channel="C123", + blocks=blocks, + text="fallback", + metadata="value", + ) + + post_message.assert_called_once_with( + channel="C123", + blocks=blocks, + text="fallback", + metadata="value", + ) + + @override_settings(DEBUG=False) + def test_production_logging_omits_debug_username(self) -> None: + with patch("twisted_site.slack.slack_bot.chat_postMessage") as post_message: + slack.log_to_channel("message") + + post_message.assert_called_once_with( + channel=slack.SLACK_LOG_CHANNEL, + text="message", + ) + + @override_settings(DEBUG=True) + def test_debug_logging_adds_debug_username(self) -> None: + with patch("twisted_site.slack.slack_bot.chat_postMessage") as post_message: + slack.log_to_channel("message") + + post_message.assert_called_once_with( + channel=slack.SLACK_LOG_CHANNEL, + text="message", + username="[DEBUG]", + ) + + def test_slack_api_errors_are_logged_without_escaping(self) -> None: + error = SlackApiError("Slack failed", {"ok": False, "error": "invalid_auth"}) + with ( + patch("twisted_site.slack.slack_bot.chat_postMessage", side_effect=error), + self.assertLogs("twisted_site.slack", level="ERROR") as logs, + ): + result = slack.log_to_channel("message") + + self.assertIsNone(result) + self.assertIn("Failed to log to Slack channel", logs.output[0]) From 3d17d36eae5ddec7a1b2008fa74c57de25780342 Mon Sep 17 00:00:00 2001 From: KavyanshKhaitan2 Date: Thu, 24 Sep 2026 08:21:07 +0530 Subject: [PATCH 084/104] Add testing instructions to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2da7f7f..c94dab5 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,7 @@ follow these steps! 2. run `uv run manage.py migrate` 3. run `docker compose up db` 4. run `uv run manage.py tailwind dev` in a new terminal + +### testing +1. run `cd twisted` +2. run `uv run manage.py test --settings mysite.test_settings` \ No newline at end of file From fba6eb6a141a03a12410adf91439325f64a6ac44 Mon Sep 17 00:00:00 2001 From: KavyanshKhaitan2 Date: Thu, 24 Sep 2026 09:32:22 +0530 Subject: [PATCH 085/104] Update README --- README.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c94dab5..a4de3e6 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,21 @@ follow these steps! 2. [install uv](https://docs.astral.sh/uv/getting-started/installation/) 3. copy twisted/.env.example to twisted/.env +All instructions below assume you've already cd'd into the `twisted` dir + ### launching -1. run `cd twisted` -2. run `uv run manage.py migrate` -3. run `docker compose up db` -4. run `uv run manage.py tailwind dev` in a new terminal +1. run `uv run manage.py migrate` +2. run `docker compose up db` +3. run `uv run manage.py tailwind dev` in a new terminal + +### getting an admin account +1. log into your account on the web +2. run `uv run manage.py shell_plus` or `uv run manage.py shell` depending on what you fancy +3. get your profile by running `profile = Profile.objects.get()` (assuming only one person has signed up) +4. run `profile.is_staff = True` +5. run `perms = ProfileStaffPermissions.objects.create(superuser = True, view_users = True, view_pathways = True, manage_pathways = True, manage_fulfillments = True, manage_shop = True, view_review = True, manage_review = True, manage_announcements = True, view_auditlogs = True)` (this creates a perms object with all perms) +6. run `profile.staff_permissions = perms` (this associates the perms object with your profile) +7. run `profile.save()` (this saves the changes to the DB) ### testing -1. run `cd twisted` -2. run `uv run manage.py test --settings mysite.test_settings` \ No newline at end of file +run `uv run manage.py test --settings mysite.test_settings` \ No newline at end of file From 6e2bfdf4b5734ac7a3b89840b435626f8ba9803a Mon Sep 17 00:00:00 2001 From: KavyanshKhaitan2 Date: Thu, 24 Sep 2026 10:43:59 +0530 Subject: [PATCH 086/104] You can now create/delete/update shop items from admin dash --- .../migrations/0040_shopitem_stock.py | 18 +++ .../migrations/0041_shopitem_image_url.py | 18 +++ twisted/twisted_site/models.py | 4 + .../templates/admin/pathways/detail.html | 148 ++++++++++++++++-- .../templates/admin/pathways/listing.html | 83 +++++++++- twisted/twisted_site/views/admin/pathways.py | 57 ++++++- twisted/twisted_site/views/image_upload.py | 6 +- 7 files changed, 310 insertions(+), 24 deletions(-) create mode 100644 twisted/twisted_site/migrations/0040_shopitem_stock.py create mode 100644 twisted/twisted_site/migrations/0041_shopitem_image_url.py diff --git a/twisted/twisted_site/migrations/0040_shopitem_stock.py b/twisted/twisted_site/migrations/0040_shopitem_stock.py new file mode 100644 index 0000000..a48b9f9 --- /dev/null +++ b/twisted/twisted_site/migrations/0040_shopitem_stock.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-09-24 04:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0039_merge_0036_merge_20260922_1730_0038_profile_region'), + ] + + operations = [ + migrations.AddField( + model_name='shopitem', + name='stock', + field=models.IntegerField(default=999), + ), + ] diff --git a/twisted/twisted_site/migrations/0041_shopitem_image_url.py b/twisted/twisted_site/migrations/0041_shopitem_image_url.py new file mode 100644 index 0000000..62e2bd5 --- /dev/null +++ b/twisted/twisted_site/migrations/0041_shopitem_image_url.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-09-24 05:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0040_shopitem_stock'), + ] + + operations = [ + migrations.AddField( + model_name='shopitem', + name='image_url', + field=models.CharField(blank=True, default='', max_length=500), + ), + ] diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index 493cfc6..39f398b 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -392,6 +392,10 @@ class ShopItem(models.Model): item_name = models.CharField(max_length=500) item_description = models.TextField() + stock = models.IntegerField(default=999) + + image_url = models.CharField(max_length=500, blank=True, default="") + @override def __str__(self) -> str: return self.item_name # ty: ignore[unsound-return-statement] diff --git a/twisted/twisted_site/templates/admin/pathways/detail.html b/twisted/twisted_site/templates/admin/pathways/detail.html index d31c3df..9d8f015 100644 --- a/twisted/twisted_site/templates/admin/pathways/detail.html +++ b/twisted/twisted_site/templates/admin/pathways/detail.html @@ -1,5 +1,77 @@ {% load time_filters %} +
Admin @@ -105,8 +177,10 @@

Shop listings

+ Image Name Description + Stock Last updated @@ -117,21 +191,41 @@

Shop listings

hx-target="#listing-{{ item.id }}" hx-trigger="click" hx-swap="innerHTML" - hx-on::after-request="document.getElementById('listing-{{ item.id }}').showPopover()" + hx-on::before-request="document.getElementById('listing-{{ item.id }}').showPopover()" > + + {% if item.image_url %} + + {% endif %} + {{ item.item_name }} {{ item.item_description }} + + {{ item.stock }} + {{ item.updated_at }} -
-
+
+
+
+ +

+ Loading... +

+

+ Kaboom +

+
+ {% endfor %} @@ -164,20 +258,30 @@

No shop items yet

+
+ +
+
+ Item image + + +

or paste an image anywhere in this dialog

+

+ +
Regions and availability +

Leave price blank for no availability

- Available Region Price {% for region in shop_regions %} - - - {{ region.name }} @@ -185,12 +289,22 @@

No shop items yet

- {% endfor %} + {% empty %} + + + + + No regions found :( + + + + + {% endfor %}
- Submit + Create!
@@ -198,16 +312,16 @@

No shop items yet

diff --git a/twisted/twisted_site/templates/admin/pathways/listing.html b/twisted/twisted_site/templates/admin/pathways/listing.html index 494c52b..b0cc65e 100644 --- a/twisted/twisted_site/templates/admin/pathways/listing.html +++ b/twisted/twisted_site/templates/admin/pathways/listing.html @@ -1,2 +1,83 @@ {% load time_filters %} -faa \ No newline at end of file +
+ +
+
+ {{ item.item_name }} +
+ + × + +
+
+
+ {% csrf_token %} + +
+ +
+
+ {{ item.item_description }} +
+
+ +
+
+ Item image + + +

or paste an image anywhere in this dialog

+

+ +
+
+ Regions and availability +

Leave price blank for no availability

+ + + Region + Price + + + {% for region in regions %} + + + {{ region.region.name }} + + + + + + {% empty %} + + + + + No regions found :( + + + + + {% endfor %} + + +
+ + Update! + +
+
+
+
+ diff --git a/twisted/twisted_site/views/admin/pathways.py b/twisted/twisted_site/views/admin/pathways.py index 8f2cdbe..fb8e3bc 100644 --- a/twisted/twisted_site/views/admin/pathways.py +++ b/twisted/twisted_site/views/admin/pathways.py @@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone -from twisted_site.models import Pathway, ShopItem, ShopRegion, User +from twisted_site.models import Pathway, ShopItem, ShopRegion, User, ShopItemRegionalPricing from .admin import AdminView @@ -188,15 +188,24 @@ def post(self, request: HttpRequest, pathway_id: int) -> HttpResponse: return HttpResponse("err") pathway = get_object_or_404(Pathway, id=pathway_id) - if request.POST.get("action") == "new_listing": item_name = request.POST["name"] item_description = request.POST["description"] - _ = ShopItem.objects.create( + stock = request.POST["stock"] + image_url = request.POST.get("image_url", "") + shop_item = ShopItem.objects.create( pathway = pathway, item_name = item_name, item_description = item_description, + stock = stock, + image_url = image_url, ) + for region in ShopRegion.objects.all(): + price = request.POST[f"region-{region.id}-price"] + if not price: + continue + price = int(price) + ShopItemRegionalPricing.objects.create(region=region, item=shop_item, price=price) messages.success(request, f"Created new shop listing for {item_name}") return redirect(request.path_info) @@ -213,4 +222,46 @@ def get(self, request: HttpRequest, listing_id: int) -> HttpResponse: item = ShopItem.objects.get(id=listing_id) context["item"] = item + regions = [] + + for region in ShopRegion.objects.all(): + listing = item.prices.filter(region=region) + listing = listing.get() if listing else None + regions.append({ + "region": region, + "listing": listing, + }) + context["regions"] = regions + return render(request, "admin/pathways/listing.html", context) + + def post(self, request: HttpRequest, listing_id: int) -> HttpResponse: + if self.perms.manage_shop: + self.allowed = True + else: + return HttpResponse("err") + item = ShopItem.objects.get(id=listing_id) + item.item_name = request.POST["name"] + item.item_description = request.POST["description"] + item.stock = int(request.POST["stock"]) + item.image_url = request.POST.get("image_url", "") + item.save() + + for region in ShopRegion.objects.all(): + new_price = request.POST[f"region-{region.id}-price"] + listing = item.prices.filter(region=region) + listing = listing.get() if listing else None + if listing and new_price: + listing.price = new_price + listing.save() + continue + if not listing and new_price: + ShopItemRegionalPricing.objects.create(region=region, item=item, price=new_price) + continue + + if listing and not new_price: + listing.delete() + + + messages.success(request, f"Updated item '{item.item_name}'!") + return redirect("admin.pathways.detail", item.pathway.id) diff --git a/twisted/twisted_site/views/image_upload.py b/twisted/twisted_site/views/image_upload.py index 1d2a82a..f247b71 100644 --- a/twisted/twisted_site/views/image_upload.py +++ b/twisted/twisted_site/views/image_upload.py @@ -142,10 +142,10 @@ def _upload_fileobj( "ContentType": content_type, }, ) - - return { + link = f"{os.environ['R2_PUBLIC_URL'].rstrip('/')}/{stored_name}" + return { # noqa: TRY300 "status": "ok", - "link": f"{os.environ['R2_PUBLIC_URL']}/{stored_name}", + "link": link, "name": original_filename, "size": size, } From 5b849ad2c21c1a4f2029465df884a28afd8e6de6 Mon Sep 17 00:00:00 2001 From: Lewin Kelly Date: Thu, 24 Sep 2026 10:31:33 -0400 Subject: [PATCH 087/104] Improve Twisted taskbar start menu icon --- .../twisted_site/static/images/twisted_t.png | Bin 3530 -> 3257 bytes .../templates/cotton/homepage_taskbar.html | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/twisted/twisted_site/static/images/twisted_t.png b/twisted/twisted_site/static/images/twisted_t.png index 9185e213befc40ab833d0b17a0a01298614be32a..26f057550668ad7bcd95612269785f54a4379b49 100644 GIT binary patch literal 3257 zcmbVOYfO`86n;y=3*{mqLJ=r#ixS)xpv=($1$53^CRLQya+QH?CNG)^q(2TmLS;9&)3 zHQvfN-<@zY`~(2GdjVj755TfW=Ku((2EaL^_Q|Or%feUrH5-msTVrmO|lC&rD}l5RxxHz=`T30Wq)41LUy@wsJ>{Uc%Ik~8 zg3d{^T-h}CuVCD-u|2Uy|NQ$HkLhU{xQXjb)_V12NXV>FoxN601hqXj=77Ll4z|xC zS+hvo^Y#u%jzohV%C~Dz*z$kCd@lmKufd9Ig3VpPB9tdQGxX=iNtcCuxce>^;Aft- zFB+1`#az97GEMdBn!BRPqIyzopud3Sbr^#<>Ka)b3Ro(MFl4KxP-7-(A)FcQbR0~7 zj@J848|sLa>poCl@RlC@<5q0d#Ty1QNirZ35GB~tb{GE=h?@#l+se?P`M9~o)rUu3 zdYDBwyh=OvlhBh*rOl3>OTSAr@FMzPfCz0+Aa8Vl9A#!cwZ!>2yaam~Ri7%yS##<= zIpwM`M^Pqmkntr96Vs3y9aw$UKny0&?)(l$slI)sq(MOvYOF>VMAgaH8jv`hTkQUR zN<8fJd;Hog_qN&Wj@furA3m96DgCV#$yr=dVvq#0KyAMH5g6~C$0Kxi93PyT)|WNXa$Gt61}$6 zG`9~CCG9+H9-p*C9Z7*f7o-VhrrPQ}JcBuv-+za}I zHd?_i@6j|7adTo=qBl7B>bA_O7P@P)i=lbjY$MNl`55K6aEuVW7Br%K9XT`+BfSYl z&Qv2bgJ;DnFyl3}{I3gSI=*sjD@9a6F6&NXxwHqax6QRaP^yx>D?`k~=H@U4%?_!5 zJBB|?@e9RI_BGfzTD|cGG(i=dE-d5>M4zSNZP=7ki_<*z*s8-D_giduw9B$M*EyvW=Uf+{OwXkZQl^-whgva?m|O$v!}!iyqS=BT5nUFwObLmbm%Lb7@Q2zZ#{-Zf ztAypTd4LkX^yb|NgOe`n52%k2Wy>|~D?%P!QM|(V9BOT`Yuur|agEs4c#knw=DGq0 zb?26-1-XA`r0%N}m@N0*p3m!TBRX*F&jzlir>9I!Mj^DIiD@W+8z5_(0%oKQjs;b) zDDWH~wzBya=!dqGeXh?ym5k`cb(**iwZFENUg2fo(sdyU7@uR`gdO4?Ja^?Em3G9R literal 3530 zcmV;*4K?zKP)XNC@V#KwKeMutBg0 z67X|@4Ip3z5=gL_8G#rnD@FvNyhF$&$5WrjWy*D`-L{VtJNJ{Kb56G(?RIt5*HzUW z=S~SOUcC5+t*x!?m_VPKSFc`O^wnZgIH zDGr^uv$KPaPkztN&MwgXd+GORZ*R{PVtILa@$1*GpYis-eEIU9QKX;&L}(BH;kQ+f z&b)s8`k&EWU%PS4+S=M@>ezMb50W<^6iMc_YuEn5_rh;EQkIsM&Ps`mu3WkDS8B`- zzv0EjMZ6uni5|iE-mpSW8XA=H0bOgOOg(jQG&wmrJvTQu56P3#i+V^TVFvZCCcc0F z9*>QUCC~Na$B+H(?d>g$l%u1gU(TODKb{>3y+G2rM{jaNyy1|P4515Bjy6E@QUN=H zL{*W<5=gEI3B@x)YFT>e*>~yL4|6(pVq)TmK6;`kBqb&w4Ww&oOT(z>_xn;mM)ULY zO~2gSHP_eI_db34v_+kZJ{-_U7|S|VDF3oU-UQSK4U;{n5M^W%6+udW7Wex2@#AM# zUqu9s4^-E2fsGfw(=+amkB?8IQ|Z3bG!T*-%e!OPCsIaIxYU?Y5o&+{PxPq%JTd7?`yq5zSfyDXQr%rbSg>BLhqP*mxlVrojZ3flP1_jXFh!R z@Eu8N@-j)17tjQ$3Th2|1Lk$;t@i2WbD$%sL97i7b-LC_#%ENZQ!)J6P*R8H+7jZi z5<{$^!nr;k8e$MR9?b~x%##2Fgujo}77uHv0?jab={3C!f*D3to_XBB{kg4*fn=y6 zu@-Bn0#ijWgNWnd0nmjZAA({aKg(T7G{qVurl+S5C4;bteBAPohxEzmYigVv+Is2I zr8)XNZP~~w($^Gg$laf=xq!DWt!q|!%+|dOGy{10^y$CQCYos_o$YI56;;p_YXEXa zizRewYHGTR!(K9gv@?65Nk=6e+Y}(BPo6x<@ld>Q(zSE0gOCiMEL!Q<(_oYYvQ9<2 z%W9>Q?AVM4cflwXfejYEW6H`#6*`tZSu-;;bL@j{70iRXz(YgO{#I~o>B*8lSRYPI zV(G;nfd|v@Z_`;`8f>uOT#-f?Pn+tswn_-Q8WsVVJU9yO%md5?HE0 z`&Pk0EnjFCW=Tb`W>6scD8F^<*5AlCyeVgaTu-MC=ooZ%rDyDy4bWvLoGuQUgEUA2 z%u{0-jFk0tLOYfHvDB$h51r+pfFAbjIFSZPMpl`pQm1dbG&(KSf>axggL!DzIVfPo z{n;Q8PnVCk1q-G+3I4qC+8xhVOR(wZN{y z2%(ZZkFABF6J^Z)G)bwCr#ity8S=SG@Yq@~b?hnSsgaMTmh7M$kF8^{BrGQktY)Wq zssyV5ZIGnFvd)v-lmmkwK72^>9$W+HBSZoTMYQzp-Mhaw6@fh|z(_#_Ks7#p{`?*J zH_joNeDB`9zq7%T&_f*=EU#a`{y^vKuB@zFuABBWbRQkp_r=M(45$DYC0KcSmNCp< zV35*i@(|QekJ3EatiDPJ#)k-!N^gCer*?Ror@&U_z!qH&dl3Kx%gM5D^tmZD+mKUR zsY+nn@S^xw{FY@t^l#j_@hcAx3?-yG!(zJM2{9N0v!O^LQBM!B zkKvG7T%P_`&J-C1x{dI+!f^5MRms3fxy2gfv3wlL=$UfU+c=FL06aQE2ENa!KKy1_ zK}(6$piYHDIF~A9z1iaew^2@CQSM6y=HcsM4UWBDTt)_NOB!{_d0)MHmE0@ztbM)0 zg>DcDi43Bx>93Kf<{CuKA1hEgOw(t@E`62K9~o>xOVi&viE0&)dHA3r>x5|%9;*w> zO6%_xtm4Rc&G_`t^a8ewr%gFHNc&;v!JeL>9m3`jA&$2ymT3(Y6`y@{$`1kK3eU#XoCr8?#eSO z>fl)VUX??s_RLF4Y1I^dwAEAvMXR^lG+0*7D*Srd_gZ!4#d!rf*I<-UIn=SC)!U?7 zTQgWiIjiUlP7y+R*_jufm-7mAt^r?%xvL1PJ6UF>Gg%l~nu$VQe%WG*(NR&(s!S1* z%P@I)rd7tz>r06@wk8?MrPb+77(pnrR6*hD?H2vDaymIEN)ht$SkH9_KF5umGBem8 z%fHzLAdbb2qe^ru9x)_b6>;oO7S?JZF7UTn9w}p&Bd?Q2g3HtECm}8F=rl`l2W@F( zomW|CCh+XpvyT`u!pjtuwfZvT#OFiW>O`lJ+#y{7Y&hW+yLt2GCET-8wxMeQJj|As zmd4_r8%PYP1xO84LTdGU0SljGz3e8;T)6fni2c z48u9ln~32vwd6fL4V4z2udBUOBH4J`7qPWAQnP@y8-)k(s+ks7y?B{`-bBo$>nXT? zZEY=Q;hE)(dcG^MMV1R0FM4Z-E$4BWOaP%1L%NVn=#<6Irl&gF&~V6g zY?t!5e4!JbexeR~(C0)Mr5<|PH^sM5*s&e+x5^=y+iuS=P6ItNf<1h6jC)s$(oYVi^^t59Hwcap7d&5XCKC) zjd`)8vnhk6B~!1sMn;WHQIhfdBEKevblS$GGjy;p{yD=Mxt{*^>lm!8V@U`70w)Je z1%EUZAhc3}SRygRL-G5v*Oj3anI12BZk>8Yoji+Nw&5}CHxL!AxGsv{%j39xF7;Jt zAE*qi=uVLS+B)@hk;^uWORVbl4R2j;Ev>YCq`vsQ73-JhOCP8Xt>`72b?QgmTf7I( zT4k|Z(S{0^LDEYrd8ngYM@An%e*ABGxI?)NQ=iX6Rcr$Y9y-hC)3Y3850_3)<+aon ze?#@;nfl|X8wV%PUx%~mOg~vJhZ4A>FWjMXQkkd9WtbdUmFF8nb)e4Isx0TPHJ)|A zI}@e;+>tj92Kse4t1oQ9zEfU^mU72ZIjLz`?jAa_Y7J!o!xlc!bhpruzt-t`D^-5f z^=@@SGp2W)Q`Vbah^D!v?0jTg3y;&Ll`PE1`s#J6y-+oqZUhW12Pdz8^0EXaapr0*p9$R>v`Q-+HJNzTGPeb5bQwb;iy@FiuVgg5K5L zsf~i=eJz7EN?vdo^sQ|@e5vxGpk0W2hH_Gib{6ur1>m+bv`mtzEpXy;?~2{ zm(^#fikB~6W;``?Y|-!zp6VVQTYDdBwNS8ha)NngnPrwn+EA8PV5t>cqf2`&gk~7#|Bcc0a{8*L zsa2-5*3Lpj=j*BN>S}xcA@A70|FaNO2$F;{#EAHR59;#G|6`rY)c-t8%H{tq#KC;5 zESJ~q5*ka2lmCE1d!AgXDkR$oO=-)h3NEj*;P0gHKeZgPFN>&vlmGw#07*qoM6N<$ Ef)_Br4gdfE diff --git a/twisted/twisted_site/templates/cotton/homepage_taskbar.html b/twisted/twisted_site/templates/cotton/homepage_taskbar.html index cdf608d..7f4ad7b 100644 --- a/twisted/twisted_site/templates/cotton/homepage_taskbar.html +++ b/twisted/twisted_site/templates/cotton/homepage_taskbar.html @@ -3,7 +3,7 @@