diff --git a/src/django_program/conference/management/commands/setup_groups.py b/src/django_program/conference/management/commands/setup_groups.py index fa58109..1019b66 100644 --- a/src/django_program/conference/management/commands/setup_groups.py +++ b/src/django_program/conference/management/commands/setup_groups.py @@ -5,113 +5,122 @@ from django.contrib.auth.models import Group, Permission from django.core.management.base import BaseCommand +# All custom Conference permissions for granting to the organizer group. +_ALL_CONFERENCE_PERMS = [ + "view_dashboard", + "manage_conference_settings", + "view_program", + "change_program", + "view_registration", + "change_registration", + "view_commerce", + "change_commerce", + "view_badges", + "change_badges", + "view_sponsors", + "change_sponsors", + "view_bulk_purchases", + "change_bulk_purchases", + "view_finance", + "change_finance", + "view_reports", + "export_reports", + "view_checkin", + "use_terminal", + "view_overrides", + "change_overrides", +] + +_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. -# Uses the app labels defined in each app's AppConfig (program_conference, etc.). _GROUP_PERMISSIONS: dict[str, list[tuple[str, str]]] = { - "Program: Conference Organizers": [ - # Full access to conference and section management + "Conference Organizer": [ + # Django CRUD on Conference model ("program_conference", "add_conference"), ("program_conference", "change_conference"), ("program_conference", "delete_conference"), ("program_conference", "view_conference"), - ("program_conference", "add_section"), - ("program_conference", "change_section"), - ("program_conference", "delete_section"), - ("program_conference", "view_section"), - # Full access to ticket types and add-ons - ("program_registration", "add_tickettype"), - ("program_registration", "change_tickettype"), - ("program_registration", "delete_tickettype"), - ("program_registration", "view_tickettype"), - ("program_registration", "add_addon"), - ("program_registration", "change_addon"), - ("program_registration", "delete_addon"), - ("program_registration", "view_addon"), - # Voucher management - ("program_registration", "add_voucher"), - ("program_registration", "change_voucher"), - ("program_registration", "delete_voucher"), - ("program_registration", "view_voucher"), - # View orders and carts (read-only for operational awareness) - ("program_registration", "view_order"), - ("program_registration", "view_orderlineitem"), - ("program_registration", "view_cart"), - ("program_registration", "view_cartitem"), - ("program_registration", "view_payment"), - ("program_registration", "view_credit"), - # Activity signup management + # All custom Conference permissions + *[("program_conference", p) for p in _ALL_CONFERENCE_PERMS], + # Programs app + ("program_programs", "view_activity"), ("program_programs", "manage_activity"), + ("program_programs", "view_travel_grant"), + ("program_programs", "review_travel_grant"), + ("program_programs", "disburse_travel_grant"), + ("program_programs", "review_receipt"), ], - "Program: Registration & Ticket Support": [ - # View conference context + "Program Committee": [ ("program_conference", "view_conference"), - ("program_conference", "view_section"), - # View and manage tickets/add-ons - ("program_registration", "view_tickettype"), - ("program_registration", "change_tickettype"), - ("program_registration", "view_addon"), - ("program_registration", "change_addon"), - # Voucher management (issue comps, apply discounts) - ("program_registration", "add_voucher"), - ("program_registration", "change_voucher"), - ("program_registration", "view_voucher"), - # Cart and order support - ("program_registration", "view_cart"), - ("program_registration", "view_cartitem"), - ("program_registration", "view_order"), - ("program_registration", "change_order"), - ("program_registration", "view_orderlineitem"), - ("program_registration", "view_payment"), - ("program_registration", "view_credit"), + ("program_conference", "view_dashboard"), + ("program_conference", "view_program"), + ("program_conference", "change_program"), + ("program_conference", "view_overrides"), + ("program_conference", "change_overrides"), ], - "Program: Finance & Accounting": [ - # Read-only conference context + "Registration Manager": [ ("program_conference", "view_conference"), - # Full access to financial records - ("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"), - # View vouchers for audit trail - ("program_registration", "view_voucher"), - # View tickets for revenue reporting - ("program_registration", "view_tickettype"), - ("program_registration", "view_addon"), + ("program_conference", "view_dashboard"), + ("program_conference", "view_registration"), + ("program_conference", "change_registration"), + ("program_conference", "view_commerce"), + ("program_conference", "change_commerce"), + ("program_conference", "view_badges"), + ("program_conference", "change_badges"), + ("program_conference", "view_checkin"), + ("program_conference", "use_terminal"), + ("program_conference", "view_bulk_purchases"), + ("program_conference", "change_bulk_purchases"), ], - "Program: Activity Organizers": [ + "Finance Team": [ ("program_conference", "view_conference"), - ("program_programs", "manage_activity"), + ("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"), + ], + "Travel Grant Reviewer": [ + ("program_conference", "view_conference"), + ("program_conference", "view_dashboard"), + ("program_programs", "view_travel_grant"), + ("program_programs", "review_travel_grant"), + ("program_programs", "review_receipt"), + ], + "Sponsor Manager": [ + ("program_conference", "view_conference"), + ("program_conference", "view_dashboard"), + ("program_conference", "view_sponsors"), + ("program_conference", "change_sponsors"), + ("program_conference", "view_bulk_purchases"), + ("program_conference", "change_bulk_purchases"), + ], + "Check-in Staff": [ + ("program_conference", "view_conference"), + ("program_conference", "view_dashboard"), + ("program_conference", "view_checkin"), + ], + "Activity Organizer": [ + ("program_conference", "view_conference"), + ("program_conference", "view_dashboard"), ("program_programs", "view_activity"), - ("program_programs", "view_activitysignup"), + ("program_programs", "manage_activity"), ], - "Program: Reports": [ - # Read-only access for report dashboards + "Reports Viewer": [ ("program_conference", "view_conference"), - ("program_registration", "view_tickettype"), - ("program_registration", "view_addon"), - ("program_registration", "view_voucher"), - ("program_registration", "view_order"), - ("program_registration", "view_orderlineitem"), - ("program_registration", "view_payment"), - ("program_registration", "view_credit"), + ("program_conference", "view_dashboard"), + ("program_conference", "view_reports"), ], - "Program: Read-Only Staff": [ + "Read-Only Staff": [ ("program_conference", "view_conference"), - ("program_conference", "view_section"), - ("program_registration", "view_tickettype"), - ("program_registration", "view_addon"), - ("program_registration", "view_voucher"), - ("program_registration", "view_cart"), - ("program_registration", "view_cartitem"), - ("program_registration", "view_order"), - ("program_registration", "view_orderlineitem"), - ("program_registration", "view_payment"), - ("program_registration", "view_credit"), + *[("program_conference", p) for p in _ALL_VIEW_CONFERENCE_PERMS], + ("program_programs", "view_activity"), + ("program_programs", "view_travel_grant"), ], } @@ -119,14 +128,18 @@ class Command(BaseCommand): """Create default permission groups for conference staff roles. - Creates six groups with appropriate permissions: + Creates ten groups with granular permissions: - * **Conference Organizers** -- full conference, ticket, voucher, and activity management - * **Registration & Ticket Support** -- ticket ops, voucher issuing, order support - * **Finance & Accounting** -- orders, payments, credits, and revenue visibility - * **Reports** -- read-only access to report dashboards and underlying data - * **Activity Organizers** -- per-activity signup management - * **Read-Only Staff** -- view-only access to all registration models + * **Conference Organizer** -- full access to all conference management + * **Program Committee** -- program content and Pretalx overrides + * **Registration Manager** -- attendees, orders, commerce, badges, check-in + * **Finance Team** -- financial dashboard, expenses, reports, read-only commerce + * **Travel Grant Reviewer** -- travel grant review and receipt approval + * **Sponsor Manager** -- sponsor and bulk purchase management + * **Check-in Staff** -- check-in dashboard access only + * **Activity Organizer** -- activity and signup management + * **Reports Viewer** -- read-only access to reports dashboard + * **Read-Only Staff** -- view-only access to all sections Safe to run multiple times; existing groups are updated with the defined permission set. @@ -147,12 +160,19 @@ def handle(self, *args: Any, **options: Any) -> None: group, created = Group.objects.get_or_create(name=group_name) verb = "Created" if created else "Updated" + perm_set = set(perm_specs) + app_labels = {app for app, _ in perm_set} permissions = Permission.objects.filter( - content_type__app_label__in={app for app, _ in perm_specs}, + content_type__app_label__in=app_labels, ) - matched = [p for p in permissions if (p.content_type.app_label, p.codename) in perm_specs] + matched = [p for p in permissions if (p.content_type.app_label, p.codename) in perm_set] + missing = perm_set - {(p.content_type.app_label, p.codename) for p in matched} group.permissions.set(matched) + if missing and verbosity > 0: + labels = ", ".join(f"{app}.{code}" for app, code in sorted(missing)) + self.stdout.write(self.style.WARNING(f" Warning: {group_name} missing permissions: {labels}")) + if verbosity > 0: self.stdout.write(self.style.SUCCESS(f" {verb} group '{group_name}' with {len(matched)} permissions")) 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..0906a76 --- /dev/null +++ b/src/django_program/conference/migrations/0010_alter_conference_options.py @@ -0,0 +1,42 @@ +# 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/conference/models.py b/src/django_program/conference/models.py index 2711a46..355e4bb 100644 --- a/src/django_program/conference/models.py +++ b/src/django_program/conference/models.py @@ -98,6 +98,30 @@ class Conference(models.Model): class Meta: 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"), + ] def __str__(self) -> str: return self.name 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 a5dcccd..19842e9 100644 --- a/src/django_program/manage/templates/django_program/manage/base.html +++ b/src/django_program/manage/templates/django_program/manage/base.html @@ -1066,6 +1066,7 @@ {% if conference %} {% endif %} diff --git a/src/django_program/manage/templates/django_program/manage/report_attendee_manifest.html b/src/django_program/manage/templates/django_program/manage/report_attendee_manifest.html index ab6858c..c7adf18 100644 --- a/src/django_program/manage/templates/django_program/manage/report_attendee_manifest.html +++ b/src/django_program/manage/templates/django_program/manage/report_attendee_manifest.html @@ -18,7 +18,9 @@

