From cc657ff14d46003751f068154366a644b76925ce Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Tue, 2 Jun 2026 16:36:29 +0200 Subject: [PATCH] feat(admin): new Django admin dashboard landing page Replace the stock admin index (alphabetical app/model table) with an Astro/React dashboard rendered via the existing custom-admin overlay. Backend: - custom_admin/index.py overrides admin.site.index, bucketing registered models into 9 workflow groups (+ Other catch-all so nothing is dropped), builds quick-action links (schedule builder, grants, submissions), and renders astro/landing.html with JSON-safe context. - tests cover grouping, exhaustive coverage, catch-all, link resolution and JSON serializability. Frontend (custom_admin Astro app): - pages/landing.astro + components/landing/* (root, dashboard, group cards, quick actions, placeholder stat cards, collapsible all-models fallback). Stats values are placeholders; wiring real data via /admin/graphql is a follow-up. Spec in specs/django-admin-landing-page.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/custom_admin/admin.py | 3 + backend/custom_admin/index.py | 252 ++++++++++++++++++ .../src/components/landing/all-models.tsx | 46 ++++ .../src/components/landing/dashboard.tsx | 55 ++++ .../src/components/landing/group-card.tsx | 45 ++++ .../src/components/landing/quick-actions.tsx | 27 ++ .../src/components/landing/root.tsx | 25 ++ .../src/components/landing/stat-card.tsx | 19 ++ .../src/components/landing/types.ts | 26 ++ backend/custom_admin/src/pages/landing.astro | 18 ++ backend/custom_admin/tests/test_index.py | 83 ++++++ specs/django-admin-landing-page.md | 242 +++++++++++++++++ 12 files changed, 841 insertions(+) create mode 100644 backend/custom_admin/index.py create mode 100644 backend/custom_admin/src/components/landing/all-models.tsx create mode 100644 backend/custom_admin/src/components/landing/dashboard.tsx create mode 100644 backend/custom_admin/src/components/landing/group-card.tsx create mode 100644 backend/custom_admin/src/components/landing/quick-actions.tsx create mode 100644 backend/custom_admin/src/components/landing/root.tsx create mode 100644 backend/custom_admin/src/components/landing/stat-card.tsx create mode 100644 backend/custom_admin/src/components/landing/types.ts create mode 100644 backend/custom_admin/src/pages/landing.astro create mode 100644 backend/custom_admin/tests/test_index.py create mode 100644 specs/django-admin-landing-page.md diff --git a/backend/custom_admin/admin.py b/backend/custom_admin/admin.py index a832dc40ad..4392486da1 100644 --- a/backend/custom_admin/admin.py +++ b/backend/custom_admin/admin.py @@ -7,12 +7,15 @@ from django.urls import path from custom_admin.audit import create_change_admin_log_entry +from custom_admin.index import install as install_custom_index SITE_NAME = "PyCon Italia" admin.site.site_header = SITE_NAME admin.site.site_title = SITE_NAME +install_custom_index() + class CustomIndexLinks(admin.ModelAdmin): def get_index_links(self) -> list: diff --git a/backend/custom_admin/index.py b/backend/custom_admin/index.py new file mode 100644 index 0000000000..c9f78267f0 --- /dev/null +++ b/backend/custom_admin/index.py @@ -0,0 +1,252 @@ +"""Custom Django admin index (landing page). + +Overrides ``admin.site.index`` to render a dashboard that organizes the +registered models into workflow groups and exposes a few quick-action +shortcuts, instead of the stock alphabetical app/model table. + +The grouping is driven by ``GROUPS`` below (keyed by model ``object_name``). +Any registered model not listed falls through to an "Other" group, so no model +is ever silently dropped -- a test enforces this. +""" + +from django.contrib import admin +from django.template.response import TemplateResponse +from django.urls import NoReverseMatch, reverse + +INDEX_TEMPLATE = "astro/landing.html" + +# Ordered workflow groups. Keys are group titles; values are the model +# ``object_name``s that belong to each group. Order here is the display order. +GROUPS: dict[str, list[str]] = { + "Program": [ + "Submission", + "SubmissionType", + "SubmissionTag", + "SubmissionComment", + "SubmissionConfirmPendingStatusProxy", + "Vote", + "RankSubmission", + "RankRequest", + "RankStat", + "UserReview", + "ReviewSession", + "Keynote", + "Event", + ], + "Schedule & Video": [ + "ScheduleItem", + "ScheduleItemInvitation", + "Room", + "Day", + "ScheduleItemSentForVideoUpload", + "WetransferToS3TransferRequest", + ], + "Finance": [ + "Grant", + "GrantReimbursement", + "GrantReimbursementCategory", + "GrantConfirmPendingStatusProxy", + "Invoice", + "Sender", + "Address", + "Item", + "BillingAddress", + "PretixPayment", + "StripeSubscriptionPayment", + "Membership", + ], + "Sponsors": [ + "Sponsor", + "SponsorBenefit", + "SponsorLevel", + "SponsorSpecialOption", + "SponsorLead", + ], + "People": [ + "User", + "Participant", + "AttendeeConferenceRole", + "BadgeScan", + "InvitationLetterRequest", + "InvitationLetterConferenceConfig", + "Organizer", + "Notification", + "VolunteerDevice", + ], + "Conference setup": [ + "Conference", + "Topic", + "AudienceLevel", + "Deadline", + "ConferenceVoucher", + "ChecklistItem", + "Language", + ], + "Content & CMS": [ + "Post", + "Page", + "GenericCopy", + "FAQ", + "Menu", + "MenuLink", + "JobListing", + "Subscription", + ], + "Comms": [ + "EmailTemplate", + "SentEmail", + ], + "System": [ + "APIToken", + "GoogleCloudOAuthCredential", + "File", + ], +} + +OTHER_GROUP = "Other" + + +def _model_to_group() -> dict[str, str]: + """Reverse map: model object_name -> group title.""" + mapping = {} + for group, object_names in GROUPS.items(): + for object_name in object_names: + mapping[object_name] = group + return mapping + + +def _clean_model(model: dict, app: dict) -> dict: + """Produce a JSON-serializable model entry from Django's app_list dict. + + Django's raw model dict carries the model class and lazy strings, neither of + which survive json.dumps -- so pick only the fields the frontend needs and + coerce names to plain strings. + """ + return { + "name": str(model["name"]), + "object_name": model["object_name"], + "admin_url": model.get("admin_url"), + "add_url": model.get("add_url"), + "view_only": model.get("view_only", False), + "app_label": app["app_label"], + "app_name": str(app["name"]), + } + + +def build_groups(app_list: list[dict]) -> list[dict]: + """Bucket the models from Django's ``app_list`` into workflow groups. + + Returns an ordered list of ``{"title", "models"}`` dicts with serializable + model entries. Empty groups are omitted. Any model whose ``object_name`` + isn't mapped lands in the Other group, so nothing is silently dropped. + """ + model_to_group = _model_to_group() + buckets: dict[str, list[dict]] = {title: [] for title in GROUPS} + buckets[OTHER_GROUP] = [] + + for app in app_list: + for model in app["models"]: + group = model_to_group.get(model["object_name"], OTHER_GROUP) + buckets[group].append(_clean_model(model, app)) + + ordered_titles = [*GROUPS.keys(), OTHER_GROUP] + return [ + {"title": title, "models": buckets[title]} + for title in ordered_titles + if buckets[title] + ] + + +def build_all_apps(app_list: list[dict]) -> list[dict]: + """Serializable copy of Django's full app/model list (the fallback section).""" + return [ + { + "app_label": app["app_label"], + "name": str(app["name"]), + "models": [_clean_model(model, app) for model in app["models"]], + } + for app in app_list + ] + + +def build_quick_links(request) -> list[dict]: + """Curated shortcuts to common daily tasks. + + Each link is ``{"title", "description", "url"}``. Links whose target can't + be resolved (e.g. a feature not wired in this deploy) are skipped. + """ + links = [] + + # Schedule builder is per-conference; point at the most recent conference, + # falling back to the conference changelist. + schedule_url = _latest_schedule_builder_url() or _safe_reverse( + "admin:conferences_conference_changelist" + ) + if schedule_url: + links.append( + { + "title": "Schedule builder", + "description": "Build the conference schedule", + "url": schedule_url, + } + ) + + grants_url = _safe_reverse("admin:grants_grant_changelist") + if grants_url: + links.append( + { + "title": "Review grants", + "description": "Review and update grant requests", + "url": grants_url, + } + ) + + submissions_url = _safe_reverse("admin:submissions_submission_changelist") + if submissions_url: + links.append( + { + "title": "Review submissions", + "description": "Review proposed talks", + "url": submissions_url, + } + ) + + return links + + +def _safe_reverse(viewname, **kwargs): + try: + return reverse(viewname, **kwargs) + except NoReverseMatch: + return None + + +def _latest_schedule_builder_url(): + from conferences.models import Conference + + conference = Conference.objects.order_by("-start").first() + if conference is None: + return None + return _safe_reverse("admin:schedule_builder", kwargs={"object_id": conference.pk}) + + +def custom_index(request, extra_context=None): + """Render the dashboard landing page in place of the stock admin index.""" + app_list = admin.site.get_app_list(request) + context = { + **admin.site.each_context(request), + "title": admin.site.index_title, + "app_list": app_list, + "groups": build_groups(app_list), + "all_apps": build_all_apps(app_list), + "quick_links": build_quick_links(request), + "breadcrumbs": [], + **(extra_context or {}), + } + request.current_app = admin.site.name + return TemplateResponse(request, INDEX_TEMPLATE, context) + + +def install(): + """Replace the default admin site's index view with ``custom_index``.""" + admin.site.index = custom_index diff --git a/backend/custom_admin/src/components/landing/all-models.tsx b/backend/custom_admin/src/components/landing/all-models.tsx new file mode 100644 index 0000000000..2181e7f785 --- /dev/null +++ b/backend/custom_admin/src/components/landing/all-models.tsx @@ -0,0 +1,46 @@ +import type { AdminApp } from "./types"; + +type Props = { + apps: AdminApp[]; +}; + +// Full stock app/model list, collapsed by default, for completeness and to keep +// rarely-used models reachable even when they aren't featured in a group. +export const AllModels = ({ apps }: Props) => { + if (apps.length === 0) { + return null; + } + + return ( +
+ + All models + +
+ {apps.map((app) => ( +
+
+ {app.name} +
+
    + {app.models.map((model) => ( +
  • + {model.admin_url ? ( + + {model.name} + + ) : ( + {model.name} + )} +
  • + ))} +
+
+ ))} +
+
+ ); +}; diff --git a/backend/custom_admin/src/components/landing/dashboard.tsx b/backend/custom_admin/src/components/landing/dashboard.tsx new file mode 100644 index 0000000000..fe47347161 --- /dev/null +++ b/backend/custom_admin/src/components/landing/dashboard.tsx @@ -0,0 +1,55 @@ +import { useArgs } from "../shared/args"; +import { AllModels } from "./all-models"; +import { GroupCard } from "./group-card"; +import { QuickActions } from "./quick-actions"; +import { StatCard } from "./stat-card"; +import type { AdminApp, Group, QuickLink } from "./types"; + +// Placeholder metrics. Real values are wired in a follow-up (T5) via the +// /admin/graphql endpoint; the layout is sized for them now. +const PLACEHOLDER_STATS = [ + { label: "Submissions" }, + { label: "Grants pending" }, + { label: "Schedule items" }, + { label: "Tickets sold" }, +]; + +export const Dashboard = () => { + const { + groups = [], + quickLinks = [], + allApps = [], + } = useArgs() as { + groups: Group[]; + quickLinks: QuickLink[]; + allApps: AdminApp[]; + }; + + return ( +
+
+

Overview

+
+ {PLACEHOLDER_STATS.map((stat) => ( + + ))} +
+
+ + {quickLinks.length > 0 && } + +
+

+ Manage by area +

+
+ {groups.map((group) => ( + + ))} +
+
+ + +
+ ); +}; diff --git a/backend/custom_admin/src/components/landing/group-card.tsx b/backend/custom_admin/src/components/landing/group-card.tsx new file mode 100644 index 0000000000..fdc9d1c856 --- /dev/null +++ b/backend/custom_admin/src/components/landing/group-card.tsx @@ -0,0 +1,45 @@ +import type { AdminModel, Group } from "./types"; + +const ModelRow = ({ model }: { model: AdminModel }) => { + return ( +
  • + {model.admin_url ? ( + + {model.name} + + ) : ( + {model.name} + )} + {model.add_url && ( + + + Add + + )} +
  • + ); +}; + +export const GroupCard = ({ group }: { group: Group }) => { + return ( +
    +
    + {group.title} +
    +
      + {group.models.map((model) => ( + + ))} +
    +
    + ); +}; diff --git a/backend/custom_admin/src/components/landing/quick-actions.tsx b/backend/custom_admin/src/components/landing/quick-actions.tsx new file mode 100644 index 0000000000..098f0db33e --- /dev/null +++ b/backend/custom_admin/src/components/landing/quick-actions.tsx @@ -0,0 +1,27 @@ +import type { QuickLink } from "./types"; + +type Props = { + links: QuickLink[]; +}; + +export const QuickActions = ({ links }: Props) => { + return ( +
    +

    + Quick actions +

    +
    + {links.map((link) => ( + +
    {link.title}
    +
    {link.description}
    +
    + ))} +
    +
    + ); +}; diff --git a/backend/custom_admin/src/components/landing/root.tsx b/backend/custom_admin/src/components/landing/root.tsx new file mode 100644 index 0000000000..b3f5714c81 --- /dev/null +++ b/backend/custom_admin/src/components/landing/root.tsx @@ -0,0 +1,25 @@ +import { Base } from "../shared/base"; +import { DjangoAdminLayout } from "../shared/django-admin-layout"; +import { Dashboard } from "./dashboard"; + +type Props = { + groups: string; + quickLinks: string; + allApps: string; + breadcrumbs: string; +}; + +export const LandingRoot = ({ + groups, + quickLinks, + allApps, + breadcrumbs, +}: Props) => { + return ( + + + + + + ); +}; diff --git a/backend/custom_admin/src/components/landing/stat-card.tsx b/backend/custom_admin/src/components/landing/stat-card.tsx new file mode 100644 index 0000000000..b68faf9f17 --- /dev/null +++ b/backend/custom_admin/src/components/landing/stat-card.tsx @@ -0,0 +1,19 @@ +type Props = { + label: string; + // Optional value; until stats are wired (T5) this stays undefined and the + // card shows a placeholder dash. + value?: number | string; +}; + +export const StatCard = ({ label, value }: Props) => { + return ( +
    +
    + {value ?? "—"} +
    +
    + {label} +
    +
    + ); +}; diff --git a/backend/custom_admin/src/components/landing/types.ts b/backend/custom_admin/src/components/landing/types.ts new file mode 100644 index 0000000000..04b839efaa --- /dev/null +++ b/backend/custom_admin/src/components/landing/types.ts @@ -0,0 +1,26 @@ +export type AdminModel = { + name: string; + object_name: string; + admin_url: string | null; + add_url: string | null; + view_only: boolean; + app_label: string; + app_name: string; +}; + +export type Group = { + title: string; + models: AdminModel[]; +}; + +export type AdminApp = { + app_label: string; + name: string; + models: AdminModel[]; +}; + +export type QuickLink = { + title: string; + description: string; + url: string; +}; diff --git a/backend/custom_admin/src/pages/landing.astro b/backend/custom_admin/src/pages/landing.astro new file mode 100644 index 0000000000..3157a4a3c8 --- /dev/null +++ b/backend/custom_admin/src/pages/landing.astro @@ -0,0 +1,18 @@ +--- +import "../custom-styles.css"; +import { LandingRoot } from "../components/landing/root"; +--- + + + PyCon Italia Admin + + + + + + diff --git a/backend/custom_admin/tests/test_index.py b/backend/custom_admin/tests/test_index.py new file mode 100644 index 0000000000..23d46c5c3e --- /dev/null +++ b/backend/custom_admin/tests/test_index.py @@ -0,0 +1,83 @@ +import pytest +from django.test import RequestFactory + +from custom_admin.index import OTHER_GROUP, build_groups, custom_index + + +@pytest.fixture +def admin_request(admin_user): + # The project's admin_user is staff-only; make it a superuser so it sees + # every registered model in get_app_list. + admin_user.is_superuser = True + admin_user.save() + request = RequestFactory().get("/admin/") + request.user = admin_user + return request + + +def test_index_returns_groups_and_quick_links(admin_request): + # Inspect context_data directly so we don't render the Astro admin base, + # which isn't available in the test environment. + response = custom_index(admin_request) + + assert "groups" in response.context_data + assert "quick_links" in response.context_data + assert len(response.context_data["groups"]) > 0 + + +def test_every_registered_model_appears_exactly_once(admin_request): + response = custom_index(admin_request) + + app_models = [ + model["object_name"] + for app in response.context_data["app_list"] + for model in app["models"] + ] + grouped_models = [ + model["object_name"] + for group in response.context_data["groups"] + for model in group["models"] + ] + + # No model dropped, none duplicated across groups. + assert sorted(grouped_models) == sorted(app_models) + + +def test_unmapped_model_falls_through_to_other(): + fake_app_list = [ + { + "app_label": "mystery", + "name": "Mystery", + "models": [ + {"object_name": "SomethingBrandNew", "name": "Something brand new"} + ], + } + ] + + groups = build_groups(fake_app_list) + + assert len(groups) == 1 + assert groups[0]["title"] == OTHER_GROUP + assert groups[0]["models"][0]["object_name"] == "SomethingBrandNew" + + +def test_context_is_json_serializable_for_astro_props(admin_request): + # The Astro page passes these through the `to_json_for_prop` filter, so they + # must survive json serialization (no model classes / lazy strings leaking). + from custom_admin.templatetags.to_json_for_prop import to_json_for_prop + + response = custom_index(admin_request) + + for key in ("groups", "all_apps", "quick_links", "breadcrumbs"): + # Raises if the value isn't serializable. + assert to_json_for_prop(response.context_data[key]) is not None + + +def test_quick_links_resolve_to_urls(admin_request): + response = custom_index(admin_request) + + quick_links = response.context_data["quick_links"] + titles = {link["title"] for link in quick_links} + + assert {"Review grants", "Review submissions"} <= titles + assert all(link["url"] for link in quick_links) diff --git a/specs/django-admin-landing-page.md b/specs/django-admin-landing-page.md new file mode 100644 index 0000000000..f56ea170da --- /dev/null +++ b/specs/django-admin-landing-page.md @@ -0,0 +1,242 @@ +# Spec: New Django Admin Landing Page + +Status: **IMPLEMENTING — T1–T4 done (tests+lint pass), T5 deferred, browser +verification pending** +Author: Claude + Marco +Date: 2026-06-02 + +## Objective + +Replace the stock Django admin index (`/admin/`) — currently a plain table +list of apps/models (`custom_admin/templates/admin/index.html`) — with a +modern **dashboard landing page** rendered as an Astro/React overlay (the +existing custom-admin pattern). + +Users: PyCon Italia staff/organizers using the Django admin daily. + +Success = on visiting `/admin/`, staff see a redesigned dashboard that: +1. **Visual redesign** — card/grid layout instead of stock app/model table. +2. **Dashboard stats** — widget slots for live metrics (submissions, grants + pending, tickets, etc.). *Data wiring deferred* — placeholders/empty state + this phase, layout must accommodate real numbers later. +3. **Quick actions / links** — curated shortcuts to common tasks (e.g. review + grants, schedule builder, document builders). +4. **Reorganize apps** — group/prioritize apps & models by workflow, not raw + alphabetical `app_list`. + +Out of scope: Wagtail admin (`/cms-admin/`), changing per-model admin pages, +actual stats data sources (separate follow-up). + +## Tech Stack + +- Backend: Django 5.2.8, plain `django.contrib.admin` +- Overlay: Astro (SSR dev, port 3002) + React 18 + Apollo Client + Tailwind + + Radix UI — the `custom_admin/` Astro app +- Data (later): existing `/admin/graphql` Strawberry endpoint + (`api/schema.py`), client-side via Apollo + GraphQL codegen + +## Commands + +``` +# Backend (must run in docker) +docker exec pycon-backend-1 uv run pytest -l -s -vvv +docker exec pycon-backend-1 uv run ruff check +docker exec pycon-backend-1 uv run ruff format + +# Astro custom-admin (inside backend/custom_admin) +pnpm dev # codegen:watch + ws-proxy + astro dev :3002 +pnpm codegen # regenerate typed GraphQL hooks +pnpm build # production build + +# Full stack +docker-compose up +``` + +## Project Structure (new/changed files) + +``` +backend/custom_admin/ + src/pages/landing.astro → new full-page Astro entry (Approach B) + src/components/landing/ + root.tsx → React root (Apollo Base wrapper) + dashboard.tsx → card grid + sections layout + quick-actions.tsx → curated shortcut cards + stat-card.tsx → reusable metric widget (placeholder) + landing.graphql → stats queries (added when data wired) + templates/admin/index.html → MODIFIED (Approach A) OR unused (B) + admin.py / new AdminSite → MODIFIED if Approach B +specs/django-admin-landing-page.md → this spec +``` + +## Code Style + +Match existing custom-admin React. Example (existing `schedule-builder/root.tsx`): + +```tsx +export const LandingRoot = ({ appList, quickLinks }: Props) => { + return ( + + + }> + + + + + ); +}; +``` + +- Astro page receives Django context as `{{ var }}`, injects as React props + (`client:only`); complex objects via `| to_json_for_prop`. +- Tailwind utility classes; Radix UI for primitives. +- Python: ruff format, existing admin conventions. + +## Testing Strategy + +- **Python**: pytest. Test the index view returns 200 + correct template/context + (app grouping, quick links). Located in `backend/custom_admin/tests/`. +- **Frontend**: existing Astro app has minimal tests; add light component render + checks only if a test setup exists (verify before assuming). +- **Manual**: `docker-compose up`, visit `/admin/`, confirm dashboard renders in + dev (DEBUG=True proxy) — and that `pnpm build` produces prod assets (prod proxy + returns 404, served from build). + +## Boundaries + +- **Always**: run ruff + pytest before commit; match existing overlay pattern; + keep `/admin/` working for users without overlay JS (graceful base list). +- **Ask first**: subclassing/replacing `admin.site` (AdminSite) [Approach B]; + adding npm/uv dependencies; changing `template_backends.py` proxy logic; + touching prod static-build pipeline. +- **Never**: commit secrets; break other proxied pages (schedule-builder etc.); + remove existing `CustomIndexLinks` mechanism without replacement. + +## Decisions (locked 2026-06-02) + +- **Render: Approach B** — full Astro page on `/admin/`. To avoid re-registering + ~65 model admins, **override `admin.site.index` method on the existing default + site** (not a new AdminSite subclass + re-point). The custom index view returns + `TemplateResponse(request, "astro/landing.html", ctx)` with `each_context` + + grouped app data + quick links. +- **Grouping**: Claude proposes workflow groups from real model list → Marco edits. +- **Fallback list**: dashboard on top, full "All models" stock list as a section + below for completeness. + +## Key Decision (RESOLVED — see Decisions above) + +**How to render the Astro UI on the fixed `/admin/` index route:** + +- **Approach A — React island in `index.html`**: Keep Django `index.html` + (extends `base_site.html`, already Astro-proxied). Mount a React widget for + dashboard cards/stats. No custom AdminSite. *Pros*: smallest blast radius, + stock index still works as fallback. *Cons*: less layout control, mixed + Django-template + island. + +- **Approach B — Custom `AdminSite.index()`**: Subclass `AdminSite`, override + `index()` → `TemplateResponse(request, "astro/landing.html", ctx)`, full Astro + page (mirrors schedule-builder). *Pros*: full-page control, cleanest match to + existing pattern. *Cons*: replacing `admin.site` is a global change (must + re-register all ~65 model admins against new site or set `default_site`); + higher risk. + +Recommendation: **A** for first iteration (low risk, ships the redesign + +quick-links + reorg), migrate to **B** later if full-page control needed. + +## Plan (Phase 2) + +### Proposed app/model groups (Marco to confirm/edit) + +1. **Program** — Submission, SubmissionType, SubmissionTag, SubmissionComment, + Vote, RankSubmission, RankRequest, RankStat, UserReview, ReviewSession, + Keynote, Event +2. **Schedule & Video** — ScheduleItem, ScheduleItemInvitation, Room, Day, + ScheduleItemSentForVideoUpload, WetransferToS3TransferRequest +3. **Finance** — Grant, GrantReimbursement, GrantReimbursementCategory, + GrantConfirmPendingStatusProxy, Invoice, Sender, Address, Item, + BillingAddress, PretixPayment, StripeSubscriptionPayment, Membership +4. **Sponsors** — Sponsor, SponsorBenefit, SponsorLevel, SponsorSpecialOption, + SponsorLead +5. **People** — User, Participant, AttendeeConferenceRole, BadgeScan, + InvitationLetterRequest, InvitationLetterConferenceConfig, Organizer, + Notification, VolunteerDevice +6. **Conference setup** — Conference, Topic, AudienceLevel, Deadline, + ConferenceVoucher, ChecklistItem, Language +7. **Content & CMS** — Post, Page, GenericCopy, FAQ, Menu, MenuLink, JobListing, + Subscription (newsletter) +8. **Comms** — EmailTemplate, SentEmail +9. **System** — APIToken, GoogleCloudOAuthCredential, File + +Any model not listed falls through to an "Other" group automatically (no model +silently dropped). + +### Implementation order (dependency-ordered) + +1. Backend: custom index view overriding `admin.site.index`, building grouped + `app_list` + quick-links context → `TemplateResponse("astro/landing.html")`. + Verify with stock template first (no Astro) so backend is provable alone. +2. Astro page `landing.astro` + React `landing/` components (Base/Apollo wrapper, + Dashboard grid, group cards, quick-action cards, stat-card placeholder). +3. "All models" full stock list as a collapsible section below dashboard. +4. Stats: leave `stat-card` placeholders; add `landing.graphql` + wiring in a + follow-up once data sources picked. + +### Risks / mitigation + +- **Overriding `admin.site.index`** could break if Django internals change → + keep view thin, reuse `each_context` + `get_app_list`, cover with a test. +- **Grouping drift** as models added → "Other" catch-all + a test asserting + every registered model lands in exactly one group. +- **Prod proxy** returns 404 for `/astro/*` when DEBUG=False → ensure prod path + uses built assets (same as schedule-builder); verify `pnpm build`. + +## Tasks (Phase 3) + +- [ ] **T1 — Backend custom index view** + - Acceptance: `/admin/` served by custom view; context has grouped app_list + (per groups above + Other catch-all) + quick_links; falls back to stock + template render OK. + - Verify: `pytest backend/custom_admin/tests/test_index.py` (200, groups, + every model present once); manual `/admin/`. + - Files: `custom_admin/admin.py` (or new `custom_admin/index.py`), + `custom_admin/apps.py` (wire override on ready), test file. +- [ ] **T2 — Astro landing page + React shell** + - Acceptance: `landing.astro` renders Dashboard via React island; receives + grouped data + quick links as props. + - Verify: `docker-compose up`, visit `/admin/`, dashboard cards show; `pnpm build`. + - Files: `src/pages/landing.astro`, `src/components/landing/root.tsx`, + `dashboard.tsx`. +- [ ] **T3 — Group cards + quick actions + stat-card placeholders** + - Acceptance: workflow group cards link to model changelists; quick-action + cards link correctly; stat-cards show empty/placeholder state. + - Verify: manual click-through; visual check. + - Files: `src/components/landing/{dashboard,quick-actions,stat-card}.tsx`. +- [ ] **T4 — "All models" fallback section** + - Acceptance: full stock app/model list present below dashboard. + - Verify: manual; rare model (e.g. Language) reachable. + - Files: `dashboard.tsx` (+ template if needed). +- [ ] **T5 — Stats data wiring (follow-up, deferred)** + - Acceptance: stat-cards show real counts via `/admin/graphql`. + - Verify: codegen + query returns numbers. + - Files: `landing.graphql`, `api/...` resolvers, `stat-card.tsx`. + +## Success Criteria + +- [x] `/admin/` served by custom dashboard view (groups/quick_links context). +- [x] Apps/models reorganized into 9 workflow groups + Other catch-all. +- [x] Quick-action shortcuts (schedule builder, grants, submissions) built. +- [x] Stat-card widget slots render with placeholder dash state. +- [x] All-models fallback section (collapsible). +- [x] ruff + pytest pass (5 tests); biome clean on new TS. +- [ ] Browser check: `docker-compose up` shows page; `pnpm build` succeeds. +- [ ] No regression: other admin pages + proxied Astro pages still work (verify + in browser). + +## Open Questions + +1. **Approach A vs B?** (see Key Decision) — recommend A. +2. **Which quick actions** to feature? (need Marco's list of top daily tasks) +3. **App grouping** — what workflow groups? (e.g. "Content", "Program", + "Finance/Grants", "Users", "System"?) +4. Which **stats** to slot in (even as placeholders) so layout is sized right? +5. Should stock app/model list remain accessible (e.g. below dashboard / a + "All models" section) for completeness?