From fbdf2c44274c9200921fcb39fc5e2848a4f0d868 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Nov 2026 17:46:20 -0600 Subject: [PATCH 01/15] feat(manage)!: configurable management shell - nav registry (django_program.manage.nav) drives the sidebar and a new Cmd/Ctrl-K palette; host projects add sections via DJANGO_PROGRAM['manage']['nav'], trim built-ins with builtin_nav / hide_nav - manage views enforce module feature toggles and manage_ui (404 when off) - pluggable attention providers with sidebar badges and a dashboard panel - single-conference mode, configurable title/home/logout, extra assets - shell CSS/JS extracted to static files; base.html works on host pages without a conference; new focus.html for kiosk pages BREAKING CHANGE: template tag libraries renamed to program_registration_tags, program_stripe_tags and program_sponsor_tags to avoid clashing with host apps. --- docs/configuration.md | 21 + docs/customizing-manage.md | 177 ++ docs/index.rst | 1 + src/django_program/manage/attention.py | 85 + .../manage/attention_builtin.py | 95 ++ src/django_program/manage/nav.py | 470 ++++++ src/django_program/manage/nav_builtin.py | 425 +++++ .../static/django_program/manage/manage.css | 1216 +++++++++++++ .../static/django_program/manage/manage.js | 294 ++++ .../django_program/manage/_icons.html | 35 + .../templates/django_program/manage/base.html | 1500 ++--------------- .../django_program/manage/dashboard.html | 38 + .../django_program/manage/focus.html | 11 + .../manage/templatetags/__init__.py | 1 + .../manage/templatetags/manage_shell.py | 158 ++ src/django_program/manage/views.py | 142 +- .../manage/views_bulk_purchases.py | 5 + src/django_program/manage/views_checkin.py | 2 + src/django_program/manage/views_financial.py | 1 + src/django_program/manage/views_letters.py | 6 + src/django_program/manage/views_overrides.py | 15 + .../manage/views_purchase_orders.py | 18 + src/django_program/manage/views_terminal.py | 1 + src/django_program/manage/views_vouchers.py | 1 + ...n_tags.py => program_registration_tags.py} | 6 +- ...{stripe_tags.py => program_stripe_tags.py} | 2 +- src/django_program/settings.py | 82 + ...ponsor_tags.py => program_sponsor_tags.py} | 4 +- tests/settings.py | 1 + .../test_example_app_integration.py | 2 +- tests/test_manage/test_attention.py | 189 +++ tests/test_manage/test_feature_gates.py | 301 ++++ tests/test_manage/test_financial_views.py | 9 +- tests/test_manage/test_nav.py | 309 ++++ tests/test_manage/test_views.py | 9 +- ...s.py => test_program_registration_tags.py} | 16 +- .../test_stripe_models_admin_forms.py | 4 +- tests/test_settings_config.py | 32 + ...r_tags.py => test_program_sponsor_tags.py} | 6 +- 39 files changed, 4244 insertions(+), 1446 deletions(-) create mode 100644 docs/customizing-manage.md create mode 100644 src/django_program/manage/attention.py create mode 100644 src/django_program/manage/attention_builtin.py create mode 100644 src/django_program/manage/nav.py create mode 100644 src/django_program/manage/nav_builtin.py create mode 100644 src/django_program/manage/static/django_program/manage/manage.css create mode 100644 src/django_program/manage/static/django_program/manage/manage.js create mode 100644 src/django_program/manage/templates/django_program/manage/_icons.html create mode 100644 src/django_program/manage/templates/django_program/manage/focus.html create mode 100644 src/django_program/manage/templatetags/__init__.py create mode 100644 src/django_program/manage/templatetags/manage_shell.py rename src/django_program/registration/templatetags/{registration_tags.py => program_registration_tags.py} (96%) rename src/django_program/registration/templatetags/{stripe_tags.py => program_stripe_tags.py} (95%) rename src/django_program/sponsors/templatetags/{sponsor_tags.py => program_sponsor_tags.py} (97%) create mode 100644 tests/test_manage/test_attention.py create mode 100644 tests/test_manage/test_feature_gates.py create mode 100644 tests/test_manage/test_nav.py rename tests/test_registration/{test_registration_tags.py => test_program_registration_tags.py} (91%) rename tests/test_sponsors/{test_sponsor_tags.py => test_program_sponsor_tags.py} (96%) diff --git a/docs/configuration.md b/docs/configuration.md index f288109..3916f71 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,6 +45,15 @@ DJANGO_PROGRAM = { "manage_ui_enabled": True, "all_ui_enabled": True, }, + # Management UI shell (see "Customizing the Management UI") + "manage": { + "title": "Conference Management", # default + "conference": "", # slug for single-conference sites + "builtin_nav": True, # True, False, or a list of keys to keep + "hide_nav": [], # section or item keys to drop + "nav": [], # dotted paths to nav hooks + "attention": ["django_program.manage.attention_builtin.builtin_attention"], + }, # General "cart_expiry_minutes": 30, # default "pending_order_expiry_minutes": 15, # default @@ -289,6 +298,18 @@ switch logic and per-conference DB overrides are applied. When the request has a `conference` attribute (set by middleware or the view), that conference's `FeatureFlags` row is consulted automatically. +Manage views enforce the module toggles too: with `travel_grants_enabled` off, every +travel grant URL under the management dashboard returns 404 and its sidebar entries +disappear. Turning off `manage_ui_enabled` (or `all_ui_enabled`) returns 404 for the +whole management dashboard. + +### Management UI settings + +The `manage` block configures the management dashboard shell: title, home link, +single-conference mode, which built-in sidebar entries to show, host-provided +sidebar sections, "needs attention" counts, and extra assets. See +[Customizing the Management UI](customizing-manage.md) for the full reference. + ### Accessing config in code ```python diff --git a/docs/customizing-manage.md b/docs/customizing-manage.md new file mode 100644 index 0000000..65070d8 --- /dev/null +++ b/docs/customizing-manage.md @@ -0,0 +1,177 @@ +# Customizing the Management UI + +The management dashboard (`django_program.manage`) is a shell (top bar, sidebar, +Cmd/Ctrl-K jump palette, "needs attention" panel) plus a set of built-in modules. +Both halves are configurable, so a host project can: + +- adopt django-program one module at a time and hide the rest, +- render its own staff pages inside the same shell, +- add its own sidebar sections, badges, and attention counts. + +All options live under `DJANGO_PROGRAM["manage"]`. + +## Settings reference + +| Key | Default | Purpose | +|---|---|---| +| `title` | `"Conference Management"` | Top-bar title. | +| `home_url` | `""` | URL name or path for the title link. Empty: the conference list, or the single conference's dashboard when `conference` is set. | +| `conference` | `""` | Conference slug for single-conference sites. The conference list redirects to its dashboard, and pages without a `conference` in context still show the conference's nav. | +| `logout_url` | `"admin:logout"` | URL name or path for "Log out". | +| `public_schedule_link` | `True` | Show "View Public Schedule" when a conference is in scope. | +| `builtin_nav` | `True` | `True` keeps every built-in sidebar entry, `False` drops them all, a list keeps only those section or item keys. | +| `hide_nav` | `[]` | Section or item keys to remove, applied after host hooks. | +| `nav` | `[]` | Dotted paths to `register(registry)` callables that add sections and items. | +| `attention` | `["django_program.manage.attention_builtin.builtin_attention"]` | Dotted paths to attention providers. | +| `stylesheets` | `[]` | Extra static stylesheets loaded after `django_program/manage/manage.css`. | +| `scripts` | `[]` | Extra static scripts loaded after `django_program/manage/manage.js`. | +| `palette` | `True` | Enable the Cmd/Ctrl-K jump palette. | + +Module toggles in `DJANGO_PROGRAM["features"]` apply to the manage UI as well: a +disabled module's sidebar entries disappear and its manage URLs return 404. + +## Built-in sidebar keys + +Sections: `conference`, `program`, `registration`, `sponsors`, `onsite`, `finance`, +`sync-overrides`. + +Items: `dashboard`, `conference-edit`, `sections`, `rooms`, `speakers`, `talks`, +`schedule`, `activities`, `travel-grants`, `attendees`, `orders`, `letters`, `badges`, +`ticket-types`, `addons`, `vouchers`, `conditions`, `bulk-purchases`, +`sponsor-levels`, `sponsor-list`, `checkin`, `terminal`, `financial`, `purchase-orders`, +`expenses`, `analytics`, `reports`, `overrides`. Talk-type and override sub-tabs are +dynamic children of `talks` and `overrides` (`overrides-speakers`, `overrides-rooms`, +`overrides-sponsors`, `overrides-type-defaults`). + +```python +DJANGO_PROGRAM = { + "manage": { + # Keep the conference dashboard and the whole Program section only + "builtin_nav": ["dashboard", "program"], + # ...but not rooms + "hide_nav": ["rooms"], + }, +} +``` + +## Adding sidebar sections + +A nav hook receives the {class}`~django_program.manage.nav.NavRegistry`: + +```python +# myproject/staff/nav.py +from django_program.manage.nav import NavItem + + +def register(registry): + registry.section("desk", "Registration desk", order=5) + registry.add( + "desk", + NavItem( + key="desk-check-in", + label="Check in attendees", + url="registration:check_in", # URL name or literal path + icon="check-square", + permission="registration.check_in", # str, list (any of), or callable(user) + group="At the desk", # optional sub-heading + description="scan badge qr", # extra palette search text + ), + NavItem( + key="desk-sync", + label="Sync Pretalx now", + url="schedule:trigger_sync_pretalx", + method="post", # renders a CSRF form button + permission=lambda user: user.is_staff, + ), + ) +``` + +```python +DJANGO_PROGRAM = {"manage": {"nav": ["myproject.staff.nav.register"]}} +``` + +`NavItem` fields: + +| Field | Meaning | +|---|---| +| `key` | Unique across all sections; also the default `active_nav` value. | +| `label` | Text, or a callable receiving the `NavContext`. | +| `url` | URL name (reversed) or literal path/URL (contains `/`). | +| `conference_url` | Reverse with `conference_slug`; hidden when no conference is in scope. | +| `icon` | Sprite name (`dp-i-` in `django_program/manage/_icons.html`) or raw `` markup. | +| `permission` | `None`, a permission string, a list (any of), or `callable(user) -> bool`. | +| `feature` | django-program feature name; hidden when that module is off. | +| `active` | `active_nav` values that mark the item current. | +| `group` | Sub-heading inside the section. | +| `method` | `"get"` (link) or `"post"` (CSRF form button). | +| `badge` | Attention item key whose count shows next to the label. | +| `children` | Callable returning dynamic child items. | +| `order` | Sort order inside the section. | + +Items whose URL name does not reverse are skipped, so unmounted modules never +produce broken links. When no item matches the page's `active_nav`, the item whose +URL is the longest prefix of the request path is highlighted, so host sub-pages +keep their section open. + +Use `django_program.manage.nav.manage_perm("view_registration")` to reuse the +built-in permission rule (superuser, `program_conference.change_conference`, or +the listed permission). + +## Attention counts + +Providers return {class}`~django_program.manage.attention.AttentionItem` objects: + +```python +# myproject/staff/attention.py +from django.urls import reverse + +from django_program.manage.attention import AttentionItem + + +def pending_receipts(request, conference): + if not request.user.has_perm("travel_grants.can_review_receipts"): + return [] + count = Receipt.objects.filter(approved=False, flagged=False).count() + return [AttentionItem(key="receipts", label="Receipts to review", count=count, url=reverse("receipts_review"))] +``` + +```python +DJANGO_PROGRAM = { + "manage": { + "attention": [ + "django_program.manage.attention_builtin.builtin_attention", + "myproject.staff.attention.pending_receipts", + ], + }, +} +``` + +Items show on the conference dashboard; a nav item with `badge="receipts"` shows +the count in the sidebar. Providers run at most once per request. + +## Rendering host pages in the shell + +Any template can extend the shell; no django-program view mixin is needed: + +```django +{% extends "django_program/manage/base.html" %} + +{% block title %}Generate vouchers{% endblock %} +{% block page_title %}

Generate vouchers