Attendee Manifest

{% endblock %} {% block page_actions %} +{% if perms.program_conference.export_reports %} Export CSV +{% endif %} {% endblock %} {% block content %} diff --git a/src/django_program/manage/templates/django_program/manage/report_credit_notes.html b/src/django_program/manage/templates/django_program/manage/report_credit_notes.html index 787a628..88dc495 100644 --- a/src/django_program/manage/templates/django_program/manage/report_credit_notes.html +++ b/src/django_program/manage/templates/django_program/manage/report_credit_notes.html @@ -18,7 +18,9 @@

Credit Notes

{% endblock %} {% block page_actions %} +{% if perms.program_conference.export_reports %} Export CSV +{% endif %} {% endblock %} {% block content %} diff --git a/src/django_program/manage/templates/django_program/manage/report_discount_effectiveness.html b/src/django_program/manage/templates/django_program/manage/report_discount_effectiveness.html index dcd5f3f..c100c57 100644 --- a/src/django_program/manage/templates/django_program/manage/report_discount_effectiveness.html +++ b/src/django_program/manage/templates/django_program/manage/report_discount_effectiveness.html @@ -18,7 +18,9 @@

Discount Effectiveness

{% endblock %} {% block page_actions %} +{% if perms.program_conference.export_reports %} Export CSV +{% endif %} {% endblock %} {% block content %} diff --git a/src/django_program/manage/templates/django_program/manage/report_inventory.html b/src/django_program/manage/templates/django_program/manage/report_inventory.html index 9ae698e..1a33475 100644 --- a/src/django_program/manage/templates/django_program/manage/report_inventory.html +++ b/src/django_program/manage/templates/django_program/manage/report_inventory.html @@ -18,7 +18,9 @@

Product Inventory

{% endblock %} {% block page_actions %} +{% if perms.program_conference.export_reports %} Export CSV +{% endif %} {% endblock %} {% block content %} diff --git a/src/django_program/manage/templates/django_program/manage/report_reconciliation.html b/src/django_program/manage/templates/django_program/manage/report_reconciliation.html index 8bd3a11..59e5eb6 100644 --- a/src/django_program/manage/templates/django_program/manage/report_reconciliation.html +++ b/src/django_program/manage/templates/django_program/manage/report_reconciliation.html @@ -18,7 +18,9 @@

Reconciliation

{% endblock %} {% block page_actions %} +{% if perms.program_conference.export_reports %} Export CSV +{% endif %} {% endblock %} {% block content %} diff --git a/src/django_program/manage/templates/django_program/manage/report_registration_flow.html b/src/django_program/manage/templates/django_program/manage/report_registration_flow.html index 882e6a2..f4d5e9f 100644 --- a/src/django_program/manage/templates/django_program/manage/report_registration_flow.html +++ b/src/django_program/manage/templates/django_program/manage/report_registration_flow.html @@ -18,7 +18,9 @@

Registration Flow

{% endblock %} {% block page_actions %} +{% if perms.program_conference.export_reports %} Export CSV +{% endif %} {% endblock %} {% block content %} diff --git a/src/django_program/manage/templates/django_program/manage/report_sales_by_date.html b/src/django_program/manage/templates/django_program/manage/report_sales_by_date.html index 0b04dc0..d04c57d 100644 --- a/src/django_program/manage/templates/django_program/manage/report_sales_by_date.html +++ b/src/django_program/manage/templates/django_program/manage/report_sales_by_date.html @@ -18,7 +18,9 @@

Sales by Date

{% endblock %} {% block page_actions %} +{% if perms.program_conference.export_reports %} Export CSV +{% endif %} {% endblock %} {% block content %} diff --git a/src/django_program/manage/templates/django_program/manage/report_speaker_registration.html b/src/django_program/manage/templates/django_program/manage/report_speaker_registration.html index 8a074d2..bb421df 100644 --- a/src/django_program/manage/templates/django_program/manage/report_speaker_registration.html +++ b/src/django_program/manage/templates/django_program/manage/report_speaker_registration.html @@ -18,7 +18,9 @@

Speaker Registration

{% endblock %} {% block page_actions %} +{% if perms.program_conference.export_reports %} Export CSV +{% endif %} {% endblock %} {% block content %} diff --git a/src/django_program/manage/templates/django_program/manage/report_visa_letters.html b/src/django_program/manage/templates/django_program/manage/report_visa_letters.html index ec70ae9..6135968 100644 --- a/src/django_program/manage/templates/django_program/manage/report_visa_letters.html +++ b/src/django_program/manage/templates/django_program/manage/report_visa_letters.html @@ -18,7 +18,9 @@

Visa Invitation Letters

{% endblock %} {% block page_actions %} +{% if perms.program_conference.export_reports %} Export CSV +{% endif %} {% endblock %} {% block content %} diff --git a/src/django_program/manage/templates/django_program/manage/report_voucher_usage.html b/src/django_program/manage/templates/django_program/manage/report_voucher_usage.html index b916e2a..5d7b413 100644 --- a/src/django_program/manage/templates/django_program/manage/report_voucher_usage.html +++ b/src/django_program/manage/templates/django_program/manage/report_voucher_usage.html @@ -18,7 +18,9 @@

Voucher Usage

