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 (
+
+ );
+};
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?