From 187dcf9aebf838e0a9d37689758e6cee9ac3a960 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 5 Nov 2026 20:02:02 +0100 Subject: [PATCH] refactor(branding): follow the module layout (moves only) constants, settings, services, the branding service, presets and the tenant-overridable definitions and resolution move to branding/core/; image validation and the reaper to branding/assets/; deps, the shared- props provider and endpoints to branding/web/; the form components from components/ to ui/components/. Tests mirror the areas. branding.{constants,settings,services,tenant_branding,images} and branding.endpoints.assets stay as deprecated aliases until 0.0.46. Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 1 + docs/module-layout.md | 2 +- docs/modules/branding.md | 4 +- host/tests/test_module_layout.py | 7 +- modules/branding/branding/assets/__init__.py | 1 + modules/branding/branding/assets/images.py | 121 +++++++++ .../branding/branding/{ => assets}/reaper.py | 2 +- modules/branding/branding/constants.py | 231 +---------------- .../branding/branding/contracts/schemas.py | 2 +- modules/branding/branding/core/__init__.py | 5 + modules/branding/branding/core/constants.py | 230 ++++++++++++++++ .../branding/branding/{ => core}/presets.py | 0 .../branding/branding/{ => core}/service.py | 18 +- modules/branding/branding/core/services.py | 38 +++ modules/branding/branding/core/settings.py | 102 ++++++++ .../branding/branding/core/tenant_branding.py | 242 +++++++++++++++++ .../branding/{ => core}/tenant_definitions.py | 4 +- .../branding/branding/endpoints/__init__.py | 1 + modules/branding/branding/endpoints/assets.py | 190 +------------- modules/branding/branding/images.py | 122 +-------- modules/branding/branding/module.py | 22 +- modules/branding/branding/pages/Manage.tsx | 18 +- modules/branding/branding/services.py | 39 +-- modules/branding/branding/settings.py | 103 +------- modules/branding/branding/tenant_branding.py | 245 +----------------- .../{ => ui}/components/BannerField.tsx | 0 .../{ => ui}/components/BrandingPreview.tsx | 0 .../{ => ui}/components/ColorField.tsx | 0 .../{ => ui}/components/DesignPackField.tsx | 0 .../{ => ui}/components/FooterField.tsx | 0 .../{ => ui}/components/ImageDropzones.tsx | 0 .../{ => ui}/components/PresetField.tsx | 0 .../{ => ui}/components/PreviewSurfaces.tsx | 0 .../{ => ui}/components/PreviewTabs.tsx | 0 .../branding/{ => ui}/components/dirty.ts | 0 .../branding/{ => ui}/components/hex.ts | 0 modules/branding/branding/web/__init__.py | 1 + modules/branding/branding/{ => web}/deps.py | 4 +- .../branding/web/endpoints/__init__.py | 0 .../branding/{ => web}/endpoints/api.py | 8 +- .../branding/branding/web/endpoints/assets.py | 187 +++++++++++++ .../{ => web}/endpoints/tenant_api.py | 12 +- .../branding/{ => web}/endpoints/views.py | 4 +- .../branding/{ => web}/shared_props.py | 8 +- .../branding/tests-js/color-field.test.tsx | 8 +- .../branding/tests-js/dirty-state.test.tsx | 4 +- .../images/test_branding_images.py} | 2 +- .../images/test_branding_logo_dark.py} | 4 +- .../reaper/test_branding_asset_lifecycle.py} | 0 .../{ => assets/reaper}/test_branding_reap.py | 6 +- .../reaper}/test_branding_reap_ordering.py | 5 +- .../tests/{ => core/module}/test_branding.py | 8 +- .../module}/test_branding_menu_permission.py | 2 +- .../module/test_branding_menu_section.py} | 2 +- .../presets/test_branding_presets.py} | 4 +- .../settings/test_branding_banner.py} | 6 +- .../settings/test_branding_design_pack.py} | 4 +- .../settings/test_branding_footer_links.py} | 6 +- .../settings/test_branding_footer_text.py} | 6 +- .../test_branding_tenant_branding.py} | 2 +- .../test_branding_tenant_branding_assets.py} | 4 +- .../test_branding_tenant_branding_cost.py} | 4 +- .../test_branding_tenant_review_fixes.py} | 8 +- .../test_branding_clear_route_scope.py | 0 .../test_branding_platform_files.py | 2 +- .../endpoints/test_branding_public_assets.py} | 0 scripts/tests/test_check_no_double_cast.py | 2 +- 67 files changed, 1072 insertions(+), 991 deletions(-) create mode 100644 modules/branding/branding/assets/__init__.py create mode 100644 modules/branding/branding/assets/images.py rename modules/branding/branding/{ => assets}/reaper.py (98%) create mode 100644 modules/branding/branding/core/__init__.py create mode 100644 modules/branding/branding/core/constants.py rename modules/branding/branding/{ => core}/presets.py (100%) rename modules/branding/branding/{ => core}/service.py (92%) create mode 100644 modules/branding/branding/core/services.py create mode 100644 modules/branding/branding/core/settings.py create mode 100644 modules/branding/branding/core/tenant_branding.py rename modules/branding/branding/{ => core}/tenant_definitions.py (98%) rename modules/branding/branding/{ => ui}/components/BannerField.tsx (100%) rename modules/branding/branding/{ => ui}/components/BrandingPreview.tsx (100%) rename modules/branding/branding/{ => ui}/components/ColorField.tsx (100%) rename modules/branding/branding/{ => ui}/components/DesignPackField.tsx (100%) rename modules/branding/branding/{ => ui}/components/FooterField.tsx (100%) rename modules/branding/branding/{ => ui}/components/ImageDropzones.tsx (100%) rename modules/branding/branding/{ => ui}/components/PresetField.tsx (100%) rename modules/branding/branding/{ => ui}/components/PreviewSurfaces.tsx (100%) rename modules/branding/branding/{ => ui}/components/PreviewTabs.tsx (100%) rename modules/branding/branding/{ => ui}/components/dirty.ts (100%) rename modules/branding/branding/{ => ui}/components/hex.ts (100%) create mode 100644 modules/branding/branding/web/__init__.py rename modules/branding/branding/{ => web}/deps.py (86%) create mode 100644 modules/branding/branding/web/endpoints/__init__.py rename modules/branding/branding/{ => web}/endpoints/api.py (96%) create mode 100644 modules/branding/branding/web/endpoints/assets.py rename modules/branding/branding/{ => web}/endpoints/tenant_api.py (91%) rename modules/branding/branding/{ => web}/endpoints/views.py (95%) rename modules/branding/branding/{ => web}/shared_props.py (95%) rename modules/branding/tests/{test_images.py => assets/images/test_branding_images.py} (99%) rename modules/branding/tests/{test_logo_dark.py => assets/images/test_branding_logo_dark.py} (98%) rename modules/branding/tests/{test_asset_lifecycle.py => assets/reaper/test_branding_asset_lifecycle.py} (100%) rename modules/branding/tests/{ => assets/reaper}/test_branding_reap.py (96%) rename modules/branding/tests/{ => assets/reaper}/test_branding_reap_ordering.py (96%) rename modules/branding/tests/{ => core/module}/test_branding.py (97%) rename modules/branding/tests/{ => core/module}/test_branding_menu_permission.py (92%) rename modules/branding/tests/{test_menu_section.py => core/module/test_branding_menu_section.py} (92%) rename modules/branding/tests/{test_presets.py => core/presets/test_branding_presets.py} (97%) rename modules/branding/tests/{test_banner.py => core/settings/test_branding_banner.py} (96%) rename modules/branding/tests/{test_design_pack.py => core/settings/test_branding_design_pack.py} (97%) rename modules/branding/tests/{test_footer_links.py => core/settings/test_branding_footer_links.py} (97%) rename modules/branding/tests/{test_footer_text.py => core/settings/test_branding_footer_text.py} (95%) rename modules/branding/tests/{test_tenant_branding.py => core/tenant_branding/test_branding_tenant_branding.py} (99%) rename modules/branding/tests/{test_tenant_branding_assets.py => core/tenant_branding/test_branding_tenant_branding_assets.py} (99%) rename modules/branding/tests/{test_tenant_branding_cost.py => core/tenant_branding/test_branding_tenant_branding_cost.py} (97%) rename modules/branding/tests/{test_ship_review.py => core/tenant_branding/test_branding_tenant_review_fixes.py} (96%) rename modules/branding/tests/{ => web/endpoints}/test_branding_clear_route_scope.py (100%) rename modules/branding/tests/{ => web/endpoints}/test_branding_platform_files.py (98%) rename modules/branding/tests/{test_public_assets.py => web/endpoints/test_branding_public_assets.py} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14610b0e3..02681dd0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ All notable changes to this project are documented in this file. The format is b - `keycloak` follows the module layout: `keycloak.core.{constants,settings,state}`, `keycloak.identity.{provider,oidc,renewal,directory,persistence,tokens,jwks}` and `keycloak.web.endpoints`. `keycloak.{constants,settings,provider,jwks,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. - `oidc` follows the module layout: `oidc.core.{constants,settings,presets,state,runtime}`, `oidc.identity.{provider,client,discovery,renewal,directory,persistence,tokens,jwks}` and `oidc.web.endpoints`. `oidc.{settings,provider,jwks}` still import, with a `DeprecationWarning`, until 0.0.46; other old `oidc.*` paths are gone. - `api_tokens` follows the module layout: `api_tokens.core.{constants,settings,services,tenant_cleanup,user_cleanup}`, `api_tokens.credentials.{format,scopes,owner,service,resolver,audit}` (`tokens.py` is now `credentials/format.py`), `api_tokens.web.{deps,endpoints}`, and the page components and types move to `ui/`. `api_tokens.{constants,service,resolver,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. +- `branding` follows the module layout: `branding.core.{constants,settings,services,service,presets,tenant_definitions,tenant_branding}`, `branding.assets.{images,reaper}`, `branding.web.{deps,shared_props,endpoints}`, and the form components move from `components/` to `ui/components/`. `branding.{constants,settings,services,tenant_branding,images}` and `branding.endpoints.assets` still import, with a `DeprecationWarning`, until 0.0.46. ### Fixed diff --git a/docs/module-layout.md b/docs/module-layout.md index ee59fb857..339edb0eb 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -216,7 +216,7 @@ when a later module PR moves files. | audit_log | 13 | 18 | flat | | auth | 8 | 20 | flat | | background_tasks | 49 | 83 | flat | -| branding | 13 | 20 | flat | +| branding | 2 (+6 shims) | 0 | follows this layout | | dashboard | 2 | 0 | follows this layout | | feature_flags | 3 (+1 shim) | 0 | follows this layout | | file_storage | 39 | 65 | flat | diff --git a/docs/modules/branding.md b/docs/modules/branding.md index 00a57c154..6ace06e08 100644 --- a/docs/modules/branding.md +++ b/docs/modules/branding.md @@ -167,7 +167,7 @@ Note the exact property: the bytes must look like **some** allowed image format, ### Asset lifecycle -Replacing or clearing an image deletes the file it stopped referencing, so repeated logo tweaks don't leave orphans in `file_storage` — system and tenant images alike (`branding.reaper`): +Replacing or clearing an image deletes the file it stopped referencing, so repeated logo tweaks don't leave orphans in `file_storage` — system and tenant images alike (`branding.assets.reaper`): - **After commit.** The delete is queued with `register_on_commit` and runs, in a session of its own, once the settings write is durable. Deleting in the request removed the bytes before that: a late rollback left the setting pointing at a file that was gone. - **Only when unreferenced.** A file another image field of the same owner still holds — the logo and the dark logo sharing one upload — is kept; the check runs again at reap time. @@ -265,7 +265,7 @@ page translates, with the English text as fallback. Server-side error details (validator messages) are still shown as sent — they come from pydantic and are not keyed. -Reads resolve per request (`branding.tenant_branding.resolve`): the request's +Reads resolve per request (`branding.core.tenant_branding.resolve`): the request's tenant overrides on top of the system theme, falling back field by field. The provider leaves the merged object on `request.state.branding`, which the root template's pre-hydration `` prefers. A hand-edited invalid override diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index 2cb3bbc7c..15a2b69fb 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -34,6 +34,7 @@ # Add named domain areas here only when they contain several related features. DOMAIN_AREAS: dict[str, frozenset[str]] = { "api_tokens": frozenset({"credentials"}), + "branding": frozenset({"assets"}), "keycloak": frozenset({"identity"}), "mail": frozenset({"delivery"}), "oidc": frozenset({"identity"}), @@ -66,12 +67,6 @@ workers_state.py """.split() ), - "branding": frozenset( - """ - components constants.py deps.py endpoints images.py presets.py reaper.py service.py - services.py settings.py shared_props.py tenant_branding.py tenant_definitions.py - """.split() - ), "file_storage": frozenset( """ aggregates.py audit_labels.py backend_guard.py backends body_limits.py checksum.py diff --git a/modules/branding/branding/assets/__init__.py b/modules/branding/branding/assets/__init__.py new file mode 100644 index 000000000..a5b1c8095 --- /dev/null +++ b/modules/branding/branding/assets/__init__.py @@ -0,0 +1 @@ +"""Branding images: upload validation and reaping files nothing references.""" diff --git a/modules/branding/branding/assets/images.py b/modules/branding/branding/assets/images.py new file mode 100644 index 000000000..eb1323631 --- /dev/null +++ b/modules/branding/branding/assets/images.py @@ -0,0 +1,121 @@ +"""Upload validation for branding images. + +The declared ``Content-Type`` on a multipart part is chosen by the caller, so +it is a claim rather than a fact. An admin who uploads ``payload.html`` renamed +``logo.png`` would otherwise have it stored and later served back under an +``image/*`` type. Every accepted format therefore has to also *look* like +itself in its first bytes. + +Mirrors the allow-list and signature check in the IIASA.GeoWiki Branding module +(``GeoWikiBrandingConsts``), including its deliberate omission of SVG. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from fastapi import HTTPException, UploadFile + +if TYPE_CHECKING: + from file_storage.contracts.schemas import StoredFileOut + from file_storage.service import FileStorageService + +# Raster + icon only. SVG is excluded on purpose: it is an XML document that can +# carry