{% endblock %} {% block page_actions %} +{% if perms.program_conference.export_reports %} Export CSV +{% endif %} {% endblock %} {% block content %} diff --git a/src/django_program/manage/views.py b/src/django_program/manage/views.py index d6032f0..e686bf1 100644 --- a/src/django_program/manage/views.py +++ b/src/django_program/manage/views.py @@ -150,19 +150,42 @@ def _safe_csv_cell(value: object) -> str: return text -class ManagePermissionMixin(LoginRequiredMixin): +_SIDEBAR_PERM_KEYS = [ + "conference", + "settings", + "program", + "registration_people", + "registration_commerce", + "badges", + "sponsors", + "bulk_purchases", + "activities", + "travel_grants", + "checkin", + "terminal", + "onsite", + "finance", + "reports", + "overrides", +] + + +class ConferencePermissionMixin(LoginRequiredMixin): """Permission mixin for conference-scoped management views. - Resolves the conference from the ``conference_slug`` URL kwarg and - checks that the authenticated user is a superuser or holds the - ``program_conference.change_conference`` permission. Stores the - resolved conference on ``self.conference`` and injects it into the - template context. + Each view sets ``required_permission`` to a permission codename. + Without an app label prefix, defaults to ``program_conference.``. + + Access is granted if ANY of: + - User is superuser + - User has ``program_conference.change_conference`` (legacy full-access) + - User has the specific ``required_permission`` Raises: PermissionDenied: If the user lacks the required permission. """ + required_permission: str = "" conference: Conference kwargs: dict[str, str] @@ -182,12 +205,6 @@ def get_submission_type_nav(self) -> list[dict[str, str | int]]: def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: """Resolve the conference and enforce permissions before dispatch. - The conference is resolved and permissions checked after the - ``LoginRequiredMixin`` verifies authentication but before the - view logic executes. If the user is not authenticated, - ``LoginRequiredMixin`` handles the redirect and we skip - conference resolution entirely. - Args: request: The incoming HTTP request. *args: Positional arguments from the URL resolver. @@ -202,18 +219,28 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo if not request.user.is_authenticated: return self.handle_no_permission() # type: ignore[return-value] - self.conference = get_object_or_404(Conference, slug=kwargs.get("conference_slug", "")) + slug = kwargs.get("conference_slug", "") + if slug: + self.conference = get_object_or_404(Conference, slug=slug) - if not (request.user.is_superuser or request.user.has_perm("program_conference.change_conference")): - raise PermissionDenied + user = request.user + if user.is_superuser: + return super().dispatch(request, *args, **kwargs) # type: ignore[misc] - return super().dispatch(request, *args, **kwargs) # type: ignore[misc] + if user.has_perm("program_conference.change_conference"): + return super().dispatch(request, *args, **kwargs) # type: ignore[misc] - def get_context_data(self, **kwargs: object) -> dict[str, object]: - """Add the conference and sidebar metadata to the template context. + if self.required_permission: + perm = self.required_permission + if "." not in perm: + perm = f"program_conference.{perm}" + if user.has_perm(perm): + return super().dispatch(request, *args, **kwargs) # type: ignore[misc] - Includes ``submission_type_nav`` for the sidebar's dynamic Talks - sub-menu. + raise PermissionDenied + + def get_context_data(self, **kwargs: object) -> dict[str, object]: + """Add the conference, sidebar metadata, and permissions to the template context. Args: **kwargs: Additional context data. @@ -225,8 +252,36 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: context["conference"] = self.conference context["submission_type_nav"] = self.get_submission_type_nav() context["last_synced"] = self._get_last_synced() + context["user_perms"] = self._get_sidebar_permissions() return context + def _get_sidebar_permissions(self) -> dict[str, bool]: + """Build a dict of sidebar section visibility flags for the current user.""" + user = self.request.user + if user.is_superuser or user.has_perm("program_conference.change_conference"): + return dict.fromkeys(_SIDEBAR_PERM_KEYS, True) + + return { + "conference": user.has_perm("program_conference.view_dashboard"), + "settings": user.has_perm("program_conference.manage_conference_settings"), + "program": user.has_perm("program_conference.view_program"), + "registration_people": user.has_perm("program_conference.view_registration"), + "registration_commerce": user.has_perm("program_conference.view_commerce"), + "badges": user.has_perm("program_conference.view_badges"), + "sponsors": user.has_perm("program_conference.view_sponsors"), + "bulk_purchases": user.has_perm("program_conference.view_bulk_purchases"), + "activities": user.has_perm("program_programs.view_activity"), + "travel_grants": user.has_perm("program_programs.view_travel_grant"), + "checkin": user.has_perm("program_conference.view_checkin"), + "terminal": user.has_perm("program_conference.use_terminal"), + "onsite": ( + user.has_perm("program_conference.view_checkin") or user.has_perm("program_conference.use_terminal") + ), + "finance": user.has_perm("program_conference.view_finance"), + "reports": user.has_perm("program_conference.view_reports"), + "overrides": user.has_perm("program_conference.view_overrides"), + } + def _get_last_synced(self) -> object: """Find the most recent synced_at timestamp across all synced models.""" latest_values = [] @@ -242,6 +297,10 @@ def _get_last_synced(self) -> object: return max(latest_values) if latest_values else None +# Backward compatibility alias +ManagePermissionMixin = ConferencePermissionMixin + + class ConferenceListView(LoginRequiredMixin, ListView): """List all conferences visible to the current user. @@ -254,10 +313,7 @@ class ConferenceListView(LoginRequiredMixin, ListView): paginate_by = 25 def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: - """Check that the user is a superuser or staff member. - - Authentication is checked first; if the user is not logged in, - ``LoginRequiredMixin`` handles the redirect. + """Check that the user has at least one conference management permission. Args: request: The incoming HTTP request. @@ -268,12 +324,13 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo The HTTP response. Raises: - PermissionDenied: If the user is not superuser or staff. + PermissionDenied: If the user has no conference management permissions. """ if not request.user.is_authenticated: return self.handle_no_permission() - if not (request.user.is_superuser or request.user.is_staff): + user = request.user + if not (user.is_superuser or any(p.startswith("program_conference.") for p in user.get_all_permissions())): raise PermissionDenied return super().dispatch(request, *args, **kwargs) @@ -281,7 +338,7 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo def get_queryset(self) -> QuerySet[Conference]: """Return conferences visible to the current user. - Superusers see all conferences; staff see active conferences only. + Superusers see all conferences; other permitted users see active ones. Returns: A queryset of Conference instances. @@ -302,7 +359,7 @@ class ImportFromPretalxView(LoginRequiredMixin, TemplateView): template_name = "django_program/manage/import_pretalx.html" def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: - """Check that the user is a superuser or staff member. + """Require ``manage_conference_settings`` permission for conference creation. Args: request: The incoming HTTP request. @@ -313,12 +370,12 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo The HTTP response. Raises: - PermissionDenied: If the user is not superuser or staff. + PermissionDenied: If the user lacks the required permission. """ if not request.user.is_authenticated: return self.handle_no_permission() - if not (request.user.is_superuser or request.user.is_staff): + if not (request.user.is_superuser or request.user.has_perm("program_conference.manage_conference_settings")): raise PermissionDenied return super().dispatch(request, *args, **kwargs) @@ -436,10 +493,10 @@ class ImportPretalxStreamView(LoginRequiredMixin, View): """ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: - """Enforce staff/superuser permissions.""" + """Require ``manage_conference_settings`` permission for conference import.""" if not request.user.is_authenticated: return self.handle_no_permission() - if not (request.user.is_superuser or request.user.is_staff): + if not (request.user.is_superuser or request.user.has_perm("program_conference.manage_conference_settings")): raise PermissionDenied return super().dispatch(request, *args, **kwargs) @@ -740,6 +797,7 @@ class DashboardView(ManagePermissionMixin, TemplateView): """ template_name = "django_program/manage/dashboard.html" + required_permission = "view_dashboard" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build dashboard context with summary statistics. @@ -798,6 +856,7 @@ class ConferenceEditView(ManagePermissionMixin, UpdateView): """ template_name = "django_program/manage/conference_edit.html" + required_permission = "manage_conference_settings" form_class = ConferenceForm context_object_name = "conference" @@ -877,6 +936,7 @@ class SectionListView(ManagePermissionMixin, ListView): """List sections for the current conference.""" template_name = "django_program/manage/section_list.html" + required_permission = "view_program" context_object_name = "sections" paginate_by = 50 @@ -899,6 +959,7 @@ class SectionEditView(ManagePermissionMixin, UpdateView): """Edit a section belonging to the current conference.""" template_name = "django_program/manage/section_edit.html" + required_permission = "change_program" form_class = SectionForm context_object_name = "section" @@ -941,6 +1002,7 @@ class SectionCreateView(ManagePermissionMixin, CreateView): """Create a new section for the current conference.""" template_name = "django_program/manage/section_edit.html" + required_permission = "change_program" form_class = SectionForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -972,6 +1034,7 @@ class RoomListView(ManagePermissionMixin, ListView): """List rooms for the current conference, ordered by position.""" template_name = "django_program/manage/room_list.html" + required_permission = "view_program" context_object_name = "rooms" paginate_by = 50 @@ -998,6 +1061,7 @@ class RoomEditView(ManagePermissionMixin, UpdateView): """ template_name = "django_program/manage/room_edit.html" + required_permission = "change_program" form_class = RoomForm context_object_name = "room" @@ -1051,6 +1115,7 @@ class RoomCreateView(ManagePermissionMixin, CreateView): """Create a new room for the current conference.""" template_name = "django_program/manage/room_edit.html" + required_permission = "change_program" form_class = RoomForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -1087,6 +1152,7 @@ class SpeakerListView(ManagePermissionMixin, ListView): """ template_name = "django_program/manage/speaker_list.html" + required_permission = "view_program" context_object_name = "speakers" paginate_by = 50 @@ -1122,6 +1188,7 @@ class SpeakerDetailView(ManagePermissionMixin, DetailView): """Read-only detail view for a speaker in the current conference.""" template_name = "django_program/manage/speaker_detail.html" + required_permission = "view_program" context_object_name = "speaker" def get_queryset(self) -> QuerySet[Speaker]: @@ -1149,6 +1216,7 @@ class TalkListView(ManagePermissionMixin, ListView): """ template_name = "django_program/manage/talk_list.html" + required_permission = "view_program" context_object_name = "talks" paginate_by = 50 @@ -1227,6 +1295,7 @@ class TalkDetailView(ManagePermissionMixin, DetailView): """Read-only detail view for a talk in the current conference.""" template_name = "django_program/manage/talk_detail.html" + required_permission = "view_program" context_object_name = "talk" def get_queryset(self) -> QuerySet[Talk]: @@ -1263,6 +1332,7 @@ class TalkEditView(ManagePermissionMixin, UpdateView): """ template_name = "django_program/manage/talk_edit.html" + required_permission = "change_program" form_class = TalkForm context_object_name = "talk" @@ -1317,6 +1387,7 @@ class ScheduleSlotListView(ManagePermissionMixin, ListView): """List schedule slots for the current conference, grouped by date.""" template_name = "django_program/manage/schedule_list.html" + required_permission = "view_program" context_object_name = "slots" paginate_by = 200 @@ -1359,6 +1430,7 @@ class ScheduleSlotEditView(ManagePermissionMixin, UpdateView): """ template_name = "django_program/manage/slot_edit.html" + required_permission = "change_program" form_class = ScheduleSlotForm context_object_name = "slot" @@ -1413,6 +1485,7 @@ class SponsorLevelListView(ManagePermissionMixin, ListView): """List sponsor levels for the current conference.""" template_name = "django_program/manage/sponsor_level_list.html" + required_permission = "view_sponsors" context_object_name = "levels" paginate_by = 50 @@ -1435,6 +1508,7 @@ class SponsorLevelEditView(ManagePermissionMixin, UpdateView): """Edit a sponsor level.""" template_name = "django_program/manage/sponsor_level_edit.html" + required_permission = "change_sponsors" form_class = SponsorLevelForm context_object_name = "level" @@ -1462,6 +1536,7 @@ class SponsorLevelCreateView(ManagePermissionMixin, CreateView): """Create a new sponsor level.""" template_name = "django_program/manage/sponsor_level_edit.html" + required_permission = "change_sponsors" form_class = SponsorLevelForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -1486,6 +1561,7 @@ class SponsorManageListView(ManagePermissionMixin, ListView): """List sponsors for the current conference.""" template_name = "django_program/manage/sponsor_list.html" + required_permission = "view_sponsors" context_object_name = "sponsors" paginate_by = 50 @@ -1512,6 +1588,7 @@ class SponsorEditView(ManagePermissionMixin, UpdateView): """ template_name = "django_program/manage/sponsor_edit.html" + required_permission = "change_sponsors" form_class = SponsorForm context_object_name = "sponsor" @@ -1554,6 +1631,7 @@ class SponsorCreateView(ManagePermissionMixin, CreateView): """Create a new sponsor.""" template_name = "django_program/manage/sponsor_edit.html" + required_permission = "change_sponsors" form_class = SponsorForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -1591,6 +1669,7 @@ class ActivityManageListView(ManagePermissionMixin, ListView): """List activities for the current conference.""" template_name = "django_program/manage/activity_list.html" + required_permission = "program_programs.view_activity" context_object_name = "activities" paginate_by = 50 @@ -1627,6 +1706,7 @@ class ActivityEditView(ManagePermissionMixin, UpdateView): """Edit an activity.""" template_name = "django_program/manage/activity_edit.html" + required_permission = "program_programs.manage_activity" form_class = ActivityForm context_object_name = "activity" @@ -1663,6 +1743,7 @@ class ActivityCreateView(ManagePermissionMixin, CreateView): """Create a new activity.""" template_name = "django_program/manage/activity_edit.html" + required_permission = "program_programs.manage_activity" form_class = ActivityForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -1828,6 +1909,8 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: class RoomSearchView(ManagePermissionMixin, View): """JSON API endpoint for room autocomplete within a conference.""" + required_permission = "view_program" + def get(self, request: HttpRequest, **kwargs: str) -> JsonResponse: # noqa: ARG002 """Search rooms by name for the current conference. @@ -1854,6 +1937,7 @@ class TravelGrantManageListView(ManagePermissionMixin, ListView): """ template_name = "django_program/manage/travel_grant_list.html" + required_permission = "program_programs.view_travel_grant" context_object_name = "grants" paginate_by = 50 @@ -1940,6 +2024,7 @@ class TravelGrantReviewView(ManagePermissionMixin, UpdateView): """Review a travel grant application.""" template_name = "django_program/manage/travel_grant_edit.html" + required_permission = "program_programs.review_travel_grant" form_class = TravelGrantForm context_object_name = "grant" @@ -1977,6 +2062,8 @@ 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"]) @@ -1995,6 +2082,8 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: class TravelGrantDisburseView(ManagePermissionMixin, View): """Mark a travel grant as disbursed.""" + required_permission = "program_programs.disburse_travel_grant" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Record disbursement details and transition the grant status. @@ -2030,6 +2119,8 @@ 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 = ( @@ -2057,6 +2148,7 @@ class ReceiptReviewDetailView(ManagePermissionMixin, DetailView): """Display a receipt for review with approve/flag controls.""" template_name = "django_program/manage/receipt_review.html" + required_permission = "program_programs.review_receipt" context_object_name = "receipt" def get_queryset(self) -> QuerySet[Receipt]: @@ -2074,6 +2166,8 @@ 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) @@ -2088,6 +2182,8 @@ 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) @@ -2110,6 +2206,8 @@ class SyncPretalxView(ManagePermissionMixin, View): checkboxes are selected, syncs everything. """ + required_permission = "manage_conference_settings" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Run the Pretalx sync and redirect back to the dashboard. @@ -2181,6 +2279,8 @@ class SyncSponsorsView(ManagePermissionMixin, View): where the sponsor profile supports API sync. """ + required_permission = "manage_conference_settings" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Run the PSF sponsor sync and redirect back to the dashboard. @@ -2213,6 +2313,8 @@ 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( @@ -2439,7 +2541,7 @@ class PretalxEventSearchView(LoginRequiredMixin, View): """ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: - """Enforce staff/superuser permissions. + """Require ``manage_conference_settings`` permission for event search. Args: request: The incoming HTTP request. @@ -2450,11 +2552,11 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo The HTTP response. Raises: - PermissionDenied: If the user is not superuser or staff. + PermissionDenied: If the user lacks the required permission. """ if not request.user.is_authenticated: return self.handle_no_permission() - if not (request.user.is_superuser or request.user.is_staff): + if not (request.user.is_superuser or request.user.has_perm("program_conference.manage_conference_settings")): raise PermissionDenied return super().dispatch(request, *args, **kwargs) @@ -2587,6 +2689,7 @@ class TicketTypeListView(ManagePermissionMixin, ListView): """List ticket types for the current conference.""" template_name = "django_program/manage/ticket_type_list.html" + required_permission = "view_commerce" context_object_name = "ticket_types" paginate_by = 50 @@ -2654,6 +2757,7 @@ class TicketTypeCreateView(ManagePermissionMixin, CreateView): """Create a new ticket type for the current conference.""" template_name = "django_program/manage/ticket_type_edit.html" + required_permission = "change_commerce" form_class = TicketTypeForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -2680,6 +2784,7 @@ class TicketTypeEditView(ManagePermissionMixin, UpdateView): """Edit a ticket type belonging to the current conference.""" template_name = "django_program/manage/ticket_type_edit.html" + required_permission = "change_commerce" form_class = TicketTypeForm context_object_name = "ticket_type" @@ -2707,6 +2812,7 @@ class AddOnListView(ManagePermissionMixin, ListView): """List add-ons for the current conference.""" template_name = "django_program/manage/addon_list.html" + required_permission = "view_commerce" context_object_name = "addons" paginate_by = 50 @@ -2752,6 +2858,7 @@ class AddOnCreateView(ManagePermissionMixin, CreateView): """Create a new add-on for the current conference.""" template_name = "django_program/manage/addon_edit.html" + required_permission = "change_commerce" form_class = AddOnForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -2778,6 +2885,7 @@ class AddOnEditView(ManagePermissionMixin, UpdateView): """Edit an add-on belonging to the current conference.""" template_name = "django_program/manage/addon_edit.html" + required_permission = "change_commerce" form_class = AddOnForm context_object_name = "addon" @@ -2808,6 +2916,7 @@ class VoucherListView(ManagePermissionMixin, ListView): """ template_name = "django_program/manage/voucher_list.html" + required_permission = "view_commerce" context_object_name = "vouchers" paginate_by = 50 @@ -2830,6 +2939,7 @@ class VoucherCreateView(ManagePermissionMixin, CreateView): """Create a new voucher for the current conference.""" template_name = "django_program/manage/voucher_edit.html" + required_permission = "change_commerce" form_class = VoucherForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -2861,6 +2971,7 @@ class VoucherEditView(ManagePermissionMixin, UpdateView): """Edit a voucher belonging to the current conference.""" template_name = "django_program/manage/voucher_edit.html" + required_permission = "change_commerce" form_class = VoucherForm context_object_name = "voucher" @@ -2895,6 +3006,7 @@ class AttendeeListView(ManagePermissionMixin, ListView): """List attendees for the current conference with check-in status.""" template_name = "django_program/manage/attendee_list.html" + required_permission = "view_registration" context_object_name = "attendees" paginate_by = 50 @@ -2932,6 +3044,7 @@ class AttendeeDetailView(ManagePermissionMixin, DetailView): """Staff-facing attendee dossier showing all activity for this person at this conference.""" template_name = "django_program/manage/attendee_detail.html" + required_permission = "view_registration" context_object_name = "attendee" def get_queryset(self) -> QuerySet[Attendee]: @@ -2986,6 +3099,7 @@ class OrderListView(ManagePermissionMixin, ListView): """ template_name = "django_program/manage/order_list.html" + required_permission = "view_registration" context_object_name = "orders" paginate_by = 50 @@ -3017,6 +3131,7 @@ class OrderDetailView(ManagePermissionMixin, DetailView): """ template_name = "django_program/manage/order_detail.html" + required_permission = "view_registration" context_object_name = "order" def get_queryset(self) -> QuerySet[Order]: @@ -3048,6 +3163,8 @@ class ManualPaymentView(ManagePermissionMixin, View): the order status is automatically transitioned to ``paid``. """ + required_permission = "change_registration" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Record a manual payment and optionally mark the order as paid. @@ -3173,6 +3290,7 @@ class ConditionListView(ManagePermissionMixin, TemplateView): """ template_name = "django_program/manage/condition_list.html" + required_permission = "view_commerce" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build a merged list of all conditions with display metadata.""" @@ -3217,6 +3335,7 @@ class ConditionCreateView(ManagePermissionMixin, CreateView): """ template_name = "django_program/manage/condition_edit.html" + required_permission = "change_commerce" def setup(self, request: HttpRequest, *args: object, **kwargs: object) -> None: """Resolve the condition type from the URL.""" @@ -3265,6 +3384,7 @@ class ConditionEditView(ManagePermissionMixin, UpdateView): """ template_name = "django_program/manage/condition_edit.html" + required_permission = "change_commerce" context_object_name = "condition" def setup(self, request: HttpRequest, *args: object, **kwargs: object) -> None: @@ -3318,6 +3438,7 @@ class BadgeTemplateListView(ManagePermissionMixin, ListView): """List badge templates for the current conference.""" template_name = "django_program/manage/badge_template_list.html" + required_permission = "view_badges" context_object_name = "badge_templates" paginate_by = 50 @@ -3348,6 +3469,7 @@ class BadgeTemplateCreateView(ManagePermissionMixin, CreateView): """Create a new badge template for the current conference.""" template_name = "django_program/manage/badge_template_edit.html" + required_permission = "change_badges" form_class = BadgeTemplateForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -3379,6 +3501,7 @@ class BadgeTemplateEditView(ManagePermissionMixin, UpdateView): """Edit an existing badge template.""" template_name = "django_program/manage/badge_template_edit.html" + required_permission = "change_badges" form_class = BadgeTemplateForm context_object_name = "badge_template" @@ -3410,6 +3533,8 @@ class BadgeBulkGenerateView(ManagePermissionMixin, View): format to use for generation. """ + required_permission = "change_badges" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Trigger bulk badge generation and redirect with a count message. @@ -3457,6 +3582,7 @@ class BadgeListView(ManagePermissionMixin, ListView): """List all generated badges with download links and filtering.""" template_name = "django_program/manage/badge_list.html" + required_permission = "view_badges" context_object_name = "badges" paginate_by = 50 @@ -3512,6 +3638,8 @@ def get_queryset(self) -> QuerySet[Badge]: class BadgeDownloadView(ManagePermissionMixin, View): """Serve a single badge file as a download attachment.""" + required_permission = "view_badges" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Return the badge file as an attachment. @@ -3542,6 +3670,8 @@ def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: class BadgeBulkDownloadView(ManagePermissionMixin, View): """Generate a ZIP archive of all matching badges and stream it.""" + required_permission = "view_badges" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Build and return a ZIP of badge files. @@ -3593,6 +3723,8 @@ class BadgePreviewView(ManagePermissionMixin, View): Always regenerates fresh output so template edits are reflected immediately. """ + required_permission = "view_badges" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Render a preview badge inline without saving. @@ -3627,6 +3759,7 @@ class ExpenseCategoryListView(ManagePermissionMixin, ListView): """List all expense categories for a conference.""" template_name = "django_program/manage/expense_category_list.html" + required_permission = "view_finance" context_object_name = "categories" def get_queryset(self) -> QuerySet[ExpenseCategory]: @@ -3654,6 +3787,7 @@ class ExpenseCategoryCreateView(ManagePermissionMixin, CreateView): """Create a new expense category.""" template_name = "django_program/manage/expense_category_edit.html" + required_permission = "change_finance" form_class = ExpenseCategoryForm def get_success_url(self) -> str: @@ -3678,6 +3812,7 @@ class ExpenseCategoryEditView(ManagePermissionMixin, UpdateView): """Edit an existing expense category.""" template_name = "django_program/manage/expense_category_edit.html" + required_permission = "change_finance" form_class = ExpenseCategoryForm context_object_name = "category" @@ -3706,6 +3841,7 @@ class ExpenseListView(ManagePermissionMixin, ListView): """List all expenses for a conference, optionally filtered by category.""" template_name = "django_program/manage/expense_list.html" + required_permission = "view_finance" context_object_name = "expenses" paginate_by = 50 @@ -3737,6 +3873,7 @@ class ExpenseCreateView(ManagePermissionMixin, CreateView): """Create a new expense.""" template_name = "django_program/manage/expense_edit.html" + required_permission = "change_finance" form_class = ExpenseForm def get_form_kwargs(self) -> dict[str, object]: @@ -3768,6 +3905,7 @@ class ExpenseEditView(ManagePermissionMixin, UpdateView): """Edit an existing expense.""" template_name = "django_program/manage/expense_edit.html" + required_permission = "change_finance" form_class = ExpenseForm context_object_name = "expense" diff --git a/src/django_program/manage/views_analytics.py b/src/django_program/manage/views_analytics.py index cd8ab2d..f0bc402 100644 --- a/src/django_program/manage/views_analytics.py +++ b/src/django_program/manage/views_analytics.py @@ -26,7 +26,7 @@ get_ticket_sales_ratio, get_travel_grant_analytics, ) -from django_program.manage.views_reports import ReportPermissionMixin +from django_program.manage.views import ConferencePermissionMixin _ZERO = Decimal("0.00") @@ -90,7 +90,7 @@ def _get_effective_targets(conference: object) -> dict[str, Any]: return targets -class AnalyticsDashboardView(ReportPermissionMixin, TemplateView): +class AnalyticsDashboardView(ConferencePermissionMixin, TemplateView): """Main analytics and KPI dashboard aggregating Tier 1 metrics. Provides revenue per attendee, cart funnel, check-in throughput, @@ -99,6 +99,7 @@ class AnalyticsDashboardView(ReportPermissionMixin, TemplateView): """ template_name = "django_program/manage/analytics_dashboard.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with all Tier 1 analytics KPIs and chart data. @@ -200,7 +201,7 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: return context -class SponsorAnalyticsView(ReportPermissionMixin, TemplateView): +class SponsorAnalyticsView(ConferencePermissionMixin, TemplateView): """Sponsor analytics dashboard with revenue and goal tracking. Provides per-level sponsor counts, revenue, benefit fulfillment @@ -208,6 +209,7 @@ class SponsorAnalyticsView(ReportPermissionMixin, TemplateView): """ template_name = "django_program/manage/sponsor_analytics.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with sponsor analytics data. @@ -240,7 +242,7 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: return context -class CrossEventDashboardView(ReportPermissionMixin, TemplateView): +class CrossEventDashboardView(ConferencePermissionMixin, TemplateView): """Cross-event intelligence dashboard with Tier 3 metrics. Provides year-over-year retention, attendee lifetime value, @@ -248,6 +250,7 @@ class CrossEventDashboardView(ReportPermissionMixin, TemplateView): """ template_name = "django_program/manage/cross_event_dashboard.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with cross-conference analytics. diff --git a/src/django_program/manage/views_bulk_purchases.py b/src/django_program/manage/views_bulk_purchases.py index 5015990..bae0544 100644 --- a/src/django_program/manage/views_bulk_purchases.py +++ b/src/django_program/manage/views_bulk_purchases.py @@ -30,6 +30,7 @@ class BulkPurchaseListView(ManagePermissionMixin, ListView): """ template_name = "django_program/manage/bulk_purchase_list.html" + required_permission = "view_bulk_purchases" context_object_name = "bulk_purchases" paginate_by = 50 @@ -58,6 +59,7 @@ class BulkPurchaseDetailView(ManagePermissionMixin, DetailView): """Display full details of a bulk purchase with its generated voucher codes.""" template_name = "django_program/manage/bulk_purchase_detail.html" + required_permission = "view_bulk_purchases" context_object_name = "purchase" def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -82,6 +84,7 @@ class BulkPurchaseCreateView(ManagePermissionMixin, CreateView): """Create a new bulk purchase on behalf of a sponsor.""" template_name = "django_program/manage/bulk_purchase_form.html" + required_permission = "change_bulk_purchases" form_class = BulkPurchaseCreateForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -125,6 +128,8 @@ class BulkPurchaseApproveView(ManagePermissionMixin, View): fulfillment can proceed. """ + required_permission = "change_bulk_purchases" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Mark the bulk purchase as approved.""" purchase = get_object_or_404( @@ -163,6 +168,8 @@ class BulkPurchaseFulfillView(ManagePermissionMixin, View): them back to the purchase via ``BulkPurchaseVoucher`` records. """ + required_permission = "change_bulk_purchases" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Generate vouchers for the bulk purchase.""" purchase = get_object_or_404( diff --git a/src/django_program/manage/views_checkin.py b/src/django_program/manage/views_checkin.py index d00cd9f..f8a3b34 100644 --- a/src/django_program/manage/views_checkin.py +++ b/src/django_program/manage/views_checkin.py @@ -22,6 +22,7 @@ class CheckInScannerView(ManagePermissionMixin, TemplateView): """ template_name = "django_program/manage/checkin_scanner.html" + required_permission = "view_checkin" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Add active_nav to the template context. @@ -46,6 +47,7 @@ class CheckInDashboardView(ManagePermissionMixin, TemplateView): """ template_name = "django_program/manage/checkin_dashboard.html" + required_permission = "view_checkin" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with check-in statistics for the dashboard. diff --git a/src/django_program/manage/views_financial.py b/src/django_program/manage/views_financial.py index edb1562..0c41185 100644 --- a/src/django_program/manage/views_financial.py +++ b/src/django_program/manage/views_financial.py @@ -8,17 +8,13 @@ import datetime import json from decimal import Decimal +from typing import TYPE_CHECKING -from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import PermissionDenied from django.db.models import Count, Q, QuerySet, Sum, Value from django.db.models.functions import Coalesce -from django.http import HttpRequest, HttpResponse # noqa: TC002 -from django.shortcuts import get_object_or_404 from django.utils import timezone from django.views.generic import TemplateView -from django_program.conference.models import Conference from django_program.manage.reports import ( get_aov_by_date, get_cashflow_waterfall, @@ -29,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, @@ -46,8 +46,6 @@ _ZERO = Decimal("0.00") -_FINANCE_GROUP_NAME = "Program: Finance & Accounting" - def _build_chart_context( conference: Conference, @@ -221,80 +219,20 @@ def _build_financial_budget_context(conference: Conference, total_revenue: Decim return budget -class FinancePermissionMixin(LoginRequiredMixin): - """Permission mixin for finance-scoped management views. - - Resolves the conference from the ``conference_slug`` URL kwarg and - checks that the authenticated user satisfies at least one of: - - * is a superuser, - * holds the ``program_conference.change_conference`` permission - (Conference Organizers), or - * belongs to the "Program: Finance & Accounting" group. - - Stores the resolved conference on ``self.conference`` and injects it - into the template context alongside ``active_nav``. - - Raises: - PermissionDenied: If the user fails all three checks. - """ - - conference: Conference - kwargs: dict[str, str] - - def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: - """Resolve the conference and enforce permissions before dispatch. - - Args: - request: The incoming HTTP request. - *args: Positional arguments from the URL resolver. - **kwargs: Keyword arguments from the URL pattern. - - Returns: - The HTTP response from the downstream view. - - Raises: - PermissionDenied: If the user is not authorized. - """ - if not request.user.is_authenticated: - return self.handle_no_permission() # type: ignore[return-value] - - self.conference = get_object_or_404(Conference, slug=kwargs.get("conference_slug", "")) - - user = request.user - allowed = ( - user.is_superuser - or user.has_perm("program_conference.change_conference") - or user.groups.filter(name=_FINANCE_GROUP_NAME).exists() - ) - if not allowed: - raise PermissionDenied - - return super().dispatch(request, *args, **kwargs) # type: ignore[misc] - - def get_context_data(self, **kwargs: object) -> dict[str, object]: - """Add the conference to the template context. - - Args: - **kwargs: Additional context data. - - Returns: - Template context with ``conference`` included. - """ - context: dict[str, object] = super().get_context_data(**kwargs) - context["conference"] = self.conference - return context +# Backward compatibility alias +FinancePermissionMixin = ConferencePermissionMixin -class FinancialDashboardView(FinancePermissionMixin, TemplateView): +class FinancialDashboardView(ConferencePermissionMixin, TemplateView): """Comprehensive financial overview for a conference. Computes revenue totals, order/cart/payment breakdowns, ticket sales analytics, and surfaces recent orders and active carts. All data is - scoped to ``self.conference`` (resolved by ``FinancePermissionMixin``). + scoped to ``self.conference``. """ template_name = "django_program/manage/financial_dashboard.html" + required_permission = "view_finance" def get_context_data(self, **kwargs: object) -> dict[str, object]: # noqa: PLR0915 """Build context with all financial metrics for the dashboard. diff --git a/src/django_program/manage/views_letters.py b/src/django_program/manage/views_letters.py index e270a94..b301399 100644 --- a/src/django_program/manage/views_letters.py +++ b/src/django_program/manage/views_letters.py @@ -31,6 +31,7 @@ class LetterRequestListView(ManagePermissionMixin, ListView): """ template_name = "django_program/manage/letter_request_list.html" + required_permission = "view_registration" context_object_name = "letter_requests" paginate_by = 50 @@ -81,6 +82,7 @@ class LetterRequestReviewView(ManagePermissionMixin, DetailView): """ template_name = "django_program/manage/letter_request_review.html" + required_permission = "change_registration" context_object_name = "letter_request" def get_queryset(self) -> QuerySet[LetterRequest]: @@ -192,6 +194,8 @@ class LetterRequestGenerateView(ManagePermissionMixin, View): to the review page. """ + required_permission = "change_registration" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Generate the PDF for an approved letter request. @@ -230,6 +234,8 @@ class LetterRequestBulkGenerateView(ManagePermissionMixin, View): summary message. """ + required_permission = "change_registration" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Generate PDFs for all approved letter requests in the conference. @@ -268,6 +274,8 @@ class LetterRequestSendView(ManagePermissionMixin, View): timestamp, and redirects back to the review page. """ + required_permission = "change_registration" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Send the invitation letter for a generated request. @@ -302,9 +310,12 @@ class LetterRequestDownloadView(ManagePermissionMixin, View): """Download the generated PDF for a letter request. GET-only. Returns the PDF file as an attachment response. Only works - if a generated PDF exists on the letter request. + if a generated PDF exists on the letter request. Requires write-level + access because the PDF contains passport PII. """ + required_permission = "change_registration" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Return the generated PDF as a downloadable attachment. diff --git a/src/django_program/manage/views_overrides.py b/src/django_program/manage/views_overrides.py index a44213b..dc59e5f 100644 --- a/src/django_program/manage/views_overrides.py +++ b/src/django_program/manage/views_overrides.py @@ -32,6 +32,7 @@ class TalkOverrideListView(ManagePermissionMixin, ListView): """List all talk overrides for the current conference.""" template_name = "django_program/manage/override_list.html" + required_permission = "view_overrides" context_object_name = "overrides" paginate_by = 50 @@ -55,6 +56,7 @@ class TalkOverrideCreateView(ManagePermissionMixin, CreateView): """Create a new talk override for the current conference.""" template_name = "django_program/manage/override_form.html" + required_permission = "change_overrides" form_class = TalkOverrideForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -96,6 +98,7 @@ class TalkOverrideEditView(ManagePermissionMixin, UpdateView): """Edit an existing talk override.""" template_name = "django_program/manage/override_form.html" + required_permission = "change_overrides" form_class = TalkOverrideForm def get_queryset(self) -> QuerySet[TalkOverride]: @@ -142,6 +145,7 @@ class SpeakerOverrideListView(ManagePermissionMixin, ListView): """List all speaker overrides for the current conference.""" template_name = "django_program/manage/speaker_override_list.html" + required_permission = "view_overrides" context_object_name = "overrides" paginate_by = 50 @@ -165,6 +169,7 @@ class SpeakerOverrideCreateView(ManagePermissionMixin, CreateView): """Create a new speaker override.""" template_name = "django_program/manage/speaker_override_form.html" + required_permission = "change_overrides" form_class = SpeakerOverrideForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -206,6 +211,7 @@ class SpeakerOverrideEditView(ManagePermissionMixin, UpdateView): """Edit an existing speaker override.""" template_name = "django_program/manage/speaker_override_form.html" + required_permission = "change_overrides" form_class = SpeakerOverrideForm def get_queryset(self) -> QuerySet[SpeakerOverride]: @@ -252,6 +258,7 @@ class RoomOverrideListView(ManagePermissionMixin, ListView): """List all room overrides for the current conference.""" template_name = "django_program/manage/room_override_list.html" + required_permission = "view_overrides" context_object_name = "overrides" paginate_by = 50 @@ -275,6 +282,7 @@ class RoomOverrideCreateView(ManagePermissionMixin, CreateView): """Create a new room override.""" template_name = "django_program/manage/room_override_form.html" + required_permission = "change_overrides" form_class = RoomOverrideForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -316,6 +324,7 @@ class RoomOverrideEditView(ManagePermissionMixin, UpdateView): """Edit an existing room override.""" template_name = "django_program/manage/room_override_form.html" + required_permission = "change_overrides" form_class = RoomOverrideForm def get_queryset(self) -> QuerySet[RoomOverride]: @@ -362,6 +371,7 @@ class SponsorOverrideListView(ManagePermissionMixin, ListView): """List all sponsor overrides for the current conference.""" template_name = "django_program/manage/sponsor_override_list.html" + required_permission = "view_overrides" context_object_name = "overrides" paginate_by = 50 @@ -385,6 +395,7 @@ class SponsorOverrideCreateView(ManagePermissionMixin, CreateView): """Create a new sponsor override.""" template_name = "django_program/manage/sponsor_override_form.html" + required_permission = "change_overrides" form_class = SponsorOverrideForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -426,6 +437,7 @@ class SponsorOverrideEditView(ManagePermissionMixin, UpdateView): """Edit an existing sponsor override.""" template_name = "django_program/manage/sponsor_override_form.html" + required_permission = "change_overrides" form_class = SponsorOverrideForm def get_queryset(self) -> QuerySet[SponsorOverride]: @@ -472,6 +484,7 @@ class SubmissionTypeDefaultListView(ManagePermissionMixin, ListView): """List all submission type defaults for the current conference.""" template_name = "django_program/manage/submission_type_default_list.html" + required_permission = "view_overrides" context_object_name = "type_defaults" paginate_by = 50 @@ -495,6 +508,7 @@ class SubmissionTypeDefaultCreateView(ManagePermissionMixin, CreateView): """Create a new submission type default for the current conference.""" template_name = "django_program/manage/submission_type_default_form.html" + required_permission = "change_overrides" form_class = SubmissionTypeDefaultForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -526,6 +540,7 @@ class SubmissionTypeDefaultEditView(ManagePermissionMixin, UpdateView): """Edit an existing submission type default.""" template_name = "django_program/manage/submission_type_default_form.html" + required_permission = "change_overrides" form_class = SubmissionTypeDefaultForm def get_queryset(self) -> QuerySet[SubmissionTypeDefault]: diff --git a/src/django_program/manage/views_reports.py b/src/django_program/manage/views_reports.py index c999cc0..753ff0a 100644 --- a/src/django_program/manage/views_reports.py +++ b/src/django_program/manage/views_reports.py @@ -10,17 +10,14 @@ import datetime import json from decimal import Decimal +from typing import TYPE_CHECKING -from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import PermissionDenied from django.db.models import Count, QuerySet, Sum from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404 from django.utils import timezone from django.views import View from django.views.generic import ListView, TemplateView -from django_program.conference.models import Conference from django_program.manage.reports import ( get_addon_inventory, get_attendee_manifest, @@ -42,13 +39,14 @@ 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 -_REPORTS_GROUP_NAME = "Program: Reports" +if TYPE_CHECKING: + from django_program.conference.models import Conference def _build_budget_context(conference: Conference) -> dict[str, object]: @@ -102,75 +100,15 @@ def _build_budget_context(conference: Conference) -> dict[str, object]: return budget -class ReportPermissionMixin(LoginRequiredMixin): - """Permission mixin for report-scoped management views. +# Backward compatibility alias +ReportPermissionMixin = ConferencePermissionMixin - Resolves the conference from the ``conference_slug`` URL kwarg and - checks that the authenticated user satisfies at least one of: - * is a superuser, - * holds the ``program_conference.change_conference`` permission, or - * belongs to the "Program: Reports" group. - - Stores the resolved conference on ``self.conference`` and injects it - into the template context alongside ``active_nav``. - - Raises: - PermissionDenied: If the user fails all three checks. - """ - - conference: Conference - kwargs: dict[str, str] - - def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: - """Resolve the conference and enforce permissions before dispatch. - - Args: - request: The incoming HTTP request. - *args: Positional arguments from the URL resolver. - **kwargs: Keyword arguments from the URL pattern. - - Returns: - The HTTP response from the downstream view. - - Raises: - PermissionDenied: If the user is not authorized. - """ - if not request.user.is_authenticated: - return self.handle_no_permission() # type: ignore[return-value] - - self.conference = get_object_or_404(Conference, slug=kwargs.get("conference_slug", "")) - - user = request.user - allowed = ( - user.is_superuser - or user.has_perm("program_conference.change_conference") - or user.groups.filter(name=_REPORTS_GROUP_NAME).exists() - ) - if not allowed: - raise PermissionDenied - - return super().dispatch(request, *args, **kwargs) # type: ignore[misc] - - def get_context_data(self, **kwargs: object) -> dict[str, object]: - """Add the conference and active_nav to the template context. - - Args: - **kwargs: Additional context data. - - Returns: - Template context with ``conference`` and ``active_nav`` included. - """ - context: dict[str, object] = super().get_context_data(**kwargs) - context["conference"] = self.conference - context["active_nav"] = "reports" - return context - - -class ReportsDashboardView(ReportPermissionMixin, TemplateView): +class ReportsDashboardView(ConferencePermissionMixin, TemplateView): """Landing page for all admin reports with summary statistics.""" template_name = "django_program/manage/reports_dashboard.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with summary stats for all report types. @@ -299,13 +237,15 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: {k: float(v) if isinstance(v, Decimal) else v for k, v in budget.items()} ) + context["active_nav"] = "reports" return context -class AttendeeManifestView(ReportPermissionMixin, ListView): +class AttendeeManifestView(ConferencePermissionMixin, ListView): """Filterable attendee manifest with pagination.""" template_name = "django_program/manage/report_attendee_manifest.html" + required_permission = "view_reports" context_object_name = "attendees" paginate_by = 50 @@ -366,12 +306,15 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: else: attendee.ticket_descriptions = "" # type: ignore[attr-defined] + context["active_nav"] = "reports" return context -class AttendeeManifestExportView(ReportPermissionMixin, View): +class AttendeeManifestExportView(ConferencePermissionMixin, View): """CSV export of the attendee manifest.""" + required_permission = "export_reports" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Return a CSV download of the attendee manifest. @@ -427,10 +370,11 @@ def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG return response -class InventoryReportView(ReportPermissionMixin, TemplateView): +class InventoryReportView(ConferencePermissionMixin, TemplateView): """Product inventory and stock status report.""" template_name = "django_program/manage/report_inventory.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with ticket type and add-on inventory data. @@ -461,12 +405,15 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: ] ) + context["active_nav"] = "reports" return context -class InventoryReportExportView(ReportPermissionMixin, View): +class InventoryReportExportView(ConferencePermissionMixin, View): """CSV export of product inventory.""" + required_permission = "export_reports" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Return a CSV download of inventory data. @@ -538,10 +485,11 @@ def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG return response -class VoucherUsageReportView(ReportPermissionMixin, TemplateView): +class VoucherUsageReportView(ConferencePermissionMixin, TemplateView): """Voucher usage and redemption rates report.""" template_name = "django_program/manage/report_voucher_usage.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with voucher usage data and summary stats. @@ -569,12 +517,15 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: ] ) + context["active_nav"] = "reports" return context -class VoucherUsageExportView(ReportPermissionMixin, View): +class VoucherUsageExportView(ConferencePermissionMixin, View): """CSV export of voucher usage data.""" + required_permission = "export_reports" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Return a CSV download of voucher usage data. @@ -623,10 +574,11 @@ def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG return response -class DiscountEffectivenessView(ReportPermissionMixin, TemplateView): +class DiscountEffectivenessView(ConferencePermissionMixin, TemplateView): """Discount conditions overview and effectiveness report.""" template_name = "django_program/manage/report_discount_effectiveness.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with discount conditions and summary stats. @@ -654,12 +606,15 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: } ) + context["active_nav"] = "reports" return context -class DiscountEffectivenessExportView(ReportPermissionMixin, View): +class DiscountEffectivenessExportView(ConferencePermissionMixin, View): """CSV export of discount effectiveness data.""" + required_permission = "export_reports" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Return a CSV download of discount conditions data. @@ -724,10 +679,11 @@ def _parse_date_param(value: str | None) -> datetime.date | None: return None -class SalesByDateView(ReportPermissionMixin, TemplateView): +class SalesByDateView(ConferencePermissionMixin, TemplateView): """Daily sales aggregation report with date filtering.""" template_name = "django_program/manage/report_sales_by_date.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with daily sales data and summary totals. @@ -761,12 +717,15 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: ] ) + context["active_nav"] = "reports" return context -class SalesByDateExportView(ReportPermissionMixin, View): +class SalesByDateExportView(ConferencePermissionMixin, View): """CSV export of daily sales data.""" + required_permission = "export_reports" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Return a CSV download of daily sales. @@ -798,10 +757,11 @@ def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG return response -class CreditNotesView(ReportPermissionMixin, TemplateView): +class CreditNotesView(ConferencePermissionMixin, TemplateView): """Credit notes listing with summary statistics.""" template_name = "django_program/manage/report_credit_notes.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with credit records and summary stats. @@ -827,12 +787,15 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: } ) + context["active_nav"] = "reports" return context -class CreditNotesExportView(ReportPermissionMixin, View): +class CreditNotesExportView(ConferencePermissionMixin, View): """CSV export of credit notes.""" + required_permission = "export_reports" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Return a CSV download of credit notes. @@ -878,10 +841,11 @@ def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG return response -class SpeakerRegistrationView(ReportPermissionMixin, TemplateView): +class SpeakerRegistrationView(ConferencePermissionMixin, TemplateView): """Speaker registration status report.""" template_name = "django_program/manage/report_speaker_registration.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with speaker registration data. @@ -909,12 +873,15 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: } ) + context["active_nav"] = "reports" return context -class SpeakerRegistrationExportView(ReportPermissionMixin, View): +class SpeakerRegistrationExportView(ConferencePermissionMixin, View): """CSV export of speaker registration data.""" + required_permission = "export_reports" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Return a CSV download of speaker registration status. @@ -943,10 +910,11 @@ def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG return response -class ReconciliationView(ReportPermissionMixin, TemplateView): +class ReconciliationView(ConferencePermissionMixin, TemplateView): """Financial reconciliation report with stat cards and detail tables.""" template_name = "django_program/manage/report_reconciliation.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with reconciliation data. @@ -982,12 +950,15 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: ] ) + context["active_nav"] = "reports" return context -class ReconciliationExportView(ReportPermissionMixin, View): +class ReconciliationExportView(ConferencePermissionMixin, View): """CSV export of financial reconciliation data.""" + required_permission = "export_reports" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Return a CSV download of reconciliation data. @@ -1022,10 +993,11 @@ def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG return response -class RegistrationFlowView(ReportPermissionMixin, TemplateView): +class RegistrationFlowView(ConferencePermissionMixin, TemplateView): """Daily registrations and cancellations flow report.""" template_name = "django_program/manage/report_registration_flow.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with daily registration flow data. @@ -1061,12 +1033,15 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: ] ) + context["active_nav"] = "reports" return context -class RegistrationFlowExportView(ReportPermissionMixin, View): +class RegistrationFlowExportView(ConferencePermissionMixin, View): """CSV export of registration flow data.""" + required_permission = "export_reports" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Return a CSV download of daily registration flow. @@ -1100,10 +1075,11 @@ def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG return response -class VisaLetterReportView(ReportPermissionMixin, TemplateView): +class VisaLetterReportView(ConferencePermissionMixin, TemplateView): """Visa invitation letter requests report with status breakdown.""" template_name = "django_program/manage/report_visa_letters.html" + required_permission = "view_reports" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build context with letter request data and chart data. @@ -1135,12 +1111,15 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: } ) + context["active_nav"] = "reports" return context -class VisaLetterExportView(ReportPermissionMixin, View): +class VisaLetterExportView(ConferencePermissionMixin, View): """CSV export of visa invitation letter requests.""" + required_permission = "export_reports" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Return a CSV download of all letter requests. diff --git a/src/django_program/manage/views_terminal.py b/src/django_program/manage/views_terminal.py index 151b289..980bde3 100644 --- a/src/django_program/manage/views_terminal.py +++ b/src/django_program/manage/views_terminal.py @@ -18,6 +18,7 @@ class TerminalPOSView(ManagePermissionMixin, TemplateView): """ template_name = "django_program/manage/terminal_pos.html" + required_permission = "use_terminal" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Add active navigation state to the template context. diff --git a/src/django_program/manage/views_vouchers.py b/src/django_program/manage/views_vouchers.py index de125ec..b08f01c 100644 --- a/src/django_program/manage/views_vouchers.py +++ b/src/django_program/manage/views_vouchers.py @@ -28,6 +28,7 @@ class VoucherBulkGenerateView(ManagePermissionMixin, FormView): """ template_name = "django_program/manage/voucher_bulk_generate.html" + required_permission = "change_commerce" form_class = VoucherBulkGenerateForm def get_context_data(self, **kwargs: object) -> dict[str, object]: 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..1b09586 --- /dev/null +++ b/src/django_program/programs/migrations/0011_alter_travelgrant_options.py @@ -0,0 +1,23 @@ +# 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"), + ], + }, + ), + ] diff --git a/src/django_program/programs/models.py b/src/django_program/programs/models.py index 99b7b79..b20358a 100644 --- a/src/django_program/programs/models.py +++ b/src/django_program/programs/models.py @@ -459,6 +459,8 @@ class Meta: unique_together = [("conference", "user")] 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"), ] def __str__(self) -> str: diff --git a/src/django_program/registration/views_checkin.py b/src/django_program/registration/views_checkin.py index e2ce30f..3354056 100644 --- a/src/django_program/registration/views_checkin.py +++ b/src/django_program/registration/views_checkin.py @@ -43,7 +43,12 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo """ if not request.user.is_authenticated: return JsonResponse({"error": "Authentication required"}, status=401) - if not (request.user.is_superuser or request.user.has_perm("program_conference.change_conference")): + if not ( + request.user.is_superuser + or request.user.has_perm("program_conference.change_conference") + or request.user.has_perm("program_conference.view_checkin") + or request.user.has_perm("program_conference.use_terminal") + ): return JsonResponse({"error": "Staff access required"}, status=403) self.conference = get_object_or_404(Conference, slug=kwargs.get("conference_slug")) return super().dispatch(request, *args, **kwargs) # type: ignore[misc] 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_manage/test_views.py b/tests/test_manage/test_views.py index feb6501..fe21496 100644 --- a/tests/test_manage/test_views.py +++ b/tests/test_manage/test_views.py @@ -71,7 +71,11 @@ def superuser(db): @pytest.fixture def staff_user(db): - return User.objects.create_user(username="staff", password="password", email="staff@test.com", is_staff=True) + user = User.objects.create_user(username="staff", password="password", email="staff@test.com", is_staff=True) + ct = ContentType.objects.get(app_label="program_conference", model="conference") + perm = Permission.objects.get(content_type=ct, codename="manage_conference_settings") + user.user_permissions.add(perm) + return user @pytest.fixture diff --git a/tests/test_registration/test_bootstrap_registration.py b/tests/test_registration/test_bootstrap_registration.py index 738cfba..aacc8a4 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,30 @@ 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 + expected_names = { + "Conference Organizer", + "Program Committee", + "Registration Manager", + "Finance Team", + "Travel Grant Reviewer", + "Sponsor Manager", + "Check-in Staff", + "Activity Organizer", + "Reports Viewer", + "Read-Only Staff", + } + assert Group.objects.filter(name__in=expected_names).count() == 10 # ---------------------------------------------------------------