Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/custom_admin/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
252 changes: 252 additions & 0 deletions backend/custom_admin/index.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions backend/custom_admin/src/components/landing/all-models.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<details className="rounded-lg border border-gray-200 bg-white shadow-sm">
<summary className="cursor-pointer select-none px-4 py-3 font-medium text-gray-700">
All models
</summary>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-4 px-4 pb-4">
{apps.map((app) => (
<div key={app.app_label}>
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">
{app.name}
</div>
<ul>
{app.models.map((model) => (
<li key={model.object_name} className="py-0.5">
{model.admin_url ? (
<a
href={model.admin_url}
className="text-[#417690] hover:underline"
>
{model.name}
</a>
) : (
<span className="text-gray-700">{model.name}</span>
)}
</li>
))}
</ul>
</div>
))}
</div>
</details>
);
};
55 changes: 55 additions & 0 deletions backend/custom_admin/src/components/landing/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-8">
<section>
<h2 className="text-base font-medium text-gray-700 mb-3">Overview</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{PLACEHOLDER_STATS.map((stat) => (
<StatCard key={stat.label} label={stat.label} />
))}
</div>
</section>

{quickLinks.length > 0 && <QuickActions links={quickLinks} />}

<section>
<h2 className="text-base font-medium text-gray-700 mb-3">
Manage by area
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{groups.map((group) => (
<GroupCard key={group.title} group={group} />
))}
</div>
</section>

<AllModels apps={allApps} />
</div>
);
};
Loading
Loading