{% endblock %} +{% block content %} + ... +{% endblock %} +``` + +Blocks: `title`, `extra_head`, `body_class`, `main_class`, `sidebar`, +`topbar_actions`, `breadcrumb`, `messages`, `page_header`, `page_title`, +`page_actions`, `content`, `extra_js`. + +For kiosk-style pages without a sidebar, extend +`django_program/manage/focus.html`. + +Set `active_nav` in the context to pin the highlighted item; otherwise the +path-prefix match applies. The shell's data comes from +`{% load manage_shell %}{% manage_shell as shell %}`, which host templates can use +directly to build their own home pages from the same nav (`shell.nav`, +`shell.palette_items`). diff --git a/docs/index.rst b/docs/index.rst index ee8c946..f04f5c3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -131,6 +131,7 @@ Quick Start badge-generation programs-activities management-dashboard + customizing-manage admin-reports .. toctree:: diff --git a/src/django_program/manage/attention.py b/src/django_program/manage/attention.py new file mode 100644 index 0000000..119b198 --- /dev/null +++ b/src/django_program/manage/attention.py @@ -0,0 +1,85 @@ +"""Pluggable "needs attention" counts for the management dashboard. + +Providers are callables ``(request, conference) -> Iterable[AttentionItem]`` +listed in ``DJANGO_PROGRAM["manage"]["attention"]``. Their items appear on +the dashboard and as sidebar badges for nav items whose ``badge`` matches the +item ``key``. Results are computed at most once per request. +""" + +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +from django.core.exceptions import ImproperlyConfigured +from django.utils.module_loading import import_string + +from django_program.settings import get_config + +if TYPE_CHECKING: + from django.http import HttpRequest + + from django_program.conference.models import Conference + +Tone = Literal["info", "success", "warning", "danger"] +AttentionProvider = Callable[["HttpRequest", "Conference | None"], Iterable["AttentionItem"]] + +_REQUEST_CACHE_ATTR = "_django_program_attention" + + +@dataclass(frozen=True, slots=True) +class AttentionItem: + """One count that may need staff action. + + Attributes: + key: Unique key; nav items with ``badge=key`` show ``count``. + label: Short description, e.g. "Receipts to review". + count: Current count. + url: Where to act on it (already resolved). + tone: Visual emphasis. + description: Optional supporting sentence. + """ + + key: str + label: str + count: int + url: str = "" + tone: Tone = "warning" + description: str = "" + + +def _load_providers() -> list[AttentionProvider]: + providers: list[AttentionProvider] = [] + for path in get_config().manage.attention: + try: + provider = import_string(path) + except ImportError as exc: + msg = f"DJANGO_PROGRAM['manage']['attention'] entry {path!r} could not be imported: {exc}" + raise ImproperlyConfigured(msg) from exc + if not callable(provider): + msg = f"DJANGO_PROGRAM['manage']['attention'] entry {path!r} is not callable" + raise ImproperlyConfigured(msg) + providers.append(provider) + return providers + + +def get_attention(request: HttpRequest, conference: Conference | None) -> list[AttentionItem]: + """Return attention items from every configured provider, cached on the request. + + Args: + request: The current request. + conference: The conference in scope, if any. + + Returns: + Items in provider order. + """ + cached = getattr(request, _REQUEST_CACHE_ATTR, None) + if cached is not None: + return cached + items = [item for provider in _load_providers() for item in provider(request, conference)] + setattr(request, _REQUEST_CACHE_ATTR, items) + return items + + +def attention_counts(request: HttpRequest, conference: Conference | None) -> dict[str, int]: + """Return ``{item.key: item.count}`` for sidebar badges.""" + return {item.key: item.count for item in get_attention(request, conference)} diff --git a/src/django_program/manage/attention_builtin.py b/src/django_program/manage/attention_builtin.py new file mode 100644 index 0000000..4b7b7aa --- /dev/null +++ b/src/django_program/manage/attention_builtin.py @@ -0,0 +1,95 @@ +"""Built-in "needs attention" provider for the management dashboard. + +Registered by default via ``ManageConfig.attention``. Each check is a cheap +``COUNT`` query guarded by the relevant feature flag and the same +permission the corresponding manage list view requires, so disabled +modules and unauthorized users never pay for a query. +""" + +from typing import TYPE_CHECKING + +from django.urls import reverse + +from django_program.features import is_feature_enabled +from django_program.manage.attention import AttentionItem +from django_program.manage.nav import manage_perm +from django_program.programs.models import Receipt, TravelGrant +from django_program.registration.models import Order + +if TYPE_CHECKING: + from collections.abc import Iterable + + from django.http import HttpRequest + + from django_program.conference.models import Conference + +_can_review_grants = manage_perm("program_programs.view_travel_grant") +_can_review_receipts = manage_perm("program_programs.review_receipt") +_can_view_orders = manage_perm("program_conference.view_registration") + + +def builtin_attention(request: HttpRequest, conference: Conference | None) -> Iterable[AttentionItem]: + """Yield built-in attention items for the current conference. + + Args: + request: The current request. + conference: The conference in scope. No items are produced outside + a conference-scoped page. + + Yields: + AttentionItem: Counts of travel grants, receipts, and orders + awaiting staff action, each only when the backing feature is + enabled, the user has the matching manage permission, and the + count is non-zero. + """ + if conference is None: + return + + user = request.user + + if is_feature_enabled("travel_grants", conference=conference): + if _can_review_grants(user): + pending_grants = TravelGrant.objects.filter( + conference=conference, + status=TravelGrant.GrantStatus.SUBMITTED, + ).count() + if pending_grants: + yield AttentionItem( + key="travel-grants-pending", + label="Travel grant applications awaiting review", + count=pending_grants, + url=( + reverse("manage:travel-grant-list", kwargs={"conference_slug": conference.slug}) + + f"?status={TravelGrant.GrantStatus.SUBMITTED}" + ), + tone="warning", + ) + + if _can_review_receipts(user): + pending_receipts = Receipt.objects.filter( + grant__conference=conference, + approved=False, + flagged=False, + ).count() + if pending_receipts: + yield AttentionItem( + key="receipts-pending", + label="Receipts awaiting review", + count=pending_receipts, + url=reverse("manage:receipt-review-queue", kwargs={"conference_slug": conference.slug}), + tone="warning", + ) + + if is_feature_enabled("registration", conference=conference) and _can_view_orders(user): + pending_orders = Order.objects.filter(conference=conference, status=Order.Status.PENDING).count() + if pending_orders: + yield AttentionItem( + key="orders-pending", + label="Pending orders", + count=pending_orders, + url=( + reverse("manage:order-list", kwargs={"conference_slug": conference.slug}) + + f"?status={Order.Status.PENDING}" + ), + tone="info", + ) diff --git a/src/django_program/manage/nav.py b/src/django_program/manage/nav.py new file mode 100644 index 0000000..10def1e --- /dev/null +++ b/src/django_program/manage/nav.py @@ -0,0 +1,470 @@ +"""Configurable navigation for the management dashboard. + +The sidebar and the Cmd/Ctrl-K palette are built from a :class:`NavRegistry` +instead of hard-coded template markup. django-program registers its own +sections (see :mod:`django_program.manage.nav_builtin`); host projects add +their own sections and items, or trim the built-in ones, through +``DJANGO_PROGRAM["manage"]``:: + + DJANGO_PROGRAM = { + "manage": { + "builtin_nav": ["conference"], # keep only these built-in keys + "hide_nav": ["conference-edit"], # then drop these keys + "nav": ["myproject.staff.nav.register"], + }, + } + + # myproject/staff/nav.py + from django_program.manage.nav import NavItem + + + def register(registry): + registry.section("desk", "Registration desk", order=10) + registry.add( + "desk", + NavItem( + key="desk-check-in", + label="Check in attendees", + url="registration:check_in", + icon="check-square", + permission="registration.check_in", + ), + ) + +Item URLs are URL names (reversed, with ``conference_slug`` when +``conference_url=True``) or literal paths/URLs (anything containing ``/``). +""" + +import functools +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Literal + +from django.core.exceptions import ImproperlyConfigured +from django.test.signals import setting_changed +from django.urls import NoReverseMatch, reverse +from django.utils.module_loading import import_string + +from django_program.features import is_feature_enabled +from django_program.settings import get_config + +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser, AnonymousUser + from django.http import HttpRequest + + from django_program.conference.models import Conference + + User = AbstractUser | AnonymousUser + +PermissionCheck = str | Sequence[str] | Callable[["User"], bool] | None +"""A permission string, any-of sequence of permission strings, a ``user -> bool`` +callable, or ``None`` (visible to anyone who can see the shell).""" + +Label = str | Callable[["NavContext"], str] + + +@dataclass(frozen=True, slots=True) +class NavContext: + """Per-request inputs used to resolve visibility, URLs and labels.""" + + request: HttpRequest + conference: Conference | None + active_nav: str = "" + + @property + def user(self) -> User: + """Return the requesting user.""" + return self.request.user + + +@dataclass(frozen=True, slots=True) +class NavItem: + """One link (or POST action) in the sidebar and palette. + + Attributes: + key: Globally unique key, also the default ``active_nav`` value. + label: Link text, or a callable receiving the :class:`NavContext`. + url: URL name or literal path/URL. + icon: Icon name from the manage sprite (``dp-i-``) or raw ```` markup. + permission: Visibility check, see :data:`PermissionCheck`. + feature: django-program feature name; hidden when the feature is off. + conference_url: Reverse ``url`` with ``conference_slug``; hidden without a conference. + active: ``active_nav`` values that mark this item current (defaults to ``key``). + group: Optional sub-heading inside the section. + method: ``"post"`` renders a CSRF form button instead of a link. + description: Extra palette search text. + badge: Attention item key whose count is shown next to the label. + children: Callable returning dynamic child items (e.g. talk types). + order: Sort order inside the section; ties keep registration order. + """ + + key: str + label: Label + url: str + icon: str = "" + permission: PermissionCheck = None + feature: str = "" + conference_url: bool = False + active: tuple[str, ...] = () + group: str = "" + method: Literal["get", "post"] = "get" + description: str = "" + badge: str = "" + children: Callable[[NavContext], Iterable[NavItem]] | None = None + order: int = 0 + + +@dataclass(frozen=True, slots=True) +class NavSection: + """A titled, collapsible group of nav items. + + Attributes: + key: Unique section key. + label: Section title, or a callable receiving the :class:`NavContext`. + order: Sort order among sections; ties keep registration order. + permission: Extra visibility check for the whole section. + feature: django-program feature name; hidden when the feature is off. + utility: Render below a separator at the end of the sidebar. + """ + + key: str + label: Label + order: int = 0 + permission: PermissionCheck = None + feature: str = "" + utility: bool = False + + +@dataclass(slots=True) +class _SectionEntry: + section: NavSection + items: list[NavItem] = field(default_factory=list) + + +class NavRegistry: + """Mutable registry of sections and items, built once per configuration.""" + + def __init__(self) -> None: + """Create an empty registry.""" + self._sections: dict[str, _SectionEntry] = {} + self._item_sections: dict[str, str] = {} + + def section(self, key: str, label: Label, **options: object) -> NavSection: + """Add a section, or update an existing section's label and options. + + Args: + key: Unique section key. + label: Section title. + **options: Other :class:`NavSection` fields. + + Returns: + The stored section. + """ + entry = self._sections.get(key) + if entry is None: + section = NavSection(key=key, label=label, **options) # type: ignore[arg-type] + self._sections[key] = _SectionEntry(section) + return section + entry.section = replace(entry.section, label=label, **options) + return entry.section + + def add(self, section_key: str, *items: NavItem) -> None: + """Append items to a section. + + Raises: + ImproperlyConfigured: If the section does not exist or a key is taken. + """ + entry = self._sections.get(section_key) + if entry is None: + msg = f"Unknown manage nav section {section_key!r}; call registry.section() first" + raise ImproperlyConfigured(msg) + for item in items: + if item.key in self._item_sections or item.key in self._sections: + msg = f"Duplicate manage nav key {item.key!r}" + raise ImproperlyConfigured(msg) + self._item_sections[item.key] = section_key + entry.items.append(item) + + def remove(self, key: str) -> None: + """Remove a section (with its items) or a single item by key. Unknown keys are ignored.""" + entry = self._sections.pop(key, None) + if entry is not None: + for item in entry.items: + self._item_sections.pop(item.key, None) + return + section_key = self._item_sections.pop(key, None) + if section_key is not None: + items = self._sections[section_key].items + items[:] = [item for item in items if item.key != key] + + def keep(self, keys: Iterable[str]) -> None: + """Keep only the given section or item keys; everything else is removed.""" + wanted = set(keys) + for section_key in list(self._sections): + if section_key in wanted: + continue + entry = self._sections[section_key] + kept = [item for item in entry.items if item.key in wanted] + if not kept: + self.remove(section_key) + continue + for item in entry.items: + if item.key not in wanted: + self._item_sections.pop(item.key, None) + entry.items[:] = kept + + def __contains__(self, key: object) -> bool: + """Return whether a section or item key is registered.""" + return key in self._sections or key in self._item_sections + + def entries(self) -> list[tuple[NavSection, list[NavItem]]]: + """Return sections and their items in display order.""" + ordered = sorted( + enumerate(self._sections.values()), + key=lambda pair: (pair[1].section.utility, pair[1].section.order, pair[0]), + ) + return [ + (entry.section, [item for _, item in sorted(enumerate(entry.items), key=lambda p: (p[1].order, p[0]))]) + for _, entry in ordered + ] + + +@functools.lru_cache(maxsize=1) +def get_registry() -> NavRegistry: + """Build the registry from built-ins plus ``DJANGO_PROGRAM["manage"]`` hooks. + + Order: built-in nav, ``builtin_nav`` filter, host ``nav`` callables, ``hide_nav``. + + Raises: + ImproperlyConfigured: If a ``nav`` entry cannot be imported or is not callable. + """ + from django_program.manage.nav_builtin import register_builtin_nav # noqa: PLC0415 + + config = get_config().manage + registry = NavRegistry() + if config.builtin_nav is not False: + register_builtin_nav(registry) + if config.builtin_nav is not True: + registry.keep(config.builtin_nav) + for path in config.nav: + try: + hook = import_string(path) + except ImportError as exc: + msg = f"DJANGO_PROGRAM['manage']['nav'] entry {path!r} could not be imported: {exc}" + raise ImproperlyConfigured(msg) from exc + if not callable(hook): + msg = f"DJANGO_PROGRAM['manage']['nav'] entry {path!r} is not callable" + raise ImproperlyConfigured(msg) + hook(registry) + for key in config.hide_nav: + registry.remove(key) + return registry + + +def _clear_registry_cache(*, setting: str, **kwargs: object) -> None: # noqa: ARG001 + if setting == "DJANGO_PROGRAM": + get_registry.cache_clear() + + +setting_changed.connect(_clear_registry_cache, dispatch_uid="django_program.manage.nav.clear_registry") + + +def has_full_manage_access(user: User) -> bool: + """Return whether ``user`` bypasses built-in manage permission checks.""" + return bool(user.is_superuser or user.has_perm("program_conference.change_conference")) + + +def manage_perm(*perms: str) -> Callable[[User], bool]: + """Build a check passing for full manage access or any of ``perms``. + + Bare codenames default to the ``program_conference`` app label, matching + :class:`~django_program.manage.views.ConferencePermissionMixin`. + """ + qualified = tuple(perm if "." in perm else f"program_conference.{perm}" for perm in perms) + + def check(user: User) -> bool: + return has_full_manage_access(user) or any(user.has_perm(perm) for perm in qualified) + + return check + + +def check_permission(user: User, permission: PermissionCheck) -> bool: + """Evaluate a :data:`PermissionCheck` for ``user``.""" + if permission is None: + return True + if isinstance(permission, str): + return bool(user.has_perm(permission)) + if callable(permission): + return bool(permission(user)) + return any(user.has_perm(perm) for perm in permission) + + +@dataclass(frozen=True, slots=True) +class ResolvedItem: + """A nav item resolved for one request.""" + + key: str + label: str + href: str + icon: str + group: str + method: str + description: str + section_label: str + is_active: bool = False + badge_count: int | None = None + children: tuple[ResolvedItem, ...] = () + + +@dataclass(frozen=True, slots=True) +class ResolvedSection: + """A visible section with its visible items.""" + + key: str + label: str + utility: bool + items: tuple[ResolvedItem, ...] + + @property + def is_active(self) -> bool: + """Return whether the current page belongs to this section.""" + return any(item.is_active or any(child.is_active for child in item.children) for item in self.items) + + +def _label(label: Label, ctx: NavContext) -> str: + return str(label(ctx) if callable(label) else label) + + +def _visible(entry: NavItem | NavSection, ctx: NavContext) -> bool: + if entry.feature and not is_feature_enabled(entry.feature, conference=ctx.conference): + return False + return check_permission(ctx.user, entry.permission) + + +def _href(item: NavItem, ctx: NavContext) -> str | None: + if "/" in item.url: + return item.url + kwargs = {} + if item.conference_url: + if ctx.conference is None: + return None + kwargs["conference_slug"] = ctx.conference.slug + try: + return reverse(item.url, kwargs=kwargs or None) + except NoReverseMatch: + return None + + +def _resolve_item(item: NavItem, ctx: NavContext, section_label: str, badges: dict[str, int]) -> ResolvedItem | None: + if not _visible(item, ctx): + return None + href = _href(item, ctx) + if href is None: + return None + children = tuple( + child + for child in ( + _resolve_item(raw, ctx, section_label, badges) for raw in (item.children(ctx) if item.children else ()) + ) + if child is not None + ) + return ResolvedItem( + key=item.key, + label=_label(item.label, ctx), + href=href, + icon=item.icon, + group=item.group, + method=item.method, + description=item.description, + section_label=section_label, + is_active=bool(ctx.active_nav) and ctx.active_nav in (item.active or (item.key,)), + badge_count=badges.get(item.badge) if item.badge else None, + children=children, + ) + + +def _prefix_length(item: ResolvedItem, path: str) -> int: + """Return ``len(item.href)`` when it is a GET path prefix of ``path``, else ``-1``.""" + if item.method == "get" and item.href.startswith("/") and path.startswith(item.href): + return len(item.href) + return -1 + + +def _mark(item: ResolvedItem, key: str) -> ResolvedItem: + children = tuple(_mark(child, key) for child in item.children) + return replace(item, is_active=item.key == key, children=children) + + +def _mark_path_active(sections: list[ResolvedSection], path: str) -> list[ResolvedSection]: + """Mark the item whose ``href`` is the longest prefix of ``path`` as active.""" + best_length, best_key = -1, "" + for section in sections: + for item in section.items: + for candidate in (item, *item.children): + length = _prefix_length(candidate, path) + if length > best_length: + best_length, best_key = length, candidate.key + if best_length < 0: + return sections + return [replace(section, items=tuple(_mark(item, best_key) for item in section.items)) for section in sections] + + +def _disambiguate_family(item: ResolvedItem, path: str) -> ResolvedItem: + """Keep one active entry when a parent and its children share an ``active_nav`` value. + + The longest matching URL prefix wins; without a match the parent stays active. + """ + family = (item, *item.children) + active = [entry for entry in family if entry.is_active] + if len(active) <= 1: + return item + best = max(active, key=lambda entry: _prefix_length(entry, path)) + if _prefix_length(best, path) < 0: + best = item + return _mark(item, best.key) + + +def build_nav( + request: HttpRequest, + *, + conference: Conference | None = None, + active_nav: str = "", + badges: dict[str, int] | None = None, +) -> list[ResolvedSection]: + """Resolve the registry for one request. + + Items are hidden when their feature is off, the permission check fails, + they need a conference that is not available, or their URL name does not + reverse (module URLs not mounted). When no item matches ``active_nav``, + the item whose URL is the longest prefix of ``request.path`` is marked active. + + Args: + request: The current request (``request.user`` is checked). + conference: The conference in scope, if any. + active_nav: The page's ``active_nav`` value. + badges: Attention counts keyed by attention item key. + + Returns: + Visible sections in display order, each with at least one item. + """ + ctx = NavContext(request=request, conference=conference, active_nav=active_nav) + counts = badges or {} + sections: list[ResolvedSection] = [] + for section, items in get_registry().entries(): + if not _visible(section, ctx): + continue + label = _label(section.label, ctx) + resolved = tuple(item for item in (_resolve_item(raw, ctx, label, counts) for raw in items) if item is not None) + if resolved: + sections.append(ResolvedSection(key=section.key, label=label, utility=section.utility, items=resolved)) + if not any(section.is_active for section in sections): + return _mark_path_active(sections, request.path) + return [ + replace(section, items=tuple(_disambiguate_family(item, request.path) for item in section.items)) + for section in sections + ] + + +def nav_uses_badges() -> bool: + """Return whether any registered item shows an attention badge.""" + return any(item.badge for _, items in get_registry().entries() for item in items) diff --git a/src/django_program/manage/nav_builtin.py b/src/django_program/manage/nav_builtin.py new file mode 100644 index 0000000..777866e --- /dev/null +++ b/src/django_program/manage/nav_builtin.py @@ -0,0 +1,425 @@ +"""Built-in management navigation for django-program. + +Registers every stock sidebar section and item (the same tree the manage +dashboard has always shown). Host projects trim or extend this tree +through ``DJANGO_PROGRAM["manage"]`` (``builtin_nav``, ``hide_nav``, +``nav``) instead of editing this module; see +:mod:`django_program.manage.nav` for the registry API. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db.models import Count +from django.urls import NoReverseMatch, reverse +from django.utils.text import slugify + +from django_program.manage.nav import NavContext, NavItem, NavRegistry, manage_perm + +if TYPE_CHECKING: + from collections.abc import Iterable + + +def _conference_label(ctx: NavContext) -> str: + """Return the conference name for the "Conference" section title.""" + return str(ctx.conference.name) if ctx.conference is not None else "Conference" + + +def _talk_type_children(ctx: NavContext) -> Iterable[NavItem]: + """Build one child nav item per talk submission type, with counts.""" + from django_program.pretalx.models import Talk # noqa: PLC0415 + + if ctx.conference is None: + return [] + types = ( + Talk.objects.filter(conference=ctx.conference) + .exclude(submission_type="") + .values("submission_type") + .annotate(count=Count("id")) + .order_by("submission_type") + ) + children: list[NavItem] = [] + for row in types: + slug = slugify(row["submission_type"]) + try: + url = reverse( + "manage:talk-list-by-type", + kwargs={"conference_slug": ctx.conference.slug, "type_slug": slug}, + ) + except NoReverseMatch: + continue + children.append( + NavItem( + key=f"talks-type-{slug}", + label=f"{row['submission_type']} ({row['count']})", + url=url, + active=("talks",), + ) + ) + return children + + +def _override_children(ctx: NavContext) -> Iterable[NavItem]: # noqa: ARG001 + """Build the Pretalx override sub-tabs (speakers, rooms, sponsors, type defaults).""" + return [ + NavItem( + key="overrides-speakers", + label="Speakers", + url="manage:speaker-override-list", + conference_url=True, + permission=manage_perm("view_overrides"), + feature="pretalx_sync", + active=("overrides",), + ), + NavItem( + key="overrides-rooms", + label="Rooms", + url="manage:room-override-list", + conference_url=True, + permission=manage_perm("view_overrides"), + feature="pretalx_sync", + active=("overrides",), + ), + NavItem( + key="overrides-sponsors", + label="Sponsors", + url="manage:sponsor-override-list", + conference_url=True, + permission=manage_perm("view_overrides"), + feature="sponsors", + active=("overrides",), + ), + NavItem( + key="overrides-type-defaults", + label="Type Defaults", + url="manage:type-default-list", + conference_url=True, + permission=manage_perm("view_overrides"), + feature="pretalx_sync", + active=("overrides",), + ), + ] + + +def register_builtin_nav(registry: NavRegistry) -> None: + """Register django-program's built-in sidebar sections and items. + + Called once by :func:`django_program.manage.nav.get_registry`, before + ``builtin_nav``/``hide_nav`` filtering and host ``nav`` hooks run. + + Args: + registry: The registry to populate. + """ + registry.section("conference", _conference_label, order=0) + registry.add( + "conference", + NavItem( + key="dashboard", + label="Dashboard", + url="manage:dashboard", + icon="dashboard", + conference_url=True, + permission=manage_perm("view_dashboard"), + order=0, + ), + NavItem( + key="conference-edit", + label="Settings", + url="manage:conference-edit", + icon="settings", + conference_url=True, + permission=manage_perm("manage_conference_settings"), + order=10, + ), + ) + + registry.section("program", "Program", order=10) + registry.add( + "program", + NavItem( + key="sections", + label="Sections", + url="manage:section-list", + icon="sections", + conference_url=True, + permission=manage_perm("view_program"), + order=0, + ), + NavItem( + key="rooms", + label="Rooms", + url="manage:room-list", + icon="rooms", + conference_url=True, + permission=manage_perm("view_program"), + order=10, + ), + NavItem( + key="speakers", + label="Speakers", + url="manage:speaker-list", + icon="speakers", + conference_url=True, + permission=manage_perm("view_program"), + order=20, + ), + NavItem( + key="talks", + label="Talks", + url="manage:talk-list", + icon="talks", + conference_url=True, + permission=manage_perm("view_program"), + children=_talk_type_children, + order=30, + ), + NavItem( + key="schedule", + label="Schedule", + url="manage:schedule-list", + icon="schedule", + conference_url=True, + permission=manage_perm("view_program"), + order=40, + ), + NavItem( + key="activities", + label="Activities", + url="manage:activity-list", + icon="activities", + conference_url=True, + permission=manage_perm("program_programs.view_activity"), + feature="programs", + order=50, + ), + NavItem( + key="travel-grants", + label="Travel Grants", + url="manage:travel-grant-list", + icon="travel-grants", + conference_url=True, + permission=manage_perm("program_programs.view_travel_grant"), + feature="travel_grants", + order=60, + ), + ) + + registry.section("registration", "Registration", order=20) + registry.add( + "registration", + NavItem( + key="attendees", + label="Attendees", + url="manage:attendee-list", + icon="attendees", + conference_url=True, + permission=manage_perm("view_registration"), + feature="registration", + group="People", + order=0, + ), + NavItem( + key="orders", + label="Orders", + url="manage:order-list", + icon="orders", + conference_url=True, + permission=manage_perm("view_registration"), + feature="registration", + group="People", + order=10, + ), + NavItem( + key="letters", + label="Visa Letters", + url="manage:letter-list", + icon="letters", + conference_url=True, + permission=manage_perm("view_registration"), + feature="visa_letters", + group="People", + order=20, + ), + NavItem( + key="badges", + label="Badges", + url="manage:badge-list", + icon="badges", + conference_url=True, + permission=manage_perm("view_badges"), + feature="registration", + group="People", + order=30, + ), + NavItem( + key="ticket-types", + label="Ticket Types", + url="manage:ticket-type-list", + icon="ticket-types", + conference_url=True, + permission=manage_perm("view_commerce"), + feature="registration", + group="Commerce", + order=40, + ), + NavItem( + key="addons", + label="Add-ons", + url="manage:addon-list", + icon="addons", + conference_url=True, + permission=manage_perm("view_commerce"), + feature="registration", + group="Commerce", + order=50, + ), + NavItem( + key="vouchers", + label="Vouchers", + url="manage:voucher-list", + icon="vouchers", + conference_url=True, + permission=manage_perm("view_commerce"), + feature="registration", + group="Commerce", + order=60, + ), + NavItem( + key="conditions", + label="Conditions", + url="manage:condition-list", + icon="conditions", + conference_url=True, + permission=manage_perm("view_commerce"), + feature="registration", + group="Commerce", + order=70, + ), + NavItem( + key="bulk-purchases", + label="Bulk Purchases", + url="manage:bulk-purchase-list", + icon="bulk-purchases", + conference_url=True, + permission=manage_perm("view_bulk_purchases"), + feature="registration", + group="Commerce", + order=80, + ), + ) + + registry.section("sponsors", "Sponsors", order=30, feature="sponsors") + registry.add( + "sponsors", + NavItem( + key="sponsor-levels", + label="Levels", + url="manage:sponsor-level-list", + icon="sponsor-levels", + conference_url=True, + permission=manage_perm("view_sponsors"), + order=0, + ), + NavItem( + key="sponsor-list", + active=("sponsors",), + label="Sponsors", + url="manage:sponsor-manage-list", + icon="sponsors", + conference_url=True, + permission=manage_perm("view_sponsors"), + order=10, + ), + ) + + registry.section("onsite", "On-site", order=40, feature="registration") + registry.add( + "onsite", + NavItem( + key="checkin", + label="Check-in", + url="manage:checkin-dashboard", + icon="checkin", + conference_url=True, + permission=manage_perm("view_checkin"), + order=0, + ), + NavItem( + key="terminal", + label="Terminal POS", + url="manage:terminal-pos", + icon="terminal", + conference_url=True, + permission=manage_perm("use_terminal"), + order=10, + ), + ) + + registry.section("finance", "Finance & Insights", order=50) + registry.add( + "finance", + NavItem( + key="financial", + label="Financial", + url="manage:financial-dashboard", + icon="financial", + conference_url=True, + permission=manage_perm("view_finance"), + feature="registration", + order=0, + ), + NavItem( + key="purchase-orders", + label="Purchase Orders", + url="manage:purchase-order-list", + icon="purchase-orders", + conference_url=True, + permission=manage_perm("view_finance"), + feature="registration", + order=10, + ), + NavItem( + key="expenses", + label="Expenses", + url="manage:expense-list", + icon="expenses", + conference_url=True, + permission=manage_perm("view_finance"), + order=20, + ), + NavItem( + key="analytics", + label="Analytics", + url="manage:analytics-dashboard", + icon="analytics", + conference_url=True, + permission=manage_perm("view_reports"), + order=30, + ), + NavItem( + key="reports", + label="Reports", + url="manage:reports-dashboard", + icon="reports", + conference_url=True, + permission=manage_perm("view_reports"), + order=40, + ), + ) + + registry.section("sync-overrides", "Sync & Overrides", order=100, utility=True) + registry.add( + "sync-overrides", + NavItem( + key="overrides", + label="Overrides", + url="manage:override-list", + icon="overrides", + conference_url=True, + permission=manage_perm("view_overrides"), + feature="pretalx_sync", + children=_override_children, + order=0, + ), + ) diff --git a/src/django_program/manage/static/django_program/manage/manage.css b/src/django_program/manage/static/django_program/manage/manage.css new file mode 100644 index 0000000..5c0ab72 --- /dev/null +++ b/src/django_program/manage/static/django_program/manage/manage.css @@ -0,0 +1,1216 @@ +/* django-program manage shell styles. + Moved out of django_program/manage/templates/django_program/manage/base.html + so host projects can load manage.css + their own DJANGO_PROGRAM["manage"]["stylesheets"]. */ + +/* ---- Reset & Base ---- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --color-bg: #f8f9fa; + --color-surface: #ffffff; + --color-text: #1a1a2e; + --color-text-secondary: #555770; + --color-text-muted: #8889a0; + --color-primary: #4338ca; + --color-primary-light: #e8e6f8; + --color-primary-hover: #3525b3; + --color-accent: #0d9488; + --color-accent-light: #d5f5f2; + --color-border: #e2e4e9; + --color-border-light: #f0f1f4; + --color-break-bg: #fef3c7; + --color-break-text: #92400e; + --color-social-bg: #dbeafe; + --color-social-text: #1e40af; + --color-warning: #d97706; + --color-warning-bg: #fffbeb; + --color-warning-border: #fcd34d; + --color-success: #059669; + --color-success-bg: #ecfdf5; + --color-danger: #dc2626; + --color-danger-bg: #fef2f2; + --color-locked-bg: #f5f3ff; + --color-locked-border: #c4b5fd; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.1); + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: "SF Mono", SFMono-Regular, Menlo, Consolas, monospace; + --sidebar-width: 240px; + --header-height: 56px; +} + +html { font-size: 16px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } + +body { + font-family: var(--font-sans); + color: var(--color-text); + background: var(--color-bg); + line-height: 1.6; + min-height: 100vh; +} + +/* ---- Links ---- */ +a { color: var(--color-primary); text-decoration: none; transition: color 0.15s ease; } +a:hover { color: var(--color-primary-hover); text-decoration: underline; } + +/* ---- Top Header Bar ---- */ +.top-bar { + background: var(--color-text); + color: #ffffff; + height: var(--header-height); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1.5rem; + position: sticky; + top: 0; + z-index: 200; + gap: 1rem; +} + +.top-bar-left { + display: flex; + align-items: center; + gap: 1.5rem; + min-width: 0; +} + +.top-bar-title { + font-size: 0.95rem; + font-weight: 700; + color: #ffffff; + letter-spacing: -0.01em; + white-space: nowrap; +} +.top-bar-title:hover { color: #ffffff; text-decoration: none; opacity: 0.9; } + +.top-bar-link { + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.65); + transition: color 0.15s ease; +} +.top-bar-link:hover { color: #ffffff; text-decoration: none; } + +.top-bar-right { + display: flex; + align-items: center; + gap: 1rem; + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.65); +} + +.top-bar-right a { + color: rgba(255, 255, 255, 0.65); + transition: color 0.15s ease; +} +.top-bar-right a:hover { color: #ffffff; text-decoration: none; } + +.top-bar-user { + color: rgba(255, 255, 255, 0.85); + font-weight: 500; +} + +.top-bar-separator { + width: 1px; + height: 18px; + background: rgba(255, 255, 255, 0.2); +} + +.top-bar-synced { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.45); + font-family: var(--font-mono); + cursor: default; +} + +/* ---- Top Bar Controls (sidebar toggle, palette trigger) ---- */ +.sidebar-toggle { + display: none; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + background: none; + color: #ffffff; + cursor: pointer; + border-radius: var(--radius-sm); + flex-shrink: 0; +} +.sidebar-toggle svg { width: 18px; height: 18px; } +.sidebar-toggle:hover { background: rgba(255, 255, 255, 0.1); } + +.palette-trigger { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 0.6rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.75); + font-size: 0.78rem; + font-family: var(--font-sans); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} +.palette-trigger:hover { background: rgba(255, 255, 255, 0.12); color: #ffffff; } +.palette-kbd { + font-family: var(--font-mono); + font-size: 0.7rem; + padding: 0.05rem 0.3rem; + border-radius: 3px; + background: rgba(255, 255, 255, 0.12); +} + +/* ---- Layout Shell ---- */ +.layout { + display: flex; + min-height: calc(100vh - var(--header-height)); +} + +/* ---- Sidebar ---- */ +.sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + background: var(--color-surface); + border-right: 1px solid var(--color-border); + padding: 1.25rem 0; + position: sticky; + top: var(--header-height); + height: calc(100vh - var(--header-height)); + overflow-y: auto; +} + +.sidebar-section { + padding: 0 0.75rem; + margin-bottom: 1.5rem; +} + +.sidebar-section-title { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-muted); + padding: 0 0.75rem; + margin-bottom: 0.4rem; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + border-radius: var(--radius-sm); + transition: color 0.15s ease; +} +.sidebar-section-title:hover { + color: var(--color-text-secondary); +} +.sidebar-section-title .sidebar-collapse-icon { + width: 12px; + height: 12px; + flex-shrink: 0; + opacity: 0.5; + transition: transform 0.2s ease, opacity 0.15s ease; +} +.sidebar-section-title:hover .sidebar-collapse-icon { + opacity: 0.8; +} +.sidebar-section.collapsed .sidebar-collapse-icon { + transform: rotate(-90deg); +} +.sidebar-section.collapsed > .sidebar-nav { + max-height: 0; + overflow: hidden; + margin: 0; + opacity: 0; +} +.sidebar-section > .sidebar-nav { + max-height: 2000px; + opacity: 1; + transition: max-height 0.25s ease, opacity 0.2s ease; +} + +.sidebar-sublabel { + font-size: 0.62rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); + padding: 0.5rem 0.75rem 0.2rem; + opacity: 0.7; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + border-radius: var(--radius-sm); + transition: opacity 0.15s ease; +} +.sidebar-sublabel:hover { + opacity: 1; +} +.sidebar-sublabel .sidebar-collapse-icon { + width: 10px; + height: 10px; + flex-shrink: 0; + opacity: 0.5; + transition: transform 0.2s ease, opacity 0.15s ease; +} +.sidebar-sublabel:hover .sidebar-collapse-icon { + opacity: 0.8; +} +.sidebar-subgroup { + max-height: 500px; + opacity: 1; + overflow: hidden; + transition: max-height 0.25s ease, opacity 0.2s ease; +} +.sidebar-subgroup.collapsed { + max-height: 0; + opacity: 0; +} +.sidebar-sublabel.collapsed-label .sidebar-collapse-icon { + transform: rotate(-90deg); +} + +.sidebar-utility-separator { + border: none; + border-top: 1px dashed var(--color-border); + margin: 0.75rem 0.75rem 0; +} + +.sidebar-nav { + list-style: none; +} + +.sidebar-nav li a { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.45rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-secondary); + transition: background 0.15s ease, color 0.15s ease; +} +.sidebar-nav li a:hover { + background: var(--color-primary-light); + color: var(--color-primary); + text-decoration: none; +} + +.sidebar-nav li a.active { + background: var(--color-primary-light); + color: var(--color-primary); + font-weight: 600; +} + +.sidebar-nav-icon { + flex-shrink: 0; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0.65; +} +.sidebar-nav-icon svg { + width: 16px; + height: 16px; +} + +/* ---- Nav Badges ---- */ +.sidebar-nav-badge { + margin-left: auto; + font-size: 0.68rem; + font-weight: 600; + color: var(--color-warning); + background: var(--color-warning-bg); + padding: 0 0.4rem; + border-radius: 999px; + line-height: 1.5; + flex-shrink: 0; +} + +/* ---- Nav Form Button (POST items) ---- */ +.sidebar-nav-form { margin: 0; } +.sidebar-nav-button { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.45rem 0.75rem; + border: none; + background: none; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + font-family: var(--font-sans); + color: var(--color-text-secondary); + cursor: pointer; + text-align: left; + transition: background 0.15s ease, color 0.15s ease; +} +.sidebar-nav-button:hover, +.sidebar-nav-button.active { + background: var(--color-primary-light); + color: var(--color-primary); +} + +/* ---- Expandable Nav Item ---- */ +.sidebar-nav-expandable { + display: flex; + align-items: center; +} +.sidebar-nav-expandable > a { + flex: 1; +} +.sidebar-nav-expand-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: none; + cursor: pointer; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + transition: background 0.15s ease, color 0.15s ease; + flex-shrink: 0; + margin-right: 0.4rem; +} +.sidebar-nav-expand-btn:hover { + background: var(--color-primary-light); + color: var(--color-primary); +} +.sidebar-nav-expand-btn svg { + width: 14px; + height: 14px; + transition: transform 0.2s ease; +} +.sidebar-nav-expand-btn.open svg { + transform: rotate(90deg); +} + +/* ---- Sidebar Subnav ---- */ +.sidebar-subnav { + list-style: none; + margin: 0.15rem 0 0.25rem; + padding: 0; + overflow: hidden; + max-height: 0; + transition: max-height 0.25s ease; +} +.sidebar-subnav.open { + max-height: 500px; +} +.sidebar-subnav li a { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.75rem 0.3rem 2.4rem; + border-radius: var(--radius-sm); + font-size: 0.8rem; + font-weight: 450; + color: var(--color-text-muted); + transition: background 0.15s ease, color 0.15s ease; + position: relative; +} +.sidebar-subnav li a::before { + content: ""; + position: absolute; + left: 1.45rem; + top: 50%; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--color-border); + transform: translateY(-50%); + transition: background 0.15s ease; +} +.sidebar-subnav li a:hover { + background: var(--color-primary-light); + color: var(--color-primary); + text-decoration: none; +} +.sidebar-subnav li a:hover::before { + background: var(--color-primary); +} +.sidebar-subnav li a.active { + background: var(--color-primary-light); + color: var(--color-primary); + font-weight: 600; +} +.sidebar-subnav li a.active::before { + background: var(--color-primary); + width: 5px; + height: 5px; +} +.sidebar-subnav-count { + margin-left: auto; + font-size: 0.68rem; + font-weight: 500; + color: var(--color-text-muted); + font-family: var(--font-mono); + background: var(--color-bg); + padding: 0 0.35rem; + border-radius: 3px; + line-height: 1.5; +} +.sidebar-subnav li a:hover .sidebar-subnav-count, +.sidebar-subnav li a.active .sidebar-subnav-count { + background: rgba(67, 56, 202, 0.08); + color: var(--color-primary); +} + +/* ---- Sidebar Scrim (mobile off-canvas) ---- */ +.sidebar-scrim { + position: fixed; + inset: 0; + background: rgba(10, 10, 20, 0.45); + z-index: 250; +} + +/* ---- Command Palette ---- */ +.manage-palette { + border: none; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: 0; + width: min(560px, 92vw); + max-height: min(480px, 80vh); + color: var(--color-text); +} +.manage-palette::backdrop { background: rgba(10, 10, 20, 0.5); } +.palette-input-wrap { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.9rem 1rem; + border-bottom: 1px solid var(--color-border); +} +.palette-input-wrap svg { width: 16px; height: 16px; color: var(--color-text-muted); flex-shrink: 0; } +.palette-input { + flex: 1; + border: none; + outline: none; + font-size: 0.95rem; + font-family: var(--font-sans); + color: var(--color-text); + background: none; +} +.palette-results { + list-style: none; + max-height: 360px; + overflow-y: auto; + padding: 0.4rem; +} +.palette-result { + display: flex; + align-items: baseline; + gap: 0.6rem; + padding: 0.55rem 0.7rem; + border-radius: var(--radius-sm); + font-size: 0.88rem; + cursor: pointer; +} +.palette-result.active, +.palette-result:hover { + background: var(--color-primary-light); + color: var(--color-primary); +} +.palette-result-section { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + flex-shrink: 0; +} +.palette-result.active .palette-result-section, +.palette-result:hover .palette-result-section { color: inherit; opacity: 0.7; } +.palette-empty { padding: 1rem; color: var(--color-text-muted); font-size: 0.88rem; text-align: center; } + +/* ---- Main Content Area ---- */ +.main-content { + flex: 1; + min-width: 0; + padding: 2rem 2.5rem 4rem; +} + +.main-content--full { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +/* ---- Breadcrumb ---- */ +.breadcrumb { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.82rem; + color: var(--color-text-muted); + margin-bottom: 1.25rem; +} + +.breadcrumb a { + color: var(--color-text-muted); + transition: color 0.15s ease; +} +.breadcrumb a:hover { color: var(--color-primary); text-decoration: none; } + +.breadcrumb-separator::after { content: "/"; margin: 0 0.1rem; color: var(--color-border); } + +/* ---- Page Header ---- */ +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 2rem; + padding-bottom: 1.25rem; + border-bottom: 2px solid var(--color-border); +} + +.page-header-text h1 { + font-size: 1.6rem; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--color-text); + line-height: 1.3; +} + +.page-header-text p { + margin-top: 0.25rem; + color: var(--color-text-secondary); + font-size: 0.95rem; +} + +.page-actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +/* ---- Buttons ---- */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 600; + border: 1px solid transparent; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; + text-decoration: none; + line-height: 1.4; + font-family: var(--font-sans); +} +.btn:hover { text-decoration: none; } + +.btn-primary { + background: var(--color-primary); + color: #ffffff; + border-color: var(--color-primary); +} +.btn-primary:hover { + background: var(--color-primary-hover); + border-color: var(--color-primary-hover); + color: #ffffff; +} + +.btn-secondary { + background: var(--color-surface); + color: var(--color-text-secondary); + border-color: var(--color-border); +} +.btn-secondary:hover { + background: var(--color-bg); + border-color: var(--color-text-muted); + color: var(--color-text); +} + +.btn-danger { + background: var(--color-danger); + color: #ffffff; + border-color: var(--color-danger); +} +.btn-danger:hover { + background: #b91c1c; + border-color: #b91c1c; + color: #ffffff; +} + +.btn-sm { + padding: 0.35rem 0.7rem; + font-size: 0.8rem; +} + +/* ---- Flash Messages ---- */ +.messages { + list-style: none; + margin-bottom: 1.5rem; +} + +.messages li { + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.messages .success { background: var(--color-success-bg); color: var(--color-success); } +.messages .error { background: var(--color-danger-bg); color: var(--color-danger); } +.messages .warning { background: var(--color-warning-bg); color: var(--color-warning); } +.messages .info { background: var(--color-primary-light); color: var(--color-primary); } + +/* ---- Cards ---- */ +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + transition: box-shadow 0.2s ease, transform 0.2s ease; +} +.card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); } +.card--static:hover { box-shadow: var(--shadow-sm); transform: none; } + +.card-body { padding: 1.25rem 1.5rem; } + +/* ---- Stat Cards Grid ---- */ +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 1.25rem; + transition: box-shadow 0.2s ease, border-color 0.2s ease; +} +.stat-card:hover { box-shadow: var(--shadow-md); border-color: var(--color-primary); } + +.stat-card a { + display: block; + color: inherit; + text-decoration: none; +} +.stat-card a:hover { text-decoration: none; color: inherit; } + +.stat-card-value { + font-size: 2rem; + font-weight: 800; + color: var(--color-text); + line-height: 1.1; + letter-spacing: -0.03em; +} + +.stat-card-label { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); + margin-top: 0.35rem; +} + +/* ---- Conference Card Grid ---- */ +.conference-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1.25rem; +} + +.conference-card a { + display: block; + color: inherit; + text-decoration: none; +} +.conference-card a:hover { text-decoration: none; color: inherit; } + +.conference-card-name { + font-size: 1.1rem; + font-weight: 700; + color: var(--color-text); + margin-bottom: 0.3rem; +} + +.conference-card-slug { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--color-text-muted); + margin-bottom: 0.6rem; +} + +.conference-card-dates { + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +/* ---- Tables ---- */ +.data-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.data-table thead { background: var(--color-bg); } + +.data-table th { + padding: 0.7rem 1rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.data-table td { + padding: 0.7rem 1rem; + font-size: 0.9rem; + border-bottom: 1px solid var(--color-border-light); + vertical-align: middle; +} + +.data-table tbody tr:last-child td { border-bottom: none; } +.data-table tbody tr:hover { background: #fafaff; } + +/* ---- Badges ---- */ +.badge { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.badge--active { background: var(--color-success-bg); color: var(--color-success); } +.badge--inactive { background: var(--color-bg); color: var(--color-text-muted); border: 1px solid var(--color-border); } +.badge--synced { background: var(--color-accent-light); color: var(--color-accent); } +.badge--warning { background: var(--color-warning-bg); color: var(--color-warning); } +.badge--confirmed { background: var(--color-success-bg); color: var(--color-success); } +.badge--accepted { background: var(--color-primary-light); color: var(--color-primary); } +.badge--submitted { background: var(--color-warning-bg); color: var(--color-warning); } +.badge--talk { background: var(--color-primary-light); color: var(--color-primary); } +.badge--break { background: var(--color-break-bg); color: var(--color-break-text); } +.badge--social { background: var(--color-social-bg); color: var(--color-social-text); } +.badge--other { background: var(--color-border-light); color: var(--color-text-muted); } + +/* ---- Forms ---- */ +.form-container { + max-width: 640px; +} + +.form-container p { + margin-bottom: 1rem; +} + +.form-container label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text); + margin-bottom: 0.35rem; +} + +.form-container input[type="text"], +.form-container input[type="email"], +.form-container input[type="url"], +.form-container input[type="number"], +.form-container input[type="date"], +.form-container input[type="datetime-local"], +.form-container textarea, +.form-container select { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-family: var(--font-sans); + color: var(--color-text); + background: var(--color-surface); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.form-container input:focus, +.form-container textarea:focus, +.form-container select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.1); +} + +.form-container textarea { + min-height: 100px; + resize: vertical; +} + +.form-container .helptext { + display: block; + font-size: 0.78rem; + color: var(--color-text-muted); + margin-top: 0.25rem; +} + +.form-container .errorlist { + list-style: none; + margin: 0.25rem 0 0; + padding: 0; +} + +.form-container .errorlist li { + font-size: 0.8rem; + color: var(--color-danger); +} + +.form-container input[type="checkbox"] { + width: auto; + margin-right: 0.4rem; +} + +.form-actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 1.5rem; + padding-top: 1.25rem; + border-top: 1px solid var(--color-border-light); +} + +/* ---- Form Group with Lock ---- */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text); + margin-bottom: 0.35rem; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-family: var(--font-sans); + color: var(--color-text); + background: var(--color-surface); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.1); +} + +.form-group textarea { min-height: 100px; resize: vertical; } + +.form-group input[type="checkbox"] { width: auto; } + +.form-group .helptext { + display: block; + font-size: 0.78rem; + color: var(--color-text-muted); + margin-top: 0.25rem; +} + +.form-group .errorlist { + list-style: none; + margin: 0.25rem 0 0; + padding: 0; +} + +.form-group .errorlist li { + font-size: 0.8rem; + color: var(--color-danger); +} + +.form-group--locked input, +.form-group--locked textarea, +.form-group--locked select { + background: var(--color-locked-bg); + border-color: var(--color-locked-border); + cursor: not-allowed; + opacity: 0.8; +} + +.lock-icon { + font-size: 0.75rem; + margin-left: 0.3rem; + color: var(--color-warning); +} + +/* ---- Sync Banner ---- */ +.sync-banner { + background: var(--color-warning-bg); + border: 1px solid var(--color-warning-border); + border-radius: var(--radius-md); + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + font-size: 0.9rem; + color: var(--color-warning); + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* ---- Info Banner ---- */ +.info-banner { + background: var(--color-primary-light); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + font-size: 0.9rem; + color: var(--color-primary); +} + +/* ---- Search / Filter Bar ---- */ +.filter-bar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.filter-bar input[type="text"], +.filter-bar input[type="search"], +.filter-bar input[type="date"] { + padding: 0.45rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-family: var(--font-sans); + color: var(--color-text); + background: var(--color-surface); + min-width: 160px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.filter-bar input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.1); +} + +.filter-bar select { + padding: 0.45rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-family: var(--font-sans); + color: var(--color-text); + background: var(--color-surface); +} + +/* ---- Pagination ---- */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-top: 2rem; + padding-top: 1.25rem; + border-top: 1px solid var(--color-border-light); +} + +.pagination a { + padding: 0.4rem 0.85rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.85rem; + font-weight: 500; + color: var(--color-text-secondary); + background: var(--color-surface); + transition: background 0.15s ease, border-color 0.15s ease; +} +.pagination a:hover { + background: var(--color-primary-light); + border-color: var(--color-primary); + color: var(--color-primary); + text-decoration: none; +} + +.pagination .page-info { + font-size: 0.85rem; + color: var(--color-text-muted); +} + +/* ---- Empty State ---- */ +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-muted); + font-size: 1.05rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.empty-state p { margin-bottom: 1rem; } +.empty-state p:last-child { margin-bottom: 0; } + +/* ---- Quick Info Grid ---- */ +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.info-item { + padding: 0.85rem 1rem; + background: var(--color-bg); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); +} + +.info-item-label { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); + margin-bottom: 0.15rem; +} + +.info-item-value { + font-size: 0.95rem; + color: var(--color-text); + font-weight: 500; +} + +/* ---- Date Group Headers (schedule) ---- */ +.date-header { + font-size: 1rem; + font-weight: 700; + color: var(--color-text); + margin: 2rem 0 0.75rem; + padding: 0.5rem 0; + display: flex; + align-items: center; + gap: 0.6rem; +} +.date-header:first-child { margin-top: 0; } + +.date-header::before { + content: ""; + display: inline-block; + width: 4px; + height: 1em; + background: var(--color-primary); + border-radius: 2px; +} + +/* ---- Section Heading ---- */ +.section-heading { + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); + margin-bottom: 0.75rem; + margin-top: 2rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--color-border-light); +} +.section-heading:first-child { margin-top: 0; } + +/* ---- Read-Only Field ---- */ +.readonly-field { + padding: 0.5rem 0.75rem; + background: var(--color-bg); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); + font-size: 0.9rem; + color: var(--color-text-secondary); +} + +/* ---- Mono Text ---- */ +.mono { + font-family: var(--font-mono); + font-size: 0.85rem; +} + +/* ---- Accessibility: focus-visible ---- */ +a:focus-visible, +button:focus-visible, +input:focus-visible, +[role="button"]:focus-visible, +.palette-result:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* ---- Accessibility: reduced motion ---- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } +} + +/* ---- Responsive (mobile off-canvas sidebar) ---- */ +@media (max-width: 900px) { + .top-bar { padding: 0 1rem; } + .top-bar-title { font-size: 0.85rem; } + .sidebar-toggle { display: inline-flex; } + .palette-trigger span { display: none; } + .sidebar { + position: fixed; + top: var(--header-height); + left: 0; + bottom: 0; + z-index: 260; + transform: translateX(-100%); + transition: transform 0.2s ease; + box-shadow: var(--shadow-lg); + } + .sidebar.open { transform: translateX(0); } + .main-content { padding: 1.25rem 1rem 3rem; } + .main-content--full { padding: 1.25rem 1rem 3rem; } + .page-header { flex-direction: column; gap: 0.75rem; } + .page-header-text h1 { font-size: 1.3rem; } + .stat-grid { grid-template-columns: repeat(2, 1fr); } + .conference-grid { grid-template-columns: 1fr; } + .filter-bar { flex-direction: column; align-items: stretch; } + .filter-bar input[type="text"], + .filter-bar input[type="search"] { min-width: 100%; } + .data-table th, .data-table td { padding: 0.5rem 0.6rem; font-size: 0.82rem; } + .info-grid { grid-template-columns: 1fr; } +} diff --git a/src/django_program/manage/static/django_program/manage/manage.js b/src/django_program/manage/static/django_program/manage/manage.js new file mode 100644 index 0000000..9de505c --- /dev/null +++ b/src/django_program/manage/static/django_program/manage/manage.js @@ -0,0 +1,294 @@ +(function () { + 'use strict'; + + var COLLAPSE_KEY = 'manage-sidebar-collapsed'; + + function readCollapsed() { + try { + return JSON.parse(localStorage.getItem(COLLAPSE_KEY) || '{}'); + } catch (e) { + return {}; + } + } + + function writeCollapsed(state) { + try { + localStorage.setItem(COLLAPSE_KEY, JSON.stringify(state)); + } catch (e) { + /* storage unavailable (private browsing, etc.) */ + } + } + + function getCookie(name) { + var match = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)'); + return match ? decodeURIComponent(match.pop()) : ''; + } + + function initSectionCollapse() { + var stored = readCollapsed(); + + document.querySelectorAll('.sidebar-section[data-section]').forEach(function (section) { + var id = section.dataset.section; + var title = section.querySelector('.sidebar-section-title'); + if (!title) return; + var hasActive = section.querySelector('.sidebar-nav a.active, .sidebar-subnav a.active'); + + if (stored[id] && !hasActive) { + section.classList.add('collapsed'); + title.setAttribute('aria-expanded', 'false'); + } + + function toggleSection() { + section.classList.toggle('collapsed'); + var collapsed = section.classList.contains('collapsed'); + stored[id] = collapsed; + title.setAttribute('aria-expanded', String(!collapsed)); + writeCollapsed(stored); + } + + title.addEventListener('click', toggleSection); + title.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleSection(); + } + }); + }); + + document.querySelectorAll('.sidebar-sublabel[data-subsection]').forEach(function (label) { + var id = label.dataset.subsection; + var target = document.querySelector('[data-subsection-target="' + id + '"]'); + if (!target) return; + var hasActive = target.querySelector('a.active'); + + if (stored[id] && !hasActive) { + target.classList.add('collapsed'); + label.classList.add('collapsed-label'); + } + + function toggleGroup() { + target.classList.toggle('collapsed'); + label.classList.toggle('collapsed-label'); + stored[id] = target.classList.contains('collapsed'); + writeCollapsed(stored); + } + + label.addEventListener('click', toggleGroup); + label.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleGroup(); + } + }); + }); + + document.querySelectorAll('.sidebar-nav-expand-btn[data-expand-target]').forEach(function (btn) { + var target = document.getElementById(btn.dataset.expandTarget); + if (!target) return; + btn.addEventListener('click', function () { + btn.classList.toggle('open'); + target.classList.toggle('open'); + btn.setAttribute('aria-expanded', String(target.classList.contains('open'))); + }); + }); + } + + function initMobileDrawer() { + var toggle = document.getElementById('manage-sidebar-toggle'); + var sidebar = document.getElementById('manage-sidebar'); + var scrim = document.getElementById('manage-sidebar-scrim'); + if (!toggle || !sidebar) return; + + function openDrawer() { + sidebar.classList.add('open'); + toggle.setAttribute('aria-expanded', 'true'); + if (scrim) scrim.hidden = false; + } + + function closeDrawer() { + sidebar.classList.remove('open'); + toggle.setAttribute('aria-expanded', 'false'); + if (scrim) scrim.hidden = true; + } + + toggle.addEventListener('click', function () { + if (sidebar.classList.contains('open')) { + closeDrawer(); + } else { + openDrawer(); + } + }); + + if (scrim) scrim.addEventListener('click', closeDrawer); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && sidebar.classList.contains('open')) closeDrawer(); + }); + } + + function initPalette() { + var trigger = document.getElementById('manage-palette-trigger'); + var dialog = document.getElementById('manage-palette'); + if (!dialog) return; + + var dataEl = document.getElementById('manage-palette-data'); + var items = []; + if (dataEl) { + try { + items = JSON.parse(dataEl.textContent); + } catch (e) { + items = []; + } + } + + var input = dialog.querySelector('.palette-input'); + var list = dialog.querySelector('.palette-results'); + var activeIndex = -1; + var filtered = items; + + function render() { + if (!list) return; + list.innerHTML = ''; + if (!filtered.length) { + var empty = document.createElement('li'); + empty.className = 'palette-empty'; + empty.textContent = 'No matches'; + list.appendChild(empty); + if (input) input.removeAttribute('aria-activedescendant'); + return; + } + filtered.forEach(function (item, index) { + var li = document.createElement('li'); + li.className = 'palette-result' + (index === activeIndex ? ' active' : ''); + li.setAttribute('role', 'option'); + li.id = 'palette-result-' + index; + var sectionEl = document.createElement('span'); + sectionEl.className = 'palette-result-section'; + sectionEl.textContent = item.section || ''; + var labelEl = document.createElement('span'); + labelEl.className = 'palette-result-label'; + labelEl.textContent = item.label; + li.appendChild(sectionEl); + li.appendChild(labelEl); + li.addEventListener('mousedown', function (e) { + e.preventDefault(); + navigate(item); + }); + list.appendChild(li); + }); + if (input) { + input.setAttribute('aria-activedescendant', activeIndex >= 0 ? 'palette-result-' + activeIndex : ''); + } + } + + function filterItems(query) { + var q = query.trim().toLowerCase(); + filtered = !q + ? items + : items.filter(function (item) { + return ( + (item.label && item.label.toLowerCase().indexOf(q) !== -1) || + (item.section && item.section.toLowerCase().indexOf(q) !== -1) || + (item.description && item.description.toLowerCase().indexOf(q) !== -1) + ); + }); + activeIndex = filtered.length ? 0 : -1; + render(); + } + + function navigate(item) { + closePalette(); + if (item.method === 'post') { + var form = document.createElement('form'); + form.method = 'post'; + form.action = item.href; + form.style.display = 'none'; + var csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = 'csrfmiddlewaretoken'; + csrfInput.value = getCookie('csrftoken'); + form.appendChild(csrfInput); + document.body.appendChild(form); + form.submit(); + } else { + window.location.href = item.href; + } + } + + function openPalette() { + filtered = items; + activeIndex = items.length ? 0 : -1; + if (input) input.value = ''; + render(); + if (typeof dialog.showModal === 'function') { + dialog.showModal(); + } else { + dialog.setAttribute('open', ''); + } + if (input) input.focus(); + } + + function closePalette() { + if (typeof dialog.close === 'function' && dialog.open) { + dialog.close(); + } else { + dialog.removeAttribute('open'); + } + } + + if (trigger) { + trigger.addEventListener('click', openPalette); + var isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform || ''); + var kbd = trigger.querySelector('.palette-kbd'); + if (kbd) kbd.textContent = isMac ? kbd.dataset.mac : kbd.dataset.pc; + } + + document.addEventListener('keydown', function (e) { + var isShortcut = (e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K'); + if (!isShortcut) return; + e.preventDefault(); + if (dialog.open) { + closePalette(); + } else { + openPalette(); + } + }); + + if (input) { + input.addEventListener('input', function () { + filterItems(input.value); + }); + input.addEventListener('keydown', function (e) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (filtered.length) activeIndex = Math.min(activeIndex + 1, filtered.length - 1); + render(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (filtered.length) activeIndex = Math.max(activeIndex - 1, 0); + render(); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (filtered[activeIndex]) navigate(filtered[activeIndex]); + } else if (e.key === 'Escape') { + closePalette(); + } + }); + } + + dialog.addEventListener('cancel', function (e) { + e.preventDefault(); + closePalette(); + }); + + dialog.addEventListener('click', function (e) { + if (e.target === dialog) closePalette(); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + initSectionCollapse(); + initMobileDrawer(); + initPalette(); + }); +})(); diff --git a/src/django_program/manage/templates/django_program/manage/_icons.html b/src/django_program/manage/templates/django_program/manage/_icons.html new file mode 100644 index 0000000..61a2270 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/_icons.html @@ -0,0 +1,35 @@ + 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 19842e9..a2681b4 100644 --- a/src/django_program/manage/templates/django_program/manage/base.html +++ b/src/django_program/manage/templates/django_program/manage/base.html @@ -1,1348 +1,161 @@ +{% load static %} +{% load manage_shell %} +{% manage_shell as shell %} - {% block title %}Management{% endblock %} -- Conference Management - + {% block title %}Management{% endblock %} -- {{ shell.config.title }} + + {% for sheet in shell.config.stylesheets %} + + {% endfor %} {% block extra_head %}{% endblock %} - + + {% include "django_program/manage/_icons.html" %} +
- Conference Management - {% if conference %} + {% if shell.nav %} + + {% endif %} + {{ shell.config.title }} + {% if shell.conference and shell.config.public_schedule_link %} + {% url 'pretalx:schedule' shell.conference.slug as public_schedule_url %} + {% if public_schedule_url %} - View Public Schedule + View Public Schedule + {% endif %} {% endif %}
+ {% block topbar_actions %}{% endblock %} + {% if shell.config.palette %} + + + {% endif %} {% if last_synced %} Synced {{ last_synced|timesince }} ago {% endif %} {% if user.is_authenticated %} {{ user.get_short_name|default:user.username }} + {% if shell.logout_href %} - Log out + Log out + {% endif %} {% endif %}
- {% if conference %} - + {% endif %} + {% endblock %} -
+
{% block breadcrumb %}{% endblock %} + {% block messages %} {% if messages %}
    {% for message in messages %} @@ -1350,7 +163,9 @@ {% endfor %}
{% endif %} + {% endblock %} + {% block page_header %} + {% endblock %} {% block content %}{% endblock %}
- {% block extra_js %}{% endblock %} - + {% block extra_js %}{% endblock %} + + {% for script in shell.config.scripts %} + + {% endfor %} diff --git a/src/django_program/manage/templates/django_program/manage/dashboard.html b/src/django_program/manage/templates/django_program/manage/dashboard.html index 69ac8a2..899b896 100644 --- a/src/django_program/manage/templates/django_program/manage/dashboard.html +++ b/src/django_program/manage/templates/django_program/manage/dashboard.html @@ -43,6 +43,25 @@

{{ conference.name }}

} .dash-alert:hover { text-decoration: none; color: var(--color-warning); } + .attention-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; } + .attention-item a { + display: flex; + align-items: baseline; + gap: 0.6rem; + padding: 0.6rem 1rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + text-decoration: none; + } + .attention-item a:hover { text-decoration: none; } + .attention-item-count { font-weight: 700; font-size: 1rem; } + .attention-item-label { font-weight: 500; } + .attention-item-desc { color: inherit; opacity: 0.8; font-weight: 400; } + .attention-item--warning a { background: var(--color-warning-bg); border: 1px solid var(--color-warning-border); color: var(--color-warning); } + .attention-item--danger a { background: var(--color-danger-bg, #fef2f2); border: 1px solid var(--color-danger-border, #fecaca); color: var(--color-danger, #dc2626); } + .attention-item--success a { background: var(--color-success-bg, #f0fdf4); border: 1px solid var(--color-success-border, #bbf7d0); color: var(--color-success-text, #166534); } + .attention-item--info a { background: var(--color-primary-bg, #eff6ff); border: 1px solid var(--color-primary-border, #bfdbfe); color: var(--color-primary, #2563eb); } + .dir-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -189,6 +208,25 @@

{{ conference.name }}

{% endif %} +{% if attention_items %} +
+
+

Needs attention

+ +
+
+{% endif %} + {% if budget %} {% include "django_program/manage/charts/_chart_utils.html" %}
diff --git a/src/django_program/manage/templates/django_program/manage/focus.html b/src/django_program/manage/templates/django_program/manage/focus.html new file mode 100644 index 0000000..8e05ac8 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/focus.html @@ -0,0 +1,11 @@ +{% extends "django_program/manage/base.html" %} +{% comment %} +Kiosk/full-width layout for pages that don't need the sidebar (check-in +scanner, terminal POS, badge preview, etc). Drops the sidebar and widens +the main content area; everything else (top bar, palette, messages) is +unchanged. +{% endcomment %} + +{% block sidebar %}{% endblock %} + +{% block main_class %}main-content main-content--full{% endblock %} diff --git a/src/django_program/manage/templatetags/__init__.py b/src/django_program/manage/templatetags/__init__.py new file mode 100644 index 0000000..7d618ae --- /dev/null +++ b/src/django_program/manage/templatetags/__init__.py @@ -0,0 +1 @@ +"""Template tags for the django-program management shell.""" diff --git a/src/django_program/manage/templatetags/manage_shell.py b/src/django_program/manage/templatetags/manage_shell.py new file mode 100644 index 0000000..bbc0c12 --- /dev/null +++ b/src/django_program/manage/templatetags/manage_shell.py @@ -0,0 +1,158 @@ +"""Template tag resolving the manage shell (nav, config, palette) for a page. + +Usage:: + + {% load manage_shell %} + {% manage_shell as shell %} + +``shell`` exposes ``nav`` (resolved sections), ``conference``, ``config`` +(the :class:`~django_program.settings.ManageConfig`), ``home_href``, +``logout_href`` and ``palette_items``. Works on any page that extends +``django_program/manage/base.html``, including host pages that only have +``request`` and ``user`` in context. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from django import template +from django.urls import NoReverseMatch, reverse +from django.utils.html import escape +from django.utils.safestring import mark_safe + +from django_program.manage.attention import attention_counts +from django_program.manage.nav import ResolvedItem, ResolvedSection, build_nav, nav_uses_badges +from django_program.settings import ManageConfig, get_config + +if TYPE_CHECKING: + from django.http import HttpRequest + + from django_program.conference.models import Conference + +register = template.Library() + + +@dataclass(frozen=True, slots=True) +class ManageShell: + """Resolved manage shell data for one request.""" + + nav: list[ResolvedSection] + conference: Conference | None + config: ManageConfig + home_href: str + logout_href: str + palette_items: list[dict[str, Any]] = field(default_factory=list) + + +def _resolve_url(name: str) -> str: + """Resolve a URL name or literal path/URL; returns "" if it can't be resolved.""" + if not name: + return "" + if "/" in name: + return name + try: + return reverse(name) + except NoReverseMatch: + return "" + + +def _resolve_conference(context_conference: object, config: ManageConfig) -> Conference | None: + if context_conference: + return context_conference # type: ignore[return-value] + if not config.conference: + return None + from django_program.conference.models import Conference # noqa: PLC0415 + + return Conference.objects.filter(slug=config.conference).first() + + +def _resolve_home_href(config: ManageConfig, conference: Conference | None) -> str: + if config.home_url: + return _resolve_url(config.home_url) + if conference is not None: + try: + return reverse("manage:dashboard", kwargs={"conference_slug": conference.slug}) + except NoReverseMatch: + pass + try: + return reverse("manage:conference-list") + except NoReverseMatch: + return "" + + +def _palette_items(sections: list[ResolvedSection]) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + + def add(resolved: ResolvedItem, section_label: str) -> None: + items.append( + { + "key": resolved.key, + "label": resolved.label, + "href": resolved.href, + "section": section_label, + "description": resolved.description, + "method": resolved.method, + } + ) + for child in resolved.children: + add(child, section_label) + + for section in sections: + for item in section.items: + add(item, section.label) + return items + + +@register.simple_tag(takes_context=True) +def manage_shell(context: template.Context) -> ManageShell: + """Resolve the manage shell for the current page. + + Args: + context: The current template context; must include ``request``. + + Returns: + A :class:`ManageShell` with nav, config, and palette data. + """ + request: HttpRequest = context["request"] + config = get_config().manage + conference = _resolve_conference(context.get("conference"), config) + active_nav = context.get("active_nav") or "" + badges = attention_counts(request, conference) if nav_uses_badges() else {} + sections = build_nav(request, conference=conference, active_nav=active_nav, badges=badges) + return ManageShell( + nav=sections, + conference=conference, + config=config, + home_href=_resolve_home_href(config, conference), + logout_href=_resolve_url(config.logout_url), + palette_items=_palette_items(sections), + ) + + +@register.filter +def group_nav_items(items: tuple[ResolvedItem, ...]) -> list[dict[str, Any]]: + """Group resolved nav items into contiguous ``{"label": ..., "items": [...]}`` buckets. + + Items with no ``group`` get an empty-string bucket label. + """ + groups: list[dict[str, Any]] = [] + for item in items: + label = item.group + if groups and groups[-1]["label"] == label: + groups[-1]["items"].append(item) + else: + groups.append({"label": label, "items": [item]}) + return groups + + +@register.filter +def nav_icon(icon: str) -> str: + """Render a nav item's icon: a sprite reference, or raw ```` markup verbatim.""" + if not icon: + return "" + if "' + return mark_safe(markup) # noqa: S308 -- markup is built from an escaped, developer-configured name diff --git a/src/django_program/manage/views.py b/src/django_program/manage/views.py index 93928f5..255bac3 100644 --- a/src/django_program/manage/views.py +++ b/src/django_program/manage/views.py @@ -34,6 +34,8 @@ from django.views.generic import CreateView, DetailView, ListView, TemplateView, UpdateView from django_program.conference.models import Conference, Expense, ExpenseCategory, KPITargets, Section +from django_program.features import require_feature +from django_program.manage.attention import get_attention from django_program.manage.forms import ( ActivityForm, AddOnForm, @@ -150,32 +152,17 @@ def _safe_csv_cell(value: object) -> str: return text -_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. Each view sets ``required_permission`` to a permission codename. Without an app label prefix, defaults to ``program_conference.``. + Each view may also set ``required_feature`` to a feature name (see + ``django_program.features``). The view always 404s when the ``manage_ui`` + feature is disabled, and 404s when ``required_feature`` is set and + disabled for the resolved conference. + Access is granted if ANY of: - User is superuser - User has ``program_conference.change_conference`` (legacy full-access) @@ -186,6 +173,7 @@ class ConferencePermissionMixin(LoginRequiredMixin): """ required_permission: str = "" + required_feature: str = "" conference: Conference kwargs: dict[str, str] @@ -203,7 +191,7 @@ 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. + """Resolve the conference, enforce feature flags and permissions before dispatch. Args: request: The incoming HTTP request. @@ -215,6 +203,7 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo Raises: PermissionDenied: If the user is not authorized. + Http404: If ``manage_ui`` or ``required_feature`` is disabled. """ if not request.user.is_authenticated: return self.handle_no_permission() # type: ignore[return-value] @@ -223,6 +212,11 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo if slug: self.conference = get_object_or_404(Conference, slug=slug) + conference = getattr(self, "conference", None) + require_feature("manage_ui", conference=conference) + if self.required_feature: + require_feature(self.required_feature, conference=conference) + user = request.user if user.is_superuser: return super().dispatch(request, *args, **kwargs) # type: ignore[misc] @@ -240,7 +234,7 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo raise PermissionDenied def get_context_data(self, **kwargs: object) -> dict[str, object]: - """Add the conference, sidebar metadata, and permissions to the template context. + """Add the conference and sync metadata to the template context. Args: **kwargs: Additional context data. @@ -250,38 +244,9 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: """ context: dict[str, object] = super().get_context_data(**kwargs) # type: ignore[misc] 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 = [] @@ -315,6 +280,10 @@ class ConferenceListView(LoginRequiredMixin, ListView): def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse: """Check that the user has at least one conference management permission. + When ``DJANGO_PROGRAM["manage"]["conference"]`` names a single-conference + site and that conference exists and is accessible to the user, redirect + straight to its dashboard instead of showing the list. + Args: request: The incoming HTTP request. *args: Positional arguments from the URL resolver. @@ -325,14 +294,27 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo Raises: PermissionDenied: If the user has no conference management permissions. + Http404: If the ``manage_ui`` feature is disabled. """ if not request.user.is_authenticated: return self.handle_no_permission() + require_feature("manage_ui") + user = request.user if not (user.is_superuser or any(p.startswith("program_conference.") for p in user.get_all_permissions())): raise PermissionDenied + single_slug = get_config().manage.conference + if single_slug: + conference = Conference.objects.filter(slug=single_slug).first() + if conference is not None and ( + user.is_superuser + or user.has_perm("program_conference.change_conference") + or user.has_perm("program_conference.view_dashboard") + ): + return redirect("manage:dashboard", conference_slug=conference.slug) + return super().dispatch(request, *args, **kwargs) def get_queryset(self) -> QuerySet[Conference]: @@ -371,10 +353,14 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo Raises: PermissionDenied: If the user lacks the required permission. + Http404: If ``manage_ui`` or ``pretalx_sync`` is disabled. """ if not request.user.is_authenticated: return self.handle_no_permission() + require_feature("manage_ui") + require_feature("pretalx_sync") + if not (request.user.is_superuser or request.user.has_perm("program_conference.manage_conference_settings")): raise PermissionDenied @@ -496,6 +482,8 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo """Require ``manage_conference_settings`` permission for conference import.""" if not request.user.is_authenticated: return self.handle_no_permission() + require_feature("manage_ui") + require_feature("pretalx_sync") if not (request.user.is_superuser or request.user.has_perm("program_conference.manage_conference_settings")): raise PermissionDenied return super().dispatch(request, *args, **kwargs) @@ -845,6 +833,8 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: ) context["has_psf_sponsor_sync"] = sponsor_profile.has_api_sync + context["attention_items"] = [item for item in get_attention(self.request, conference) if item.count] + return context @@ -1486,6 +1476,7 @@ class SponsorLevelListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/sponsor_level_list.html" required_permission = "view_sponsors" + required_feature = "sponsors" context_object_name = "levels" paginate_by = 50 @@ -1509,6 +1500,7 @@ class SponsorLevelEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/sponsor_level_edit.html" required_permission = "change_sponsors" + required_feature = "sponsors" form_class = SponsorLevelForm context_object_name = "level" @@ -1537,6 +1529,7 @@ class SponsorLevelCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/sponsor_level_edit.html" required_permission = "change_sponsors" + required_feature = "sponsors" form_class = SponsorLevelForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -1562,6 +1555,7 @@ class SponsorManageListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/sponsor_list.html" required_permission = "view_sponsors" + required_feature = "sponsors" context_object_name = "sponsors" paginate_by = 50 @@ -1589,6 +1583,7 @@ class SponsorEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/sponsor_edit.html" required_permission = "change_sponsors" + required_feature = "sponsors" form_class = SponsorForm context_object_name = "sponsor" @@ -1632,6 +1627,7 @@ class SponsorCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/sponsor_edit.html" required_permission = "change_sponsors" + required_feature = "sponsors" form_class = SponsorForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -1670,6 +1666,7 @@ class ActivityManageListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/activity_list.html" required_permission = "program_programs.view_activity" + required_feature = "programs" context_object_name = "activities" paginate_by = 50 @@ -1707,6 +1704,7 @@ class ActivityEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/activity_edit.html" required_permission = "program_programs.manage_activity" + required_feature = "programs" form_class = ActivityForm context_object_name = "activity" @@ -1744,6 +1742,7 @@ class ActivityCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/activity_edit.html" required_permission = "program_programs.manage_activity" + required_feature = "programs" form_class = ActivityForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -1789,6 +1788,8 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo return self.handle_no_permission() # type: ignore[return-value] self.conference = get_object_or_404(Conference, slug=kwargs.get("conference_slug", "")) + require_feature("manage_ui", conference=self.conference) + require_feature("programs", conference=self.conference) self.activity = get_object_or_404(Activity, pk=kwargs.get("pk"), conference=self.conference) user = request.user @@ -1938,6 +1939,7 @@ class TravelGrantManageListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/travel_grant_list.html" required_permission = "program_programs.view_travel_grant" + required_feature = "travel_grants" context_object_name = "grants" paginate_by = 50 @@ -2025,6 +2027,7 @@ class TravelGrantReviewView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/travel_grant_edit.html" required_permission = "program_programs.review_travel_grant" + required_feature = "travel_grants" form_class = TravelGrantForm context_object_name = "grant" @@ -2063,6 +2066,7 @@ class TravelGrantSendMessageView(ManagePermissionMixin, View): """POST-only view for reviewers to send a message on a grant.""" required_permission = "program_programs.review_travel_grant" + required_feature = "travel_grants" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Create a message attached to the grant.""" @@ -2083,6 +2087,7 @@ class TravelGrantDisburseView(ManagePermissionMixin, View): """Mark a travel grant as disbursed.""" required_permission = "program_programs.disburse_travel_grant" + required_feature = "travel_grants" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Record disbursement details and transition the grant status. @@ -2120,6 +2125,7 @@ class ReceiptReviewQueueView(ManagePermissionMixin, View): """Pick a random pending receipt for review.""" required_permission = "program_programs.review_receipt" + required_feature = "travel_grants" def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Redirect to a random pending receipt, or back to the grant list if none.""" @@ -2149,6 +2155,7 @@ class ReceiptReviewDetailView(ManagePermissionMixin, DetailView): template_name = "django_program/manage/receipt_review.html" required_permission = "program_programs.review_receipt" + required_feature = "travel_grants" context_object_name = "receipt" def get_queryset(self) -> QuerySet[Receipt]: @@ -2167,6 +2174,7 @@ class ReceiptApproveView(ManagePermissionMixin, View): """POST-only view to approve a receipt.""" required_permission = "program_programs.review_receipt" + required_feature = "travel_grants" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Mark the receipt as approved by the current user.""" @@ -2183,6 +2191,7 @@ class ReceiptFlagView(ManagePermissionMixin, View): """POST-only view to flag a receipt.""" required_permission = "program_programs.review_receipt" + required_feature = "travel_grants" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Flag the receipt with a reason provided by the reviewer.""" @@ -2207,6 +2216,7 @@ class SyncPretalxView(ManagePermissionMixin, View): """ required_permission = "manage_conference_settings" + required_feature = "pretalx_sync" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Run the Pretalx sync and redirect back to the dashboard. @@ -2280,6 +2290,7 @@ class SyncSponsorsView(ManagePermissionMixin, View): """ required_permission = "manage_conference_settings" + required_feature = "sponsors" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Run the PSF sponsor sync and redirect back to the dashboard. @@ -2314,6 +2325,7 @@ class SyncPretalxStreamView(ManagePermissionMixin, View): """ required_permission = "manage_conference_settings" + required_feature = "pretalx_sync" def post(self, request: HttpRequest, **kwargs: str) -> StreamingHttpResponse: # noqa: ARG002 """Start the streaming sync and return an SSE response.""" @@ -2553,9 +2565,12 @@ def dispatch(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpRespo Raises: PermissionDenied: If the user lacks the required permission. + Http404: If ``manage_ui`` or ``pretalx_sync`` is disabled. """ if not request.user.is_authenticated: return self.handle_no_permission() + require_feature("manage_ui") + require_feature("pretalx_sync") if not (request.user.is_superuser or request.user.has_perm("program_conference.manage_conference_settings")): raise PermissionDenied return super().dispatch(request, *args, **kwargs) @@ -2690,6 +2705,7 @@ class TicketTypeListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/ticket_type_list.html" required_permission = "view_commerce" + required_feature = "registration" context_object_name = "ticket_types" paginate_by = 50 @@ -2758,6 +2774,7 @@ class TicketTypeCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/ticket_type_edit.html" required_permission = "change_commerce" + required_feature = "registration" form_class = TicketTypeForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -2785,6 +2802,7 @@ class TicketTypeEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/ticket_type_edit.html" required_permission = "change_commerce" + required_feature = "registration" form_class = TicketTypeForm context_object_name = "ticket_type" @@ -2813,6 +2831,7 @@ class AddOnListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/addon_list.html" required_permission = "view_commerce" + required_feature = "registration" context_object_name = "addons" paginate_by = 50 @@ -2859,6 +2878,7 @@ class AddOnCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/addon_edit.html" required_permission = "change_commerce" + required_feature = "registration" form_class = AddOnForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -2886,6 +2906,7 @@ class AddOnEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/addon_edit.html" required_permission = "change_commerce" + required_feature = "registration" form_class = AddOnForm context_object_name = "addon" @@ -2917,6 +2938,7 @@ class VoucherListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/voucher_list.html" required_permission = "view_commerce" + required_feature = "registration" context_object_name = "vouchers" paginate_by = 50 @@ -2940,6 +2962,7 @@ class VoucherCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/voucher_edit.html" required_permission = "change_commerce" + required_feature = "registration" form_class = VoucherForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -2972,6 +2995,7 @@ class VoucherEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/voucher_edit.html" required_permission = "change_commerce" + required_feature = "registration" form_class = VoucherForm context_object_name = "voucher" @@ -3007,6 +3031,7 @@ class AttendeeListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/attendee_list.html" required_permission = "view_registration" + required_feature = "registration" context_object_name = "attendees" paginate_by = 50 @@ -3045,6 +3070,7 @@ class AttendeeDetailView(ManagePermissionMixin, DetailView): template_name = "django_program/manage/attendee_detail.html" required_permission = "view_registration" + required_feature = "registration" context_object_name = "attendee" def get_queryset(self) -> QuerySet[Attendee]: @@ -3100,6 +3126,7 @@ class OrderListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/order_list.html" required_permission = "view_registration" + required_feature = "registration" context_object_name = "orders" paginate_by = 50 @@ -3132,6 +3159,7 @@ class OrderDetailView(ManagePermissionMixin, DetailView): template_name = "django_program/manage/order_detail.html" required_permission = "view_registration" + required_feature = "registration" context_object_name = "order" def get_queryset(self) -> QuerySet[Order]: @@ -3164,6 +3192,7 @@ class ManualPaymentView(ManagePermissionMixin, View): """ required_permission = "change_registration" + required_feature = "registration" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Record a manual payment and optionally mark the order as paid. @@ -3291,6 +3320,7 @@ class ConditionListView(ManagePermissionMixin, TemplateView): template_name = "django_program/manage/condition_list.html" required_permission = "view_commerce" + required_feature = "registration" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Build a merged list of all conditions with display metadata.""" @@ -3336,6 +3366,7 @@ class ConditionCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/condition_edit.html" required_permission = "change_commerce" + required_feature = "registration" def setup(self, request: HttpRequest, *args: object, **kwargs: object) -> None: """Resolve the condition type from the URL.""" @@ -3385,6 +3416,7 @@ class ConditionEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/condition_edit.html" required_permission = "change_commerce" + required_feature = "registration" context_object_name = "condition" def setup(self, request: HttpRequest, *args: object, **kwargs: object) -> None: @@ -3439,6 +3471,7 @@ class BadgeTemplateListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/badge_template_list.html" required_permission = "view_badges" + required_feature = "registration" context_object_name = "badge_templates" paginate_by = 50 @@ -3470,6 +3503,7 @@ class BadgeTemplateCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/badge_template_edit.html" required_permission = "change_badges" + required_feature = "registration" form_class = BadgeTemplateForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -3502,6 +3536,7 @@ class BadgeTemplateEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/badge_template_edit.html" required_permission = "change_badges" + required_feature = "registration" form_class = BadgeTemplateForm context_object_name = "badge_template" @@ -3534,6 +3569,7 @@ class BadgeBulkGenerateView(ManagePermissionMixin, View): """ required_permission = "change_badges" + required_feature = "registration" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Trigger bulk badge generation and redirect with a count message. @@ -3583,6 +3619,7 @@ class BadgeListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/badge_list.html" required_permission = "view_badges" + required_feature = "registration" context_object_name = "badges" paginate_by = 50 @@ -3639,6 +3676,7 @@ class BadgeDownloadView(ManagePermissionMixin, View): """Serve a single badge file as a download attachment.""" required_permission = "view_badges" + required_feature = "registration" def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Return the badge file as an attachment. @@ -3673,6 +3711,7 @@ class BadgeBulkDownloadView(ManagePermissionMixin, View): """Generate a ZIP archive of all matching badges and stream it.""" required_permission = "view_badges" + required_feature = "registration" def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Build and return a ZIP of badge files. @@ -3727,6 +3766,7 @@ class BadgePreviewView(ManagePermissionMixin, View): """ required_permission = "view_badges" + required_feature = "registration" def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Render a preview badge inline without saving. diff --git a/src/django_program/manage/views_bulk_purchases.py b/src/django_program/manage/views_bulk_purchases.py index bae0544..4a0cf40 100644 --- a/src/django_program/manage/views_bulk_purchases.py +++ b/src/django_program/manage/views_bulk_purchases.py @@ -31,6 +31,7 @@ class BulkPurchaseListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/bulk_purchase_list.html" required_permission = "view_bulk_purchases" + required_feature = "registration" context_object_name = "bulk_purchases" paginate_by = 50 @@ -60,6 +61,7 @@ class BulkPurchaseDetailView(ManagePermissionMixin, DetailView): template_name = "django_program/manage/bulk_purchase_detail.html" required_permission = "view_bulk_purchases" + required_feature = "registration" context_object_name = "purchase" def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -85,6 +87,7 @@ class BulkPurchaseCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/bulk_purchase_form.html" required_permission = "change_bulk_purchases" + required_feature = "registration" form_class = BulkPurchaseCreateForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -129,6 +132,7 @@ class BulkPurchaseApproveView(ManagePermissionMixin, View): """ required_permission = "change_bulk_purchases" + required_feature = "registration" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Mark the bulk purchase as approved.""" @@ -169,6 +173,7 @@ class BulkPurchaseFulfillView(ManagePermissionMixin, View): """ required_permission = "change_bulk_purchases" + required_feature = "registration" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Generate vouchers for the bulk purchase.""" diff --git a/src/django_program/manage/views_checkin.py b/src/django_program/manage/views_checkin.py index f8a3b34..372c86a 100644 --- a/src/django_program/manage/views_checkin.py +++ b/src/django_program/manage/views_checkin.py @@ -23,6 +23,7 @@ class CheckInScannerView(ManagePermissionMixin, TemplateView): template_name = "django_program/manage/checkin_scanner.html" required_permission = "view_checkin" + required_feature = "registration" def get_context_data(self, **kwargs: object) -> dict[str, object]: """Add active_nav to the template context. @@ -48,6 +49,7 @@ class CheckInDashboardView(ManagePermissionMixin, TemplateView): template_name = "django_program/manage/checkin_dashboard.html" required_permission = "view_checkin" + required_feature = "registration" 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 0c41185..1a7f19a 100644 --- a/src/django_program/manage/views_financial.py +++ b/src/django_program/manage/views_financial.py @@ -233,6 +233,7 @@ class FinancialDashboardView(ConferencePermissionMixin, TemplateView): template_name = "django_program/manage/financial_dashboard.html" required_permission = "view_finance" + required_feature = "registration" 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 b301399..f8d9194 100644 --- a/src/django_program/manage/views_letters.py +++ b/src/django_program/manage/views_letters.py @@ -32,6 +32,7 @@ class LetterRequestListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/letter_request_list.html" required_permission = "view_registration" + required_feature = "visa_letters" context_object_name = "letter_requests" paginate_by = 50 @@ -83,6 +84,7 @@ class LetterRequestReviewView(ManagePermissionMixin, DetailView): template_name = "django_program/manage/letter_request_review.html" required_permission = "change_registration" + required_feature = "visa_letters" context_object_name = "letter_request" def get_queryset(self) -> QuerySet[LetterRequest]: @@ -195,6 +197,7 @@ class LetterRequestGenerateView(ManagePermissionMixin, View): """ required_permission = "change_registration" + required_feature = "visa_letters" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Generate the PDF for an approved letter request. @@ -235,6 +238,7 @@ class LetterRequestBulkGenerateView(ManagePermissionMixin, View): """ required_permission = "change_registration" + required_feature = "visa_letters" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Generate PDFs for all approved letter requests in the conference. @@ -275,6 +279,7 @@ class LetterRequestSendView(ManagePermissionMixin, View): """ required_permission = "change_registration" + required_feature = "visa_letters" def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: """Send the invitation letter for a generated request. @@ -315,6 +320,7 @@ class LetterRequestDownloadView(ManagePermissionMixin, View): """ required_permission = "change_registration" + required_feature = "visa_letters" 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 dc59e5f..35a5ea2 100644 --- a/src/django_program/manage/views_overrides.py +++ b/src/django_program/manage/views_overrides.py @@ -33,6 +33,7 @@ class TalkOverrideListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/override_list.html" required_permission = "view_overrides" + required_feature = "pretalx_sync" context_object_name = "overrides" paginate_by = 50 @@ -57,6 +58,7 @@ class TalkOverrideCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/override_form.html" required_permission = "change_overrides" + required_feature = "pretalx_sync" form_class = TalkOverrideForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -99,6 +101,7 @@ class TalkOverrideEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/override_form.html" required_permission = "change_overrides" + required_feature = "pretalx_sync" form_class = TalkOverrideForm def get_queryset(self) -> QuerySet[TalkOverride]: @@ -146,6 +149,7 @@ class SpeakerOverrideListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/speaker_override_list.html" required_permission = "view_overrides" + required_feature = "pretalx_sync" context_object_name = "overrides" paginate_by = 50 @@ -170,6 +174,7 @@ class SpeakerOverrideCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/speaker_override_form.html" required_permission = "change_overrides" + required_feature = "pretalx_sync" form_class = SpeakerOverrideForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -212,6 +217,7 @@ class SpeakerOverrideEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/speaker_override_form.html" required_permission = "change_overrides" + required_feature = "pretalx_sync" form_class = SpeakerOverrideForm def get_queryset(self) -> QuerySet[SpeakerOverride]: @@ -259,6 +265,7 @@ class RoomOverrideListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/room_override_list.html" required_permission = "view_overrides" + required_feature = "pretalx_sync" context_object_name = "overrides" paginate_by = 50 @@ -283,6 +290,7 @@ class RoomOverrideCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/room_override_form.html" required_permission = "change_overrides" + required_feature = "pretalx_sync" form_class = RoomOverrideForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -325,6 +333,7 @@ class RoomOverrideEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/room_override_form.html" required_permission = "change_overrides" + required_feature = "pretalx_sync" form_class = RoomOverrideForm def get_queryset(self) -> QuerySet[RoomOverride]: @@ -372,6 +381,7 @@ class SponsorOverrideListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/sponsor_override_list.html" required_permission = "view_overrides" + required_feature = "sponsors" context_object_name = "overrides" paginate_by = 50 @@ -396,6 +406,7 @@ class SponsorOverrideCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/sponsor_override_form.html" required_permission = "change_overrides" + required_feature = "sponsors" form_class = SponsorOverrideForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -438,6 +449,7 @@ class SponsorOverrideEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/sponsor_override_form.html" required_permission = "change_overrides" + required_feature = "sponsors" form_class = SponsorOverrideForm def get_queryset(self) -> QuerySet[SponsorOverride]: @@ -485,6 +497,7 @@ class SubmissionTypeDefaultListView(ManagePermissionMixin, ListView): template_name = "django_program/manage/submission_type_default_list.html" required_permission = "view_overrides" + required_feature = "pretalx_sync" context_object_name = "type_defaults" paginate_by = 50 @@ -509,6 +522,7 @@ class SubmissionTypeDefaultCreateView(ManagePermissionMixin, CreateView): template_name = "django_program/manage/submission_type_default_form.html" required_permission = "change_overrides" + required_feature = "pretalx_sync" form_class = SubmissionTypeDefaultForm def get_context_data(self, **kwargs: object) -> dict[str, object]: @@ -541,6 +555,7 @@ class SubmissionTypeDefaultEditView(ManagePermissionMixin, UpdateView): template_name = "django_program/manage/submission_type_default_form.html" required_permission = "change_overrides" + required_feature = "pretalx_sync" form_class = SubmissionTypeDefaultForm def get_queryset(self) -> QuerySet[SubmissionTypeDefault]: diff --git a/src/django_program/manage/views_purchase_orders.py b/src/django_program/manage/views_purchase_orders.py index 33f430f..25628de 100644 --- a/src/django_program/manage/views_purchase_orders.py +++ b/src/django_program/manage/views_purchase_orders.py @@ -90,6 +90,7 @@ class PurchaseOrderListView(ManagePermissionMixin, ListView): parameter. """ + required_feature = "registration" template_name = "django_program/manage/purchase_order_list.html" context_object_name = "purchase_orders" paginate_by = 50 @@ -125,6 +126,7 @@ def get_queryset(self) -> QuerySet[PurchaseOrder]: class PurchaseOrderDetailView(ManagePermissionMixin, DetailView): """Display full details of a purchase order with payments and credit notes.""" + required_feature = "registration" template_name = "django_program/manage/purchase_order_detail.html" context_object_name = "purchase_order" @@ -152,6 +154,8 @@ class PurchaseOrderCreateView(ManagePermissionMixin, View): via the service layer, then redirects to the detail view. """ + required_feature = "registration" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Render the blank purchase order form.""" return TemplateResponse( @@ -237,6 +241,8 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR class PurchaseOrderRecordPaymentView(ManagePermissionMixin, View): """Record a payment against a purchase order (POST-only).""" + required_feature = "registration" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Validate and record a payment.""" po = get_object_or_404(PurchaseOrder, pk=self.kwargs["pk"], conference=self.conference) @@ -295,6 +301,8 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR class PurchaseOrderIssueCreditView(ManagePermissionMixin, View): """Issue a credit note against a purchase order (POST-only).""" + required_feature = "registration" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Validate and issue a credit note.""" po = get_object_or_404(PurchaseOrder, pk=self.kwargs["pk"], conference=self.conference) @@ -336,6 +344,8 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR class PurchaseOrderCancelView(ManagePermissionMixin, View): """Cancel a purchase order (POST-only).""" + required_feature = "registration" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Cancel the purchase order.""" po = get_object_or_404(PurchaseOrder, pk=self.kwargs["pk"], conference=self.conference) @@ -362,6 +372,8 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR class PurchaseOrderSendView(ManagePermissionMixin, View): """Mark a draft purchase order as sent (POST-only).""" + required_feature = "registration" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Transition the PO from draft to sent.""" po = get_object_or_404(PurchaseOrder, pk=self.kwargs["pk"], conference=self.conference) @@ -387,6 +399,8 @@ def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: AR class PurchaseOrderInvoiceView(ManagePermissionMixin, View): """Generate and download a PO invoice as PDF.""" + required_feature = "registration" + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Generate the invoice PDF and return it as a downloadable attachment.""" po = get_object_or_404(PurchaseOrder, pk=self.kwargs["pk"], conference=self.conference) @@ -404,6 +418,8 @@ class PurchaseOrderStripeInvoiceView(ManagePermissionMixin, View): customer can pay via card or ACH. Sets the PO status to SENT on success. """ + required_feature = "registration" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Create a Stripe invoice and redirect back to the PO detail page.""" po = get_object_or_404(PurchaseOrder, pk=self.kwargs["pk"], conference=self.conference) @@ -448,6 +464,8 @@ class PurchaseOrderQBOInvoiceView(ManagePermissionMixin, View): to SENT on success if currently in draft. """ + required_feature = "registration" + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 """Create a QBO invoice and redirect back to the PO detail page.""" po = get_object_or_404(PurchaseOrder, pk=self.kwargs["pk"], conference=self.conference) diff --git a/src/django_program/manage/views_terminal.py b/src/django_program/manage/views_terminal.py index 980bde3..6d9ed8f 100644 --- a/src/django_program/manage/views_terminal.py +++ b/src/django_program/manage/views_terminal.py @@ -19,6 +19,7 @@ class TerminalPOSView(ManagePermissionMixin, TemplateView): template_name = "django_program/manage/terminal_pos.html" required_permission = "use_terminal" + required_feature = "registration" 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 b08f01c..1c75e48 100644 --- a/src/django_program/manage/views_vouchers.py +++ b/src/django_program/manage/views_vouchers.py @@ -29,6 +29,7 @@ class VoucherBulkGenerateView(ManagePermissionMixin, FormView): template_name = "django_program/manage/voucher_bulk_generate.html" required_permission = "change_commerce" + required_feature = "registration" form_class = VoucherBulkGenerateForm def get_context_data(self, **kwargs: object) -> dict[str, object]: diff --git a/src/django_program/registration/templatetags/registration_tags.py b/src/django_program/registration/templatetags/program_registration_tags.py similarity index 96% rename from src/django_program/registration/templatetags/registration_tags.py rename to src/django_program/registration/templatetags/program_registration_tags.py index 492805e..6b54f59 100644 --- a/src/django_program/registration/templatetags/registration_tags.py +++ b/src/django_program/registration/templatetags/program_registration_tags.py @@ -23,7 +23,7 @@ def cart_total(cart: Cart) -> Decimal: Usage in templates:: - {% load registration_tags %} + {% load program_registration_tags %} {% cart_total cart as total %}

Total: {{ total }}

@@ -48,7 +48,7 @@ def format_currency(amount: Decimal | None, currency: str = "USD") -> str: Usage in templates:: - {% load registration_tags %} + {% load program_registration_tags %} {{ amount|format_currency }} {{ amount|format_currency:"JPY" }} @@ -96,7 +96,7 @@ def ticket_availability(ticket_type: TicketType) -> str: Usage in templates:: - {% load registration_tags %} + {% load program_registration_tags %} {% ticket_availability ticket_type as status %} {{ status }} diff --git a/src/django_program/registration/templatetags/stripe_tags.py b/src/django_program/registration/templatetags/program_stripe_tags.py similarity index 95% rename from src/django_program/registration/templatetags/stripe_tags.py rename to src/django_program/registration/templatetags/program_stripe_tags.py index eadab5e..77f2ea7 100644 --- a/src/django_program/registration/templatetags/stripe_tags.py +++ b/src/django_program/registration/templatetags/program_stripe_tags.py @@ -13,7 +13,7 @@ def stripe_public_key(conference: Conference) -> str: Usage in templates:: - {% load stripe_tags %} + {% load program_stripe_tags %} {% stripe_public_key conference as stripe_key %} @@ -129,6 +129,7 @@

Analytics & KPIs

+ {% if sponsors_enabled %}
Analytics & KPIs Sponsor Analytics →
+ {% endif %} @@ -145,8 +147,8 @@

Analytics & KPIs

{% include "django_program/manage/charts/_cart_funnel.html" with chart_data=chart_cart_funnel_json %} {% include "django_program/manage/charts/_room_utilization.html" with chart_data=chart_room_utilization_json %} - {% include "django_program/manage/charts/_activity_capacity.html" with chart_data=chart_activity_capacity_json %} - {% include "django_program/manage/charts/_grant_status.html" with chart_data=chart_grant_status_json %} + {% if programs_enabled %}{% include "django_program/manage/charts/_activity_capacity.html" with chart_data=chart_activity_capacity_json %}{% endif %} + {% if travel_grants_enabled %}{% include "django_program/manage/charts/_grant_status.html" with chart_data=chart_grant_status_json %}{% endif %} {% include "django_program/manage/charts/_content_analytics.html" with chart_data=chart_content_json %} {% include "django_program/manage/charts/_revenue_breakdown.html" with chart_data=chart_revenue_breakdown_json %}
@@ -192,6 +194,7 @@

Ticket Sales Ratio

{% endif %} + {% if sponsors_enabled %}

Sponsor Benefit Fulfillment

@@ -232,6 +235,7 @@

Sponsor Benefit Fulfillment {% endif %}

+ {% endif %}