Template
## Summary - Closes #5 - Optional `shop`, `pos_sync`, `events`, and `shipping` apps gated by `FEATURE_*` flags - Deps match the catalog: shop/events need email + Stripe; POS/shipping need shop - Portal inventory, POS webhooks, capacity tickets, EasyPost/Pirate Ship shipping ## Test plan - [ ] `manage.py test` (152 passed locally) - [ ] Shop cart + paid order decrements stocked inventory - [ ] POS inbound webhook decrements SKU; paid order queues outbound reserve - [ ] Event capacity blocks overbook; paid order emails ticket codes - [ ] Shipping stub label + Pirate Ship CSV of unshipped paid orders - [ ] `validate-env.sh` rejects shop without email/payments, POS/shipping without shop Reviewed-on: #6
107 lines
2.2 KiB
Python
107 lines
2.2 KiB
Python
"""Optional-app registry. Apps register from AppConfig.ready() — never from always-on imports."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable
|
|
|
|
from django.urls import reverse
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NavItem:
|
|
section: str
|
|
label: str
|
|
url_name: str
|
|
group: str = "Overview"
|
|
order: int = 100
|
|
|
|
|
|
GROUP_ORDER = {
|
|
"Overview": 10,
|
|
"Outreach": 20,
|
|
"Content": 30,
|
|
"Social": 40,
|
|
"Retail": 45,
|
|
"Billing": 50,
|
|
}
|
|
|
|
_portal_nav: list[NavItem] = []
|
|
_public_nav: list[NavItem] = []
|
|
_dashboard_collectors: list[Callable] = []
|
|
_dispatchers: list[Callable] = []
|
|
_features: set[str] = set()
|
|
|
|
|
|
def register_feature(name: str) -> None:
|
|
_features.add(name)
|
|
|
|
|
|
def enabled_features() -> frozenset[str]:
|
|
return frozenset(_features)
|
|
|
|
|
|
def register_portal_nav(
|
|
*,
|
|
section: str,
|
|
label: str,
|
|
url_name: str,
|
|
group: str = "Overview",
|
|
order: int = 100,
|
|
) -> None:
|
|
item = NavItem(
|
|
section=section, label=label, url_name=url_name, group=group, order=order
|
|
)
|
|
_portal_nav.append(item)
|
|
|
|
|
|
def register_public_nav(
|
|
*,
|
|
section: str,
|
|
label: str,
|
|
url_name: str,
|
|
order: int = 100,
|
|
) -> None:
|
|
_public_nav.append(
|
|
NavItem(section=section, label=label, url_name=url_name, order=order)
|
|
)
|
|
|
|
|
|
def register_dashboard_collector(fn: Callable) -> None:
|
|
"""fn(request) -> dict merged into dashboard home context."""
|
|
_dashboard_collectors.append(fn)
|
|
|
|
|
|
def register_dispatcher(fn: Callable) -> None:
|
|
"""fn() -> int count of items enqueued. Called by dispatch_due."""
|
|
_dispatchers.append(fn)
|
|
|
|
|
|
def portal_nav() -> list[NavItem]:
|
|
return sorted(
|
|
_portal_nav,
|
|
key=lambda i: (GROUP_ORDER.get(i.group, 99), i.order, i.label),
|
|
)
|
|
|
|
|
|
def public_nav() -> list[NavItem]:
|
|
return sorted(_public_nav, key=lambda i: (i.order, i.label))
|
|
|
|
|
|
def collect_dashboard_context(request) -> dict:
|
|
extra: dict = {}
|
|
for fn in _dashboard_collectors:
|
|
extra.update(fn(request) or {})
|
|
return extra
|
|
|
|
|
|
def run_dispatchers() -> int:
|
|
total = 0
|
|
for fn in _dispatchers:
|
|
total += int(fn() or 0)
|
|
return total
|
|
|
|
|
|
def reverse_nav(item: NavItem) -> str:
|
|
return reverse(item.url_name)
|