Skip to content

Commit cc657ff

Browse files
marcoaciernoclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 5d8f9c3 commit cc657ff

12 files changed

Lines changed: 841 additions & 0 deletions

File tree

backend/custom_admin/admin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77
from django.urls import path
88

99
from custom_admin.audit import create_change_admin_log_entry
10+
from custom_admin.index import install as install_custom_index
1011

1112
SITE_NAME = "PyCon Italia"
1213

1314
admin.site.site_header = SITE_NAME
1415
admin.site.site_title = SITE_NAME
1516

17+
install_custom_index()
18+
1619

1720
class CustomIndexLinks(admin.ModelAdmin):
1821
def get_index_links(self) -> list:

backend/custom_admin/index.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"""Custom Django admin index (landing page).
2+
3+
Overrides ``admin.site.index`` to render a dashboard that organizes the
4+
registered models into workflow groups and exposes a few quick-action
5+
shortcuts, instead of the stock alphabetical app/model table.
6+
7+
The grouping is driven by ``GROUPS`` below (keyed by model ``object_name``).
8+
Any registered model not listed falls through to an "Other" group, so no model
9+
is ever silently dropped -- a test enforces this.
10+
"""
11+
12+
from django.contrib import admin
13+
from django.template.response import TemplateResponse
14+
from django.urls import NoReverseMatch, reverse
15+
16+
INDEX_TEMPLATE = "astro/landing.html"
17+
18+
# Ordered workflow groups. Keys are group titles; values are the model
19+
# ``object_name``s that belong to each group. Order here is the display order.
20+
GROUPS: dict[str, list[str]] = {
21+
"Program": [
22+
"Submission",
23+
"SubmissionType",
24+
"SubmissionTag",
25+
"SubmissionComment",
26+
"SubmissionConfirmPendingStatusProxy",
27+
"Vote",
28+
"RankSubmission",
29+
"RankRequest",
30+
"RankStat",
31+
"UserReview",
32+
"ReviewSession",
33+
"Keynote",
34+
"Event",
35+
],
36+
"Schedule & Video": [
37+
"ScheduleItem",
38+
"ScheduleItemInvitation",
39+
"Room",
40+
"Day",
41+
"ScheduleItemSentForVideoUpload",
42+
"WetransferToS3TransferRequest",
43+
],
44+
"Finance": [
45+
"Grant",
46+
"GrantReimbursement",
47+
"GrantReimbursementCategory",
48+
"GrantConfirmPendingStatusProxy",
49+
"Invoice",
50+
"Sender",
51+
"Address",
52+
"Item",
53+
"BillingAddress",
54+
"PretixPayment",
55+
"StripeSubscriptionPayment",
56+
"Membership",
57+
],
58+
"Sponsors": [
59+
"Sponsor",
60+
"SponsorBenefit",
61+
"SponsorLevel",
62+
"SponsorSpecialOption",
63+
"SponsorLead",
64+
],
65+
"People": [
66+
"User",
67+
"Participant",
68+
"AttendeeConferenceRole",
69+
"BadgeScan",
70+
"InvitationLetterRequest",
71+
"InvitationLetterConferenceConfig",
72+
"Organizer",
73+
"Notification",
74+
"VolunteerDevice",
75+
],
76+
"Conference setup": [
77+
"Conference",
78+
"Topic",
79+
"AudienceLevel",
80+
"Deadline",
81+
"ConferenceVoucher",
82+
"ChecklistItem",
83+
"Language",
84+
],
85+
"Content & CMS": [
86+
"Post",
87+
"Page",
88+
"GenericCopy",
89+
"FAQ",
90+
"Menu",
91+
"MenuLink",
92+
"JobListing",
93+
"Subscription",
94+
],
95+
"Comms": [
96+
"EmailTemplate",
97+
"SentEmail",
98+
],
99+
"System": [
100+
"APIToken",
101+
"GoogleCloudOAuthCredential",
102+
"File",
103+
],
104+
}
105+
106+
OTHER_GROUP = "Other"
107+
108+
109+
def _model_to_group() -> dict[str, str]:
110+
"""Reverse map: model object_name -> group title."""
111+
mapping = {}
112+
for group, object_names in GROUPS.items():
113+
for object_name in object_names:
114+
mapping[object_name] = group
115+
return mapping
116+
117+
118+
def _clean_model(model: dict, app: dict) -> dict:
119+
"""Produce a JSON-serializable model entry from Django's app_list dict.
120+
121+
Django's raw model dict carries the model class and lazy strings, neither of
122+
which survive json.dumps -- so pick only the fields the frontend needs and
123+
coerce names to plain strings.
124+
"""
125+
return {
126+
"name": str(model["name"]),
127+
"object_name": model["object_name"],
128+
"admin_url": model.get("admin_url"),
129+
"add_url": model.get("add_url"),
130+
"view_only": model.get("view_only", False),
131+
"app_label": app["app_label"],
132+
"app_name": str(app["name"]),
133+
}
134+
135+
136+
def build_groups(app_list: list[dict]) -> list[dict]:
137+
"""Bucket the models from Django's ``app_list`` into workflow groups.
138+
139+
Returns an ordered list of ``{"title", "models"}`` dicts with serializable
140+
model entries. Empty groups are omitted. Any model whose ``object_name``
141+
isn't mapped lands in the Other group, so nothing is silently dropped.
142+
"""
143+
model_to_group = _model_to_group()
144+
buckets: dict[str, list[dict]] = {title: [] for title in GROUPS}
145+
buckets[OTHER_GROUP] = []
146+
147+
for app in app_list:
148+
for model in app["models"]:
149+
group = model_to_group.get(model["object_name"], OTHER_GROUP)
150+
buckets[group].append(_clean_model(model, app))
151+
152+
ordered_titles = [*GROUPS.keys(), OTHER_GROUP]
153+
return [
154+
{"title": title, "models": buckets[title]}
155+
for title in ordered_titles
156+
if buckets[title]
157+
]
158+
159+
160+
def build_all_apps(app_list: list[dict]) -> list[dict]:
161+
"""Serializable copy of Django's full app/model list (the fallback section)."""
162+
return [
163+
{
164+
"app_label": app["app_label"],
165+
"name": str(app["name"]),
166+
"models": [_clean_model(model, app) for model in app["models"]],
167+
}
168+
for app in app_list
169+
]
170+
171+
172+
def build_quick_links(request) -> list[dict]:
173+
"""Curated shortcuts to common daily tasks.
174+
175+
Each link is ``{"title", "description", "url"}``. Links whose target can't
176+
be resolved (e.g. a feature not wired in this deploy) are skipped.
177+
"""
178+
links = []
179+
180+
# Schedule builder is per-conference; point at the most recent conference,
181+
# falling back to the conference changelist.
182+
schedule_url = _latest_schedule_builder_url() or _safe_reverse(
183+
"admin:conferences_conference_changelist"
184+
)
185+
if schedule_url:
186+
links.append(
187+
{
188+
"title": "Schedule builder",
189+
"description": "Build the conference schedule",
190+
"url": schedule_url,
191+
}
192+
)
193+
194+
grants_url = _safe_reverse("admin:grants_grant_changelist")
195+
if grants_url:
196+
links.append(
197+
{
198+
"title": "Review grants",
199+
"description": "Review and update grant requests",
200+
"url": grants_url,
201+
}
202+
)
203+
204+
submissions_url = _safe_reverse("admin:submissions_submission_changelist")
205+
if submissions_url:
206+
links.append(
207+
{
208+
"title": "Review submissions",
209+
"description": "Review proposed talks",
210+
"url": submissions_url,
211+
}
212+
)
213+
214+
return links
215+
216+
217+
def _safe_reverse(viewname, **kwargs):
218+
try:
219+
return reverse(viewname, **kwargs)
220+
except NoReverseMatch:
221+
return None
222+
223+
224+
def _latest_schedule_builder_url():
225+
from conferences.models import Conference
226+
227+
conference = Conference.objects.order_by("-start").first()
228+
if conference is None:
229+
return None
230+
return _safe_reverse("admin:schedule_builder", kwargs={"object_id": conference.pk})
231+
232+
233+
def custom_index(request, extra_context=None):
234+
"""Render the dashboard landing page in place of the stock admin index."""
235+
app_list = admin.site.get_app_list(request)
236+
context = {
237+
**admin.site.each_context(request),
238+
"title": admin.site.index_title,
239+
"app_list": app_list,
240+
"groups": build_groups(app_list),
241+
"all_apps": build_all_apps(app_list),
242+
"quick_links": build_quick_links(request),
243+
"breadcrumbs": [],
244+
**(extra_context or {}),
245+
}
246+
request.current_app = admin.site.name
247+
return TemplateResponse(request, INDEX_TEMPLATE, context)
248+
249+
250+
def install():
251+
"""Replace the default admin site's index view with ``custom_index``."""
252+
admin.site.index = custom_index
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { AdminApp } from "./types";
2+
3+
type Props = {
4+
apps: AdminApp[];
5+
};
6+
7+
// Full stock app/model list, collapsed by default, for completeness and to keep
8+
// rarely-used models reachable even when they aren't featured in a group.
9+
export const AllModels = ({ apps }: Props) => {
10+
if (apps.length === 0) {
11+
return null;
12+
}
13+
14+
return (
15+
<details className="rounded-lg border border-gray-200 bg-white shadow-sm">
16+
<summary className="cursor-pointer select-none px-4 py-3 font-medium text-gray-700">
17+
All models
18+
</summary>
19+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-4 px-4 pb-4">
20+
{apps.map((app) => (
21+
<div key={app.app_label}>
22+
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">
23+
{app.name}
24+
</div>
25+
<ul>
26+
{app.models.map((model) => (
27+
<li key={model.object_name} className="py-0.5">
28+
{model.admin_url ? (
29+
<a
30+
href={model.admin_url}
31+
className="text-[#417690] hover:underline"
32+
>
33+
{model.name}
34+
</a>
35+
) : (
36+
<span className="text-gray-700">{model.name}</span>
37+
)}
38+
</li>
39+
))}
40+
</ul>
41+
</div>
42+
))}
43+
</div>
44+
</details>
45+
);
46+
};
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useArgs } from "../shared/args";
2+
import { AllModels } from "./all-models";
3+
import { GroupCard } from "./group-card";
4+
import { QuickActions } from "./quick-actions";
5+
import { StatCard } from "./stat-card";
6+
import type { AdminApp, Group, QuickLink } from "./types";
7+
8+
// Placeholder metrics. Real values are wired in a follow-up (T5) via the
9+
// /admin/graphql endpoint; the layout is sized for them now.
10+
const PLACEHOLDER_STATS = [
11+
{ label: "Submissions" },
12+
{ label: "Grants pending" },
13+
{ label: "Schedule items" },
14+
{ label: "Tickets sold" },
15+
];
16+
17+
export const Dashboard = () => {
18+
const {
19+
groups = [],
20+
quickLinks = [],
21+
allApps = [],
22+
} = useArgs() as {
23+
groups: Group[];
24+
quickLinks: QuickLink[];
25+
allApps: AdminApp[];
26+
};
27+
28+
return (
29+
<div className="flex flex-col gap-8">
30+
<section>
31+
<h2 className="text-base font-medium text-gray-700 mb-3">Overview</h2>
32+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
33+
{PLACEHOLDER_STATS.map((stat) => (
34+
<StatCard key={stat.label} label={stat.label} />
35+
))}
36+
</div>
37+
</section>
38+
39+
{quickLinks.length > 0 && <QuickActions links={quickLinks} />}
40+
41+
<section>
42+
<h2 className="text-base font-medium text-gray-700 mb-3">
43+
Manage by area
44+
</h2>
45+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
46+
{groups.map((group) => (
47+
<GroupCard key={group.title} group={group} />
48+
))}
49+
</div>
50+
</section>
51+
52+
<AllModels apps={allApps} />
53+
</div>
54+
);
55+
};

0 commit comments

Comments
 (0)