Community nav/sidebar/branding admin settings reuse existing endpoints instead of a new CRUD surface
Admins can now customize the community's navbar items (activate/deactivate/reorder/relabel/re-icon), reorder the sidebar's Spaces/SpaceGroups, and edit the community's logo/PWA branding — all from /settings/community/* inside frontend/apps/web itself (not the separate frontend/apps/admin backoffice SPA). Most of this reused backend that already existed; only Community.nav_config and one new headless endpoint were actually new.
Context
The three sub-features could each have been built as a dedicated CRUD surface (new models, new ViewSets, new endpoints). Investigation before building found the backend was already most of the way there:
CommunitySettingsView(apps.communities.views_settings,/api/v1/community/admin/settings/) already retrieve/updateslogo_light/logo_dark/pwa_icon/pwa_icon_maskable/pwa_short_name, gatedIsAdminOrModerator. It's already consumed byfrontend/apps/adminvia rawfetch+FormData(multipart).SpaceGroupViewSet/SpaceViewSet(apps.spaces.views.space_groups/spaces,/api/v1/space-groups/,/api/v1/spaces/) are fullModelViewSets withorderas a plain writable field — reordering is justPATCH {order: n}per moved row.- Nothing existed for the navbar items themselves — the list was a hardcoded array in
Sidebar.tsx.
Decision
- Nav items: add
Community.nav_config(JSONField(default=list), sparse list of{key, enabled, order, label, icon_type, icon_value}overrides) — same precedent as the existingthemeJSONField — and add it toCommunitySettingsSerializer's fields rather than a new model/endpoint.Sidebar.tsxmerges it with a hardcoded default-metadata table (frontend/apps/web/src/lib/navItems.tsx) so a key absent fromnav_config(a community that never configured it, or a future new built-in item) falls back to its default rather than needing a migration. - Sidebar reorder: no new endpoint. The settings page PATCHes
orderdirectly onSpaceGroupViewSet/SpaceViewSetper moved row, reusing the existingSpace.order/SpaceGroup.orderfields that already driveSidebar.tsx's rendering order. Reorder is within-group only — dragging a Space into a different group (aspace_groupFK change) is out of scope; no create/edit/delete UI for Spaces/SpaceGroups was added here. - Branding (logo/PWA):
CommunitySettingsViewitself is left untouched — itsFileFields only ever accept raw multipart bytes, which is exactly whatfrontend/apps/admin's hand-writtenfetch(..., {body: formData})already sends. Orval's generated React Query hooks always serialize the request body as JSON (Content-Type: application/json,JSON.stringify(...)), so they can never drive a real file upload — reusingCommunitySettingsViewfromapps/webwas not actually possible once this was discovered mid-implementation. Instead, a new headless endpoint,CommunityBrandingView(PATCH /api/headless/v1/community_branding), was added — modeled directly on the existingSpaceUpdateView's banner/icon/logo handling: the frontend uploads the raw file via the already-establisheduseUploadMedia()hook (itself a deliberate, documented exception to "Orval hooks are the single source of truth," for the same reason — multipart isn't representable in the generated client), then PATCHes the resulting Media id; the view resolves it to an actual file via_pick_media_file. Gated admin-only (notIsAdminOrModeratorlikeCommunitySettingsViewand the Space/SpaceGroup viewsets) — a new decision for a new admin-facing feature, not a tightening of any existing endpoint's permissions. - A schema-collision bug was found and fixed in passing:
apps.headless.schemas.SpaceSerializer(an OpenAPI-only contract class) andapps.spaces.serializers.spaces.SpaceSerializer(the realModelViewSetserializer for/api/v1/spaces/) shared the same drf-spectacular component name ("Space"), so the OpenAPI response schema — and therefore the Orval-generated TS type — for one of them was silently wrong. Gave the headless one an explicitref_name = "HeadlessSpace"(Meta.ref_name); existing consumers of the old, ambiguousSpaceimport (Sidebar.tsx) were updated toHeadlessSpace, the shape they actually always meant.
Consequences
- Positive: No new relational model or ViewSet for nav items; no new endpoint for sidebar reorder at all. The only genuinely new backend surface is one JSONField + one small headless view, both directly analogous to existing patterns in this codebase.
- Positive:
frontend/apps/admin's existing branding UI (components/Settings/CommunitySettings.tsx) is completely untouched and keeps working —CommunitySettingsView's contract (including itsFileFields) was never changed. - Negative: Branding now has two write paths onto the same four
Communityfields —CommunitySettingsView(raw multipart, admin or moderator, consumed byapps/admin) andCommunityBrandingView(Media-id JSON, admin only, consumed byapps/web). They're not in conflict (both end up setting the same model fields), but a future reader touching branding needs to know both exist and why. - Negative: The
Space/HeadlessSpaceschema-naming collision was almost certainly present (silently) for other class-name collisions betweenapps/headless/schemas.py's OpenAPI-only contract classes and real ModelSerializers elsewhere in the codebase — this fix covers only the one collision that blocked this feature (needingSpace.order/space_groupcorrectly typed for the sidebar reorder page), not a full audit.
Related
backend/apps/communities/models/community.py—nav_configfield,NAV_CONFIG_KEYS/NAV_CONFIG_ICON_TYPES.backend/apps/communities/views_settings.py—CommunitySettingsSerializer.validate_nav_config.backend/apps/communities/views.py—PublicCommunityInfoView(how a non-admin member'sSidebar.tsxactually readsnav_config, since the admin-settings endpoint is admin/moderator-gated).backend/apps/headless/views.py::CommunityBrandingView,backend/apps/headless/schemas.py::CommunityBrandingUpdateRequestSerializer.frontend/apps/web/src/lib/navItems.tsx,frontend/apps/web/src/components/Sidebar.tsx.frontend/apps/web/src/routes/settings/community/— the three admin settings pages.- ADR 0010 — the
visible_to/accessible_tocontract this feature's read paths respect (unrelated to but adjacent to the admin-only write gate here).