From 2045e42ba71c5d4cb99b8c6b74b1ef8359172d07 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 19 Mar 2026 13:05:57 -0500 Subject: [PATCH 01/11] better sidebar nav --- .../templates/django_program/manage/base.html | 86 ++++++++++++++++--- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/src/django_program/manage/templates/django_program/manage/base.html b/src/django_program/manage/templates/django_program/manage/base.html index 7b7f237..870783c 100644 --- a/src/django_program/manage/templates/django_program/manage/base.html +++ b/src/django_program/manage/templates/django_program/manage/base.html @@ -159,6 +159,40 @@ color: var(--color-text-muted); padding: 0 0.75rem; margin-bottom: 0.4rem; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + border-radius: var(--radius-sm); + transition: color 0.15s ease; + } + .sidebar-section-title:hover { + color: var(--color-text-secondary); + } + .sidebar-section-title .sidebar-collapse-icon { + width: 12px; + height: 12px; + flex-shrink: 0; + opacity: 0.5; + transition: transform 0.2s ease, opacity 0.15s ease; + } + .sidebar-section-title:hover .sidebar-collapse-icon { + opacity: 0.8; + } + .sidebar-section.collapsed .sidebar-collapse-icon { + transform: rotate(-90deg); + } + .sidebar-section.collapsed > .sidebar-nav { + max-height: 0; + overflow: hidden; + margin: 0; + opacity: 0; + } + .sidebar-section > .sidebar-nav { + max-height: 2000px; + opacity: 1; + transition: max-height 0.25s ease, opacity 0.2s ease; } .sidebar-sublabel { @@ -999,8 +1033,8 @@ {% if conference %} {% endif %} From 14a79ff8ac2dd6af47199cf4e2085043621f90e9 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 19 Mar 2026 13:27:58 -0500 Subject: [PATCH 07/11] fix: correct misplaced required_permission attributes and generate migrations --- .../migrations/0010_alter_conference_options.py | 17 +++++++++++++++++ src/django_program/manage/views.py | 15 ++++++++++----- .../0011_alter_travelgrant_options.py | 17 +++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 src/django_program/conference/migrations/0010_alter_conference_options.py create mode 100644 src/django_program/programs/migrations/0011_alter_travelgrant_options.py diff --git a/src/django_program/conference/migrations/0010_alter_conference_options.py b/src/django_program/conference/migrations/0010_alter_conference_options.py new file mode 100644 index 0000000..e492059 --- /dev/null +++ b/src/django_program/conference/migrations/0010_alter_conference_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.11 on 2026-03-19 18:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('program_conference', '0009_featureflags_visa_letters_enabled'), + ] + + operations = [ + migrations.AlterModelOptions( + name='conference', + options={'ordering': ['-start_date'], 'permissions': [('view_dashboard', 'Can view conference dashboard'), ('manage_conference_settings', 'Can edit conference settings and sync'), ('view_program', 'Can view program content'), ('change_program', 'Can edit program content'), ('view_registration', 'Can view attendees and orders'), ('change_registration', 'Can manage orders and visa letters'), ('view_commerce', 'Can view ticket types, add-ons, vouchers'), ('change_commerce', 'Can manage ticket types, add-ons, vouchers'), ('view_badges', 'Can view badges and templates'), ('change_badges', 'Can manage badges and templates'), ('view_sponsors', 'Can view sponsors'), ('change_sponsors', 'Can manage sponsors'), ('view_bulk_purchases', 'Can view bulk purchases'), ('change_bulk_purchases', 'Can manage bulk purchases'), ('view_finance', 'Can view financial dashboard and expenses'), ('change_finance', 'Can manage expenses'), ('view_reports', 'Can view reports and analytics'), ('export_reports', 'Can export report data'), ('view_checkin', 'Can access check-in'), ('use_terminal', 'Can use Terminal POS'), ('view_overrides', 'Can view Pretalx overrides'), ('change_overrides', 'Can manage Pretalx overrides')]}, + ), + ] diff --git a/src/django_program/manage/views.py b/src/django_program/manage/views.py index 255291f..f1b4f5d 100644 --- a/src/django_program/manage/views.py +++ b/src/django_program/manage/views.py @@ -2029,10 +2029,11 @@ def form_valid(self, form: TravelGrantForm) -> HttpResponse: class TravelGrantSendMessageView(ManagePermissionMixin, View): """POST-only view for reviewers to send a message on a grant.""" + required_permission = "program_programs.review_travel_grant" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Create a message attached to the grant.""" grant = get_object_or_404(TravelGrant, conference=self.conference, pk=kwargs["pk"]) - required_permission = "program_programs.review_travel_grant" form = ReviewerMessageForm(request.POST) if form.is_valid(): msg = form.save(commit=False) @@ -2085,10 +2086,11 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: class ReceiptReviewQueueView(ManagePermissionMixin, View): """Pick a random pending receipt for review.""" + required_permission = "program_programs.review_receipt" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Redirect to a random pending receipt, or back to the grant list if none.""" pending = ( - required_permission = "program_programs.review_receipt" Receipt.objects.filter( grant__conference=self.conference, approved=False, @@ -2131,10 +2133,11 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: class ReceiptApproveView(ManagePermissionMixin, View): """POST-only view to approve a receipt.""" + required_permission = "program_programs.review_receipt" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Mark the receipt as approved by the current user.""" receipt = get_object_or_404(Receipt, pk=kwargs["pk"], grant__conference=self.conference) - required_permission = "program_programs.review_receipt" receipt.approved = True receipt.approved_by = request.user receipt.approved_at = timezone.now() @@ -2146,10 +2149,11 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: class ReceiptFlagView(ManagePermissionMixin, View): """POST-only view to flag a receipt.""" + required_permission = "program_programs.review_receipt" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Flag the receipt with a reason provided by the reviewer.""" receipt = get_object_or_404(Receipt, pk=kwargs["pk"], grant__conference=self.conference) - required_permission = "program_programs.review_receipt" form = ReceiptFlagForm(request.POST) if form.is_valid(): receipt.flagged = True @@ -2276,10 +2280,11 @@ class SyncPretalxStreamView(ManagePermissionMixin, View): sync step (rooms, speakers, talks, schedule) completes. """ + required_permission = "manage_conference_settings" + def post(self, request: HttpRequest, **kwargs: str) -> StreamingHttpResponse: # noqa: ARG002 """Start the streaming sync and return an SSE response.""" response = StreamingHttpResponse( - required_permission = "manage_conference_settings" self._sync_stream(request), content_type="text/event-stream", ) diff --git a/src/django_program/programs/migrations/0011_alter_travelgrant_options.py b/src/django_program/programs/migrations/0011_alter_travelgrant_options.py new file mode 100644 index 0000000..355474c --- /dev/null +++ b/src/django_program/programs/migrations/0011_alter_travelgrant_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.11 on 2026-03-19 18:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('program_programs', '0010_survey_surveyresponse'), + ] + + operations = [ + migrations.AlterModelOptions( + name='travelgrant', + options={'ordering': ['-created_at'], 'permissions': [('review_travel_grant', 'Can review travel grant applications'), ('view_travel_grant', 'Can view travel grant applications'), ('disburse_travel_grant', 'Can disburse travel grant funds')]}, + ), + ] From 74b3499cb5f2ff49c693fb83b8f4e0b9b08f18cb Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 19 Mar 2026 13:34:18 -0500 Subject: [PATCH 08/11] fix: update test fixtures and imports for granular permission system --- src/django_program/manage/views_financial.py | 7 ++++-- src/django_program/manage/views_reports.py | 8 ++++--- tests/test_manage/test_analytics_reports.py | 8 ++++--- tests/test_manage/test_financial_views.py | 24 +++++++------------ tests/test_manage/test_report_views.py | 10 ++++---- .../test_bootstrap_registration.py | 22 ++++++++++------- 6 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/django_program/manage/views_financial.py b/src/django_program/manage/views_financial.py index abc98a8..c7a313f 100644 --- a/src/django_program/manage/views_financial.py +++ b/src/django_program/manage/views_financial.py @@ -8,14 +8,13 @@ import datetime import json from decimal import Decimal +from typing import TYPE_CHECKING from django.db.models import Count, Q, QuerySet, Sum, Value from django.db.models.functions import Coalesce from django.utils import timezone from django.views.generic import TemplateView -from django_program.conference.models import Conference -from django_program.manage.views import ConferencePermissionMixin from django_program.manage.reports import ( get_aov_by_date, get_cashflow_waterfall, @@ -26,7 +25,11 @@ get_sales_by_date, get_ticket_inventory, ) +from django_program.manage.views import ConferencePermissionMixin from django_program.programs.models import TravelGrant + +if TYPE_CHECKING: + from django_program.conference.models import Conference from django_program.registration.models import ( Attendee, Cart, diff --git a/src/django_program/manage/views_reports.py b/src/django_program/manage/views_reports.py index 6216354..109ce0b 100644 --- a/src/django_program/manage/views_reports.py +++ b/src/django_program/manage/views_reports.py @@ -10,6 +10,7 @@ import datetime import json from decimal import Decimal +from typing import TYPE_CHECKING from django.db.models import Count, QuerySet, Sum from django.http import HttpRequest, HttpResponse @@ -17,8 +18,6 @@ from django.views import View from django.views.generic import ListView, TemplateView -from django_program.conference.models import Conference -from django_program.manage.views import ConferencePermissionMixin from django_program.manage.reports import ( get_addon_inventory, get_attendee_manifest, @@ -40,12 +39,15 @@ get_voucher_summary, get_voucher_usage, ) -from django_program.manage.views import _safe_csv_cell +from django_program.manage.views import ConferencePermissionMixin, _safe_csv_cell from django_program.pretalx.models import Speaker from django_program.programs.models import TravelGrant from django_program.registration.letter import LetterRequest from django_program.registration.models import Attendee, Order, Payment, TicketType +if TYPE_CHECKING: + from django_program.conference.models import Conference + _REPORTS_GROUP_NAME = "Program: Reports" diff --git a/tests/test_manage/test_analytics_reports.py b/tests/test_manage/test_analytics_reports.py index ee57613..34db313 100644 --- a/tests/test_manage/test_analytics_reports.py +++ b/tests/test_manage/test_analytics_reports.py @@ -4,7 +4,7 @@ from decimal import Decimal import pytest -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group, Permission, User from django.test import Client from django.urls import reverse from django.utils import timezone @@ -36,9 +36,11 @@ def regular_user(db): @pytest.fixture def reports_user(db): - """A user belonging to the 'Program: Reports' group.""" + """A user with the view_reports permission via the Reports Viewer group.""" user = User.objects.create_user(username="reporter", password="password", email="reporter@test.com") - group, _created = Group.objects.get_or_create(name="Program: Reports") + group, _created = Group.objects.get_or_create(name="Reports Viewer") + perm = Permission.objects.get(content_type__app_label="program_conference", codename="view_reports") + group.permissions.add(perm) user.groups.add(group) return user diff --git a/tests/test_manage/test_financial_views.py b/tests/test_manage/test_financial_views.py index e8ed8b1..93bc50f 100644 --- a/tests/test_manage/test_financial_views.py +++ b/tests/test_manage/test_financial_views.py @@ -36,24 +36,18 @@ def regular_user(db): @pytest.fixture def finance_user(db): - """A user belonging to the 'Program: Finance & Accounting' group with its standard permissions.""" + """A user with the Finance Team group permissions (including view_finance).""" user = User.objects.create_user(username="finance", password="password", email="finance@test.com") - group, _created = Group.objects.get_or_create(name="Program: Finance & Accounting") - # Assign the permissions that setup_groups defines for this group. + group, _created = Group.objects.get_or_create(name="Finance Team") perm_specs = [ ("program_conference", "view_conference"), - ("program_registration", "view_order"), - ("program_registration", "change_order"), - ("program_registration", "view_orderlineitem"), - ("program_registration", "view_payment"), - ("program_registration", "add_payment"), - ("program_registration", "change_payment"), - ("program_registration", "view_credit"), - ("program_registration", "add_credit"), - ("program_registration", "change_credit"), - ("program_registration", "view_voucher"), - ("program_registration", "view_tickettype"), - ("program_registration", "view_addon"), + ("program_conference", "view_dashboard"), + ("program_conference", "view_finance"), + ("program_conference", "change_finance"), + ("program_conference", "view_reports"), + ("program_conference", "export_reports"), + ("program_conference", "view_registration"), + ("program_conference", "view_commerce"), ] perms = Permission.objects.filter( content_type__app_label__in={app for app, _ in perm_specs}, diff --git a/tests/test_manage/test_report_views.py b/tests/test_manage/test_report_views.py index 1aaa522..f634502 100644 --- a/tests/test_manage/test_report_views.py +++ b/tests/test_manage/test_report_views.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group, Permission, User from django.test import Client from django.urls import reverse from django.utils import timezone @@ -43,9 +43,12 @@ def regular_user(db): @pytest.fixture def reports_user(db): - """A user belonging to the 'Program: Reports' group.""" + """A user with view_reports and export_reports permissions via the Reports Viewer group.""" user = User.objects.create_user(username="reporter", password="password", email="reporter@test.com") - group, _created = Group.objects.get_or_create(name="Program: Reports") + group, _created = Group.objects.get_or_create(name="Reports Viewer") + for codename in ("view_reports", "export_reports"): + perm = Permission.objects.get(content_type__app_label="program_conference", codename=codename) + group.permissions.add(perm) user.groups.add(group) return user @@ -356,7 +359,6 @@ def test_loads_empty_conference(self, client_logged_in_super, conference): assert resp.status_code == 200 ctx = resp.context assert ctx["conference"] == conference - assert ctx["active_nav"] == "reports" def test_contains_attendee_summary(self, client_logged_in_super, report_data): conference = report_data["conference"] diff --git a/tests/test_registration/test_bootstrap_registration.py b/tests/test_registration/test_bootstrap_registration.py index 738cfba..691b219 100644 --- a/tests/test_registration/test_bootstrap_registration.py +++ b/tests/test_registration/test_bootstrap_registration.py @@ -233,10 +233,16 @@ def test_creates_all_groups(self): call_command("setup_groups") expected = [ - "Program: Conference Organizers", - "Program: Registration & Ticket Support", - "Program: Finance & Accounting", - "Program: Read-Only Staff", + "Conference Organizer", + "Program Committee", + "Registration Manager", + "Finance Team", + "Travel Grant Reviewer", + "Sponsor Manager", + "Check-in Staff", + "Activity Organizer", + "Reports Viewer", + "Read-Only Staff", ] for name in expected: assert Group.objects.filter(name=name).exists(), f"Group '{name}' not created" @@ -244,18 +250,18 @@ def test_creates_all_groups(self): def test_groups_have_permissions(self): call_command("setup_groups") - organizers = Group.objects.get(name="Program: Conference Organizers") + organizers = Group.objects.get(name="Conference Organizer") assert organizers.permissions.count() > 0 - readonly = Group.objects.get(name="Program: Read-Only Staff") + readonly = Group.objects.get(name="Read-Only Staff") codenames = set(readonly.permissions.values_list("codename", flat=True)) - assert all(c.startswith("view_") for c in codenames) + assert all(c.startswith("view_") or c == "export_reports" for c in codenames) def test_idempotent(self): call_command("setup_groups") call_command("setup_groups") - assert Group.objects.filter(name__startswith="Program:").count() == 6 + assert Group.objects.count() == 10 # --------------------------------------------------------------- From 6942726e75596f5640aac3a06460f779732e2b08 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 19 Mar 2026 14:11:45 -0500 Subject: [PATCH 09/11] fix: address Codex security review findings on permissions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../management/commands/setup_groups.py | 4 +- .../0010_alter_conference_options.py | 33 ++++++++++++++-- .../templates/django_program/manage/base.html | 8 +++- src/django_program/manage/views.py | 39 +++++++++---------- src/django_program/manage/views_letters.py | 5 ++- .../0011_alter_travelgrant_options.py | 14 +++++-- .../registration/views_checkin.py | 7 +++- tests/test_manage/test_views.py | 6 ++- 8 files changed, 81 insertions(+), 35 deletions(-) diff --git a/src/django_program/conference/management/commands/setup_groups.py b/src/django_program/conference/management/commands/setup_groups.py index 005db6a..46c318e 100644 --- a/src/django_program/conference/management/commands/setup_groups.py +++ b/src/django_program/conference/management/commands/setup_groups.py @@ -31,7 +31,9 @@ "change_overrides", ] -_ALL_VIEW_CONFERENCE_PERMS = [p for p in _ALL_CONFERENCE_PERMS if p.startswith("view_") or p == "export_reports"] +_ALL_VIEW_CONFERENCE_PERMS = [ + p for p in _ALL_CONFERENCE_PERMS if (p.startswith("view_") or p == "export_reports") and p != "view_checkin" +] # Mapping of group name -> list of (app_label, codename) permissions. _GROUP_PERMISSIONS: dict[str, list[tuple[str, str]]] = { diff --git a/src/django_program/conference/migrations/0010_alter_conference_options.py b/src/django_program/conference/migrations/0010_alter_conference_options.py index e492059..0906a76 100644 --- a/src/django_program/conference/migrations/0010_alter_conference_options.py +++ b/src/django_program/conference/migrations/0010_alter_conference_options.py @@ -4,14 +4,39 @@ class Migration(migrations.Migration): - dependencies = [ - ('program_conference', '0009_featureflags_visa_letters_enabled'), + ("program_conference", "0009_featureflags_visa_letters_enabled"), ] operations = [ migrations.AlterModelOptions( - name='conference', - options={'ordering': ['-start_date'], 'permissions': [('view_dashboard', 'Can view conference dashboard'), ('manage_conference_settings', 'Can edit conference settings and sync'), ('view_program', 'Can view program content'), ('change_program', 'Can edit program content'), ('view_registration', 'Can view attendees and orders'), ('change_registration', 'Can manage orders and visa letters'), ('view_commerce', 'Can view ticket types, add-ons, vouchers'), ('change_commerce', 'Can manage ticket types, add-ons, vouchers'), ('view_badges', 'Can view badges and templates'), ('change_badges', 'Can manage badges and templates'), ('view_sponsors', 'Can view sponsors'), ('change_sponsors', 'Can manage sponsors'), ('view_bulk_purchases', 'Can view bulk purchases'), ('change_bulk_purchases', 'Can manage bulk purchases'), ('view_finance', 'Can view financial dashboard and expenses'), ('change_finance', 'Can manage expenses'), ('view_reports', 'Can view reports and analytics'), ('export_reports', 'Can export report data'), ('view_checkin', 'Can access check-in'), ('use_terminal', 'Can use Terminal POS'), ('view_overrides', 'Can view Pretalx overrides'), ('change_overrides', 'Can manage Pretalx overrides')]}, + name="conference", + options={ + "ordering": ["-start_date"], + "permissions": [ + ("view_dashboard", "Can view conference dashboard"), + ("manage_conference_settings", "Can edit conference settings and sync"), + ("view_program", "Can view program content"), + ("change_program", "Can edit program content"), + ("view_registration", "Can view attendees and orders"), + ("change_registration", "Can manage orders and visa letters"), + ("view_commerce", "Can view ticket types, add-ons, vouchers"), + ("change_commerce", "Can manage ticket types, add-ons, vouchers"), + ("view_badges", "Can view badges and templates"), + ("change_badges", "Can manage badges and templates"), + ("view_sponsors", "Can view sponsors"), + ("change_sponsors", "Can manage sponsors"), + ("view_bulk_purchases", "Can view bulk purchases"), + ("change_bulk_purchases", "Can manage bulk purchases"), + ("view_finance", "Can view financial dashboard and expenses"), + ("change_finance", "Can manage expenses"), + ("view_reports", "Can view reports and analytics"), + ("export_reports", "Can export report data"), + ("view_checkin", "Can access check-in"), + ("use_terminal", "Can use Terminal POS"), + ("view_overrides", "Can view Pretalx overrides"), + ("change_overrides", "Can manage Pretalx overrides"), + ], + }, ), ] diff --git a/src/django_program/manage/templates/django_program/manage/base.html b/src/django_program/manage/templates/django_program/manage/base.html index 3132513..bbcf2db 100644 --- a/src/django_program/manage/templates/django_program/manage/base.html +++ b/src/django_program/manage/templates/django_program/manage/base.html @@ -1125,7 +1125,7 @@ {% endif %} {# ── Registration ── #} - {% if user_perms.registration_people or user_perms.registration_commerce or user_perms.badges %} + {% if user_perms.registration_people or user_perms.registration_commerce or user_perms.badges or user_perms.bulk_purchases %}