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
# ---------------------------------------------------------------