simple-module-branding 0.0.24__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
branding/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Branding module."""
branding/constants.py ADDED
@@ -0,0 +1,65 @@
1
+ """Branding module constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Final
7
+
8
+ from file_storage.constants import PATH_FILE_DOWNLOAD, ROUTE_PREFIX_API
9
+
10
+ PACKAGE: Final = "branding"
11
+
12
+ # Modules this one depends on (kept as constants so the depends_on list doesn't
13
+ # carry bare string literals — see scripts/check_hardcoded_strings.py).
14
+ _MODULE_SETTINGS: Final = "Settings"
15
+ _MODULE_FILE_STORAGE: Final = "FileStorage"
16
+
17
+ # Inertia page identifier. The view endpoint renders this as a literal (so the
18
+ # SM003/SM004 static-AST diagnostics can pair it with pages/Manage.tsx); a test
19
+ # asserts the literal matches this constant.
20
+ _PAGE_MANAGE: Final = "Branding/Manage"
21
+
22
+ PERM_VIEW: Final = "branding.view"
23
+ PERM_MANAGE: Final = "branding.manage"
24
+
25
+ # A #rrggbb hex colour (single source of truth for both the settings and the
26
+ # update-DTO validators).
27
+ HEX_COLOR_RE: Final = re.compile(r"^#[0-9a-fA-F]{6}$")
28
+ MAX_APP_NAME_LEN: Final = 60
29
+
30
+ # Image upload guard-rails (enforced before handing the file to file_storage).
31
+ MAX_IMAGE_BYTES: Final = 2 * 1024 * 1024 # 2 MB
32
+ ALLOWED_IMAGE_TYPES: Final = frozenset(
33
+ {
34
+ "image/png",
35
+ "image/jpeg",
36
+ "image/svg+xml",
37
+ "image/webp",
38
+ "image/gif",
39
+ "image/x-icon",
40
+ "image/vnd.microsoft.icon",
41
+ }
42
+ )
43
+
44
+ # file_storage download URL, derived from file_storage's own route constants
45
+ # (branding depends on FileStorage) so it tracks any route change. The
46
+ # ``{file_id}`` placeholder is filled per stored-file id.
47
+ FILE_DOWNLOAD_URL: Final = ROUTE_PREFIX_API + PATH_FILE_DOWNLOAD
48
+
49
+
50
+ def clean_app_name(value: str) -> str:
51
+ """Normalise + validate an app name (shared by the settings + update DTO).
52
+
53
+ The name is surfaced in HTML titles and—critically—email ``Subject``
54
+ headers, so control characters (notably CR/LF) must be rejected: an
55
+ embedded newline would otherwise pass a bare ``strip()`` and then raise
56
+ when set as a header, breaking every transactional email.
57
+ """
58
+ cleaned = value.strip()
59
+ if not cleaned:
60
+ raise ValueError("app_name must not be blank")
61
+ if len(cleaned) > MAX_APP_NAME_LEN:
62
+ raise ValueError(f"app_name must be at most {MAX_APP_NAME_LEN} characters")
63
+ if any(ord(ch) < 0x20 for ch in cleaned):
64
+ raise ValueError("app_name must not contain control characters")
65
+ return cleaned
@@ -0,0 +1,8 @@
1
+ """Branding contracts — public interface for other modules."""
2
+
3
+ from branding.contracts.schemas import BrandingOut, BrandingUpdate
4
+
5
+ __all__ = [
6
+ "BrandingOut",
7
+ "BrandingUpdate",
8
+ ]
@@ -0,0 +1,43 @@
1
+ """SQLModel DTOs for the Branding module — the public surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import field_validator
6
+ from sqlmodel import Field, SQLModel
7
+
8
+ from branding.constants import HEX_COLOR_RE, MAX_APP_NAME_LEN, clean_app_name
9
+
10
+
11
+ class BrandingOut(SQLModel):
12
+ """Current branding, with logo/favicon resolved to download URLs."""
13
+
14
+ app_name: str
15
+ primary_color: str = ""
16
+ logo_url: str | None = None
17
+ favicon_url: str | None = None
18
+
19
+
20
+ class BrandingUpdate(SQLModel):
21
+ """Editable text fields. Logo/favicon are set via dedicated upload routes."""
22
+
23
+ app_name: str | None = Field(default=None, max_length=MAX_APP_NAME_LEN)
24
+ primary_color: str | None = Field(default=None)
25
+
26
+ @field_validator("app_name")
27
+ @classmethod
28
+ def _non_empty_name(cls, value: str | None) -> str | None:
29
+ # Validate here so bad input surfaces as a 422 rather than a 500 when
30
+ # BrandingSettings re-validates (blank, too long, or control chars —
31
+ # the last would otherwise break email Subject headers downstream).
32
+ if value is None:
33
+ return None
34
+ return clean_app_name(value)
35
+
36
+ @field_validator("primary_color")
37
+ @classmethod
38
+ def _valid_hex(cls, value: str | None) -> str | None:
39
+ if value is None:
40
+ return None
41
+ if value != "" and not HEX_COLOR_RE.match(value):
42
+ raise ValueError("primary_color must be a #rrggbb hex string or empty")
43
+ return value.lower()
branding/deps.py ADDED
@@ -0,0 +1,21 @@
1
+ """FastAPI dependencies for the Branding module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import Depends, Request
8
+ from simple_module_db.deps import get_db
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from branding.service import BrandingService
12
+
13
+
14
+ async def get_branding_service(
15
+ request: Request,
16
+ db: AsyncSession = Depends(get_db),
17
+ ) -> BrandingService:
18
+ return BrandingService(request.app, db)
19
+
20
+
21
+ BrandingServiceDep = Annotated[BrandingService, Depends(get_branding_service)]
File without changes
@@ -0,0 +1,74 @@
1
+ """REST API endpoints for Branding (JSON). All writes require branding.manage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile
6
+ from file_storage.deps import get_file_storage_service
7
+ from file_storage.service import FileStorageService
8
+ from simple_module_hosting.permissions import RequiresPermission
9
+
10
+ from branding import constants
11
+ from branding.contracts.schemas import BrandingOut, BrandingUpdate
12
+ from branding.deps import BrandingServiceDep
13
+
14
+ router = APIRouter()
15
+
16
+ _MANAGE = Depends(RequiresPermission(constants.PERM_MANAGE))
17
+
18
+
19
+ def _validate_image(file: UploadFile) -> None:
20
+ if file.content_type not in constants.ALLOWED_IMAGE_TYPES:
21
+ raise HTTPException(
22
+ status_code=415,
23
+ detail=f"Unsupported image type {file.content_type!r}.",
24
+ )
25
+ if file.size is not None and file.size > constants.MAX_IMAGE_BYTES:
26
+ raise HTTPException(
27
+ status_code=413,
28
+ detail=f"Image exceeds {constants.MAX_IMAGE_BYTES} bytes.",
29
+ )
30
+
31
+
32
+ @router.get("/", response_model=BrandingOut, dependencies=[_MANAGE])
33
+ async def get_branding(service: BrandingServiceDep) -> BrandingOut:
34
+ return service.current()
35
+
36
+
37
+ @router.put("/", response_model=BrandingOut, dependencies=[_MANAGE])
38
+ async def update_branding(data: BrandingUpdate, service: BrandingServiceDep) -> BrandingOut:
39
+ changes = {k: v for k, v in data.model_dump(exclude_unset=True).items() if v is not None}
40
+ if not changes:
41
+ return service.current()
42
+ return await service.apply(changes)
43
+
44
+
45
+ @router.post("/logo", response_model=BrandingOut, dependencies=[_MANAGE])
46
+ async def upload_logo(
47
+ service: BrandingServiceDep,
48
+ file: UploadFile,
49
+ storage: FileStorageService = Depends(get_file_storage_service),
50
+ ) -> BrandingOut:
51
+ _validate_image(file)
52
+ stored = await storage.upload(file)
53
+ return await service.set_logo(str(stored.id))
54
+
55
+
56
+ @router.post("/favicon", response_model=BrandingOut, dependencies=[_MANAGE])
57
+ async def upload_favicon(
58
+ service: BrandingServiceDep,
59
+ file: UploadFile,
60
+ storage: FileStorageService = Depends(get_file_storage_service),
61
+ ) -> BrandingOut:
62
+ _validate_image(file)
63
+ stored = await storage.upload(file)
64
+ return await service.set_favicon(str(stored.id))
65
+
66
+
67
+ @router.delete("/logo", response_model=BrandingOut, dependencies=[_MANAGE])
68
+ async def clear_logo(service: BrandingServiceDep) -> BrandingOut:
69
+ return await service.clear_logo()
70
+
71
+
72
+ @router.delete("/favicon", response_model=BrandingOut, dependencies=[_MANAGE])
73
+ async def clear_favicon(service: BrandingServiceDep) -> BrandingOut:
74
+ return await service.clear_favicon()
@@ -0,0 +1,29 @@
1
+ """Inertia view endpoints for Branding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from inertia import InertiaResponse
7
+ from simple_module_hosting.inertia_deps import InertiaDep
8
+ from simple_module_hosting.permissions import RequiresPermission
9
+
10
+ from branding import constants
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.get(
16
+ "/",
17
+ response_model=None,
18
+ dependencies=[Depends(RequiresPermission(constants.PERM_VIEW))],
19
+ )
20
+ async def manage(inertia: InertiaDep) -> InertiaResponse:
21
+ # Current branding is delivered through the shared ``branding`` prop
22
+ # (the branding shared-props provider), so no page props are needed.
23
+ # The page name is inlined as a literal (rather than constants._PAGE_MANAGE)
24
+ # so the SM003/SM004 diagnostics — which do static AST analysis and can't
25
+ # resolve attribute access — pair this call with pages/Manage.tsx. A unit
26
+ # test asserts the literal matches constants._PAGE_MANAGE.
27
+ return await inertia.render(
28
+ "Branding/Manage",
29
+ )
@@ -0,0 +1,23 @@
1
+ {
2
+ "manage": {
3
+ "title": "Branding",
4
+ "description": "Customise your application's name, logo, favicon and primary colour.",
5
+ "app_name_label": "Application name",
6
+ "app_name_help": "Shown in the sidebar, the browser tab and on sign-in.",
7
+ "primary_color_label": "Primary colour",
8
+ "primary_color_help": "Accent colour used across buttons and highlights. Leave blank for the default.",
9
+ "logo_label": "Logo",
10
+ "logo_help": "Square PNG or SVG works best. Replaces the default badge in the sidebar.",
11
+ "favicon_label": "Favicon",
12
+ "favicon_help": "Small icon shown in the browser tab. PNG, SVG or ICO.",
13
+ "upload_button": "Upload",
14
+ "replace_button": "Replace",
15
+ "remove_button": "Remove",
16
+ "save_button": "Save changes",
17
+ "saving": "Saving…",
18
+ "preview_title": "Preview",
19
+ "saved_toast": "Branding updated",
20
+ "error_toast": "Could not update branding",
21
+ "upload_error_toast": "Could not upload image"
22
+ }
23
+ }
branding/module.py ADDED
@@ -0,0 +1,77 @@
1
+ """Branding module definition.
2
+
3
+ Lets an administrator customise the app name, logo, favicon and primary colour.
4
+ Values persist in the shared settings store (no branding table) and reach every
5
+ page through a registered Inertia shared-props provider.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.resources
11
+ from pathlib import Path
12
+
13
+ from fastapi import APIRouter, FastAPI
14
+ from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
15
+ from simple_module_core.module import ModuleBase, ModuleMeta
16
+ from simple_module_core.permissions import PermissionRegistry
17
+
18
+ from branding import constants
19
+
20
+
21
+ class BrandingModule(ModuleBase):
22
+ meta = ModuleMeta(
23
+ name="Branding",
24
+ route_prefix="/api/branding",
25
+ view_prefix="/branding",
26
+ depends_on=[constants._MODULE_SETTINGS, constants._MODULE_FILE_STORAGE],
27
+ )
28
+
29
+ def register_settings(self, app: FastAPI) -> None:
30
+ from settings.registration import register_module_settings
31
+
32
+ from branding.services import BrandingServices
33
+ from branding.settings import BrandingSettings
34
+
35
+ register_module_settings(
36
+ app,
37
+ constants.PACKAGE,
38
+ BrandingSettings,
39
+ lambda s: BrandingServices(settings=s),
40
+ )
41
+
42
+ def register_permissions(self, registry: PermissionRegistry) -> None:
43
+ registry.add_group(
44
+ "Branding",
45
+ [constants.PERM_VIEW, constants.PERM_MANAGE],
46
+ )
47
+
48
+ def register_menu_items(self, registry: MenuRegistry) -> None:
49
+ registry.add(
50
+ MenuItem(
51
+ label="Branding",
52
+ url="/branding",
53
+ icon="palette",
54
+ order=115,
55
+ section=MenuSection.SIDEBAR,
56
+ group="Administration",
57
+ roles=["admin"],
58
+ )
59
+ )
60
+
61
+ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
62
+ from branding.endpoints.api import router as api
63
+ from branding.endpoints.views import router as views
64
+
65
+ api_router.include_router(api)
66
+ view_router.include_router(views)
67
+
68
+ async def on_startup(self, app: FastAPI) -> None:
69
+ from simple_module_hosting.shared_props import register_inertia_shared_provider
70
+
71
+ from branding.shared_props import branding_shared_props
72
+
73
+ register_inertia_shared_provider(app, branding_shared_props)
74
+
75
+ def locale_dirs(self) -> dict[str, Path]:
76
+ base = Path(str(importlib.resources.files(__package__) / "locales"))
77
+ return {constants.PACKAGE: base}
branding/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@simple-module-py/branding",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Frontend assets for the Branding module",
6
+ "peerDependencies": {
7
+ "react": "^19.0.0",
8
+ "react-dom": "^19.0.0",
9
+ "@inertiajs/react": "^2.0.0",
10
+ "@simple-module-py/ui": "*"
11
+ },
12
+ "devDependencies": {
13
+ "@simple-module-py/tsconfig": "*"
14
+ },
15
+ "dependencies": {}
16
+ }
@@ -0,0 +1,261 @@
1
+ import { Head, router, usePage } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
4
+ import { Button } from '@simple-module-py/ui/components/ui/button';
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from '@simple-module-py/ui/components/ui/card';
12
+ import { Input } from '@simple-module-py/ui/components/ui/input';
13
+ import { Label } from '@simple-module-py/ui/components/ui/label';
14
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
15
+ import type { SharedProps } from '@simple-module-py/ui/types';
16
+ import { type ChangeEvent, useRef, useState } from 'react';
17
+ import { toast } from 'sonner';
18
+
19
+ const DEFAULT_SWATCH = '#10b981';
20
+ type ImageKind = 'logo' | 'favicon';
21
+
22
+ async function readError(res: Response): Promise<string> {
23
+ try {
24
+ const body = await res.json();
25
+ return typeof body?.detail === 'string' ? body.detail : res.statusText;
26
+ } catch {
27
+ return res.statusText;
28
+ }
29
+ }
30
+
31
+ function ImageField({
32
+ label,
33
+ help,
34
+ url,
35
+ onUpload,
36
+ onRemove,
37
+ disabled,
38
+ }: {
39
+ label: string;
40
+ help: string;
41
+ url: string | null;
42
+ onUpload: (file: File) => void;
43
+ onRemove: () => void;
44
+ disabled: boolean;
45
+ }) {
46
+ const { t } = useT();
47
+ const inputRef = useRef<HTMLInputElement>(null);
48
+
49
+ return (
50
+ <div className="space-y-2">
51
+ <Label>{label}</Label>
52
+ <div className="flex items-center gap-4">
53
+ <div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-lg border bg-muted">
54
+ {url ? (
55
+ <img src={url} alt={label} className="h-full w-full object-contain" />
56
+ ) : (
57
+ <span className="text-xs text-muted-foreground">—</span>
58
+ )}
59
+ </div>
60
+ <div className="flex gap-2">
61
+ <input
62
+ ref={inputRef}
63
+ type="file"
64
+ accept="image/*"
65
+ className="hidden"
66
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
67
+ const file = e.target.files?.[0];
68
+ if (file) onUpload(file);
69
+ e.target.value = '';
70
+ }}
71
+ />
72
+ <Button
73
+ type="button"
74
+ variant="outline"
75
+ size="sm"
76
+ disabled={disabled}
77
+ onClick={() => inputRef.current?.click()}
78
+ >
79
+ {url ? t(keys.branding.manage.replace_button) : t(keys.branding.manage.upload_button)}
80
+ </Button>
81
+ {url && (
82
+ <Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={onRemove}>
83
+ {t(keys.branding.manage.remove_button)}
84
+ </Button>
85
+ )}
86
+ </div>
87
+ </div>
88
+ <p className="text-xs text-muted-foreground">{help}</p>
89
+ </div>
90
+ );
91
+ }
92
+
93
+ function Manage() {
94
+ const { t } = useT();
95
+ const { auth, branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps;
96
+ const canManage = auth?.permissions?.includes('branding.manage');
97
+
98
+ const [appName, setAppName] = useState(branding?.appName ?? '');
99
+ const [color, setColor] = useState(branding?.primaryColor ?? '');
100
+ const [busy, setBusy] = useState(false);
101
+
102
+ async function run(work: () => Promise<Response>, errorMsg: string) {
103
+ setBusy(true);
104
+ try {
105
+ const res = await work();
106
+ if (!res.ok) throw new Error(await readError(res));
107
+ toast.success(t(keys.branding.manage.saved_toast));
108
+ router.reload();
109
+ } catch (err) {
110
+ toast.error(`${errorMsg}: ${(err as Error).message}`);
111
+ } finally {
112
+ setBusy(false);
113
+ }
114
+ }
115
+
116
+ const saveText = () =>
117
+ run(
118
+ () =>
119
+ fetch('/api/branding/', {
120
+ method: 'PUT',
121
+ headers: { 'Content-Type': 'application/json' },
122
+ body: JSON.stringify({ app_name: appName, primary_color: color }),
123
+ }),
124
+ t(keys.branding.manage.error_toast),
125
+ );
126
+
127
+ const uploadImage = (kind: ImageKind, file: File) => {
128
+ const form = new FormData();
129
+ form.append('file', file);
130
+ return run(
131
+ () => fetch(`/api/branding/${kind}`, { method: 'POST', body: form }),
132
+ t(keys.branding.manage.upload_error_toast),
133
+ );
134
+ };
135
+
136
+ const removeImage = (kind: ImageKind) =>
137
+ run(
138
+ () => fetch(`/api/branding/${kind}`, { method: 'DELETE' }),
139
+ t(keys.branding.manage.error_toast),
140
+ );
141
+
142
+ return (
143
+ <>
144
+ <Head title={t(keys.branding.manage.title)} />
145
+ <PageShell
146
+ title={t(keys.branding.manage.title)}
147
+ description={t(keys.branding.manage.description)}
148
+ >
149
+ <div className="grid gap-6 lg:grid-cols-3">
150
+ <Card className="lg:col-span-2">
151
+ <CardHeader>
152
+ <CardTitle>{t(keys.branding.manage.title)}</CardTitle>
153
+ <CardDescription>{t(keys.branding.manage.description)}</CardDescription>
154
+ </CardHeader>
155
+ <CardContent className="space-y-6">
156
+ <div className="space-y-2">
157
+ <Label htmlFor="app_name">{t(keys.branding.manage.app_name_label)}</Label>
158
+ <Input
159
+ id="app_name"
160
+ value={appName}
161
+ maxLength={60}
162
+ disabled={!canManage || busy}
163
+ onChange={(e) => setAppName(e.target.value)}
164
+ />
165
+ <p className="text-xs text-muted-foreground">
166
+ {t(keys.branding.manage.app_name_help)}
167
+ </p>
168
+ </div>
169
+
170
+ <div className="space-y-2">
171
+ <Label htmlFor="primary_color">{t(keys.branding.manage.primary_color_label)}</Label>
172
+ <div className="flex items-center gap-3">
173
+ <input
174
+ type="color"
175
+ aria-label={t(keys.branding.manage.primary_color_label)}
176
+ value={color || DEFAULT_SWATCH}
177
+ disabled={!canManage || busy}
178
+ onChange={(e) => setColor(e.target.value)}
179
+ className="h-9 w-12 cursor-pointer rounded border bg-transparent"
180
+ />
181
+ <Input
182
+ id="primary_color"
183
+ value={color}
184
+ placeholder={DEFAULT_SWATCH}
185
+ disabled={!canManage || busy}
186
+ onChange={(e) => setColor(e.target.value)}
187
+ className="max-w-40 font-mono"
188
+ />
189
+ {color && (
190
+ <Button
191
+ type="button"
192
+ variant="ghost"
193
+ size="sm"
194
+ disabled={busy}
195
+ onClick={() => setColor('')}
196
+ >
197
+ {t(keys.branding.manage.remove_button)}
198
+ </Button>
199
+ )}
200
+ </div>
201
+ <p className="text-xs text-muted-foreground">
202
+ {t(keys.branding.manage.primary_color_help)}
203
+ </p>
204
+ </div>
205
+
206
+ <ImageField
207
+ label={t(keys.branding.manage.logo_label)}
208
+ help={t(keys.branding.manage.logo_help)}
209
+ url={branding?.logoUrl ?? null}
210
+ onUpload={(file) => uploadImage('logo', file)}
211
+ onRemove={() => removeImage('logo')}
212
+ disabled={!canManage || busy}
213
+ />
214
+ <ImageField
215
+ label={t(keys.branding.manage.favicon_label)}
216
+ help={t(keys.branding.manage.favicon_help)}
217
+ url={branding?.faviconUrl ?? null}
218
+ onUpload={(file) => uploadImage('favicon', file)}
219
+ onRemove={() => removeImage('favicon')}
220
+ disabled={!canManage || busy}
221
+ />
222
+
223
+ <Button type="button" disabled={!canManage || busy} onClick={saveText}>
224
+ {busy ? t(keys.branding.manage.saving) : t(keys.branding.manage.save_button)}
225
+ </Button>
226
+ </CardContent>
227
+ </Card>
228
+
229
+ <Card>
230
+ <CardHeader>
231
+ <CardTitle>{t(keys.branding.manage.preview_title)}</CardTitle>
232
+ </CardHeader>
233
+ <CardContent>
234
+ <div className="flex items-center gap-3 rounded-lg border p-4">
235
+ <div
236
+ className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg text-white"
237
+ style={{ backgroundColor: color || DEFAULT_SWATCH }}
238
+ >
239
+ {branding?.logoUrl ? (
240
+ <img
241
+ src={branding.logoUrl}
242
+ alt={appName}
243
+ className="h-full w-full object-contain"
244
+ />
245
+ ) : (
246
+ <span className="font-bold">{(appName.trim()[0] ?? 'S').toUpperCase()}</span>
247
+ )}
248
+ </div>
249
+ <span className="font-semibold">{appName || 'SimpleModule'}</span>
250
+ </div>
251
+ </CardContent>
252
+ </Card>
253
+ </div>
254
+ </PageShell>
255
+ </>
256
+ );
257
+ }
258
+
259
+ Manage.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
260
+
261
+ export default Manage;
branding/py.typed ADDED
File without changes
branding/service.py ADDED
@@ -0,0 +1,61 @@
1
+ """Branding service — reads current branding and applies admin changes.
2
+
3
+ There is no branding DB table: values live in the shared settings store. Writes
4
+ go through ``settings.reload.apply_changes_and_reload`` which validates against
5
+ ``BrandingSettings``, persists (SYSTEM scope), hot-swaps ``app.state.branding``
6
+ and publishes ``SettingsReloaded``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from branding.constants import PACKAGE
14
+ from branding.contracts.schemas import BrandingOut
15
+ from branding.shared_props import file_url
16
+
17
+ if TYPE_CHECKING:
18
+ from fastapi import FastAPI
19
+ from sqlalchemy.ext.asyncio import AsyncSession
20
+
21
+
22
+ class BrandingService:
23
+ """Read/update the application's branding."""
24
+
25
+ def __init__(self, app: FastAPI, db: AsyncSession) -> None:
26
+ self.app = app
27
+ self.db = db
28
+
29
+ def current(self) -> BrandingOut:
30
+ settings = self.app.state.branding.settings
31
+ return BrandingOut(
32
+ app_name=settings.app_name,
33
+ primary_color=settings.primary_color,
34
+ logo_url=file_url(settings.logo_file_id),
35
+ favicon_url=file_url(settings.favicon_file_id),
36
+ )
37
+
38
+ async def apply(self, changes: dict[str, Any]) -> BrandingOut:
39
+ """Persist and hot-swap the given field changes, then return current."""
40
+ # Plugin→plugin imports (settings is a declared dependency); kept local
41
+ # so import order during discovery stays tolerant.
42
+ from settings.reload import apply_changes_and_reload
43
+ from settings.service import SettingService
44
+ from settings.store import SettingsStore
45
+
46
+ store = SettingsStore(SettingService(self.db))
47
+ bus = self.app.state.sm.event_bus
48
+ await apply_changes_and_reload(self.app, bus, store, package=PACKAGE, changes=changes)
49
+ return self.current()
50
+
51
+ async def set_logo(self, file_id: str) -> BrandingOut:
52
+ return await self.apply({"logo_file_id": file_id})
53
+
54
+ async def set_favicon(self, file_id: str) -> BrandingOut:
55
+ return await self.apply({"favicon_file_id": file_id})
56
+
57
+ async def clear_logo(self) -> BrandingOut:
58
+ return await self.apply({"logo_file_id": ""})
59
+
60
+ async def clear_favicon(self) -> BrandingOut:
61
+ return await self.apply({"favicon_file_id": ""})
branding/services.py ADDED
@@ -0,0 +1,20 @@
1
+ """Module-scoped state container.
2
+
3
+ Stored as ``app.state.branding`` by
4
+ :meth:`BrandingModule.register_settings` (via ``register_module_settings``).
5
+ ``settings`` is hot-swapped by ``settings.reload.apply_changes_and_reload`` when
6
+ an admin saves changes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from branding.settings import BrandingSettings
14
+
15
+
16
+ @dataclass
17
+ class BrandingServices:
18
+ """Branding module singletons."""
19
+
20
+ settings: BrandingSettings
branding/settings.py ADDED
@@ -0,0 +1,42 @@
1
+ """Branding module settings — DB-backed via ``register_module_settings``.
2
+
3
+ The four values an administrator can customise (app name, logo, favicon,
4
+ primary colour) are stored in the shared settings store at SYSTEM scope,
5
+ hydrated into ``app.state.branding.settings`` at boot, and hot-swapped on save
6
+ through ``settings.reload.apply_changes_and_reload``.
7
+
8
+ Logo/favicon are stored as ``file_storage`` UUIDs (empty string = unset); the
9
+ frontend derives a download URL from the id.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pydantic import field_validator
15
+ from pydantic_settings import BaseSettings, SettingsConfigDict
16
+
17
+ from branding.constants import HEX_COLOR_RE, clean_app_name
18
+
19
+ DEFAULT_APP_NAME = "SimpleModule"
20
+
21
+
22
+ class BrandingSettings(BaseSettings):
23
+ """Customisable application identity."""
24
+
25
+ model_config = SettingsConfigDict(extra="ignore")
26
+
27
+ app_name: str = DEFAULT_APP_NAME
28
+ primary_color: str = "" # "" = use the theme default; otherwise "#rrggbb"
29
+ logo_file_id: str = "" # file_storage UUID, "" = no custom logo
30
+ favicon_file_id: str = "" # file_storage UUID, "" = no custom favicon
31
+
32
+ @field_validator("app_name")
33
+ @classmethod
34
+ def _non_empty_name(cls, value: str) -> str:
35
+ return clean_app_name(value)
36
+
37
+ @field_validator("primary_color")
38
+ @classmethod
39
+ def _valid_hex(cls, value: str) -> str:
40
+ if value and not HEX_COLOR_RE.match(value):
41
+ raise ValueError("primary_color must be a #rrggbb hex string or empty")
42
+ return value.lower()
@@ -0,0 +1,44 @@
1
+ """Branding shared-props provider.
2
+
3
+ Registered on ``app.state.inertia_shared_providers`` so every Inertia page
4
+ (authenticated *and* guest) receives a ``branding`` block in its shared props.
5
+ The frontend uses it for the app name, logo, favicon and primary colour.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from branding.constants import FILE_DOWNLOAD_URL
13
+
14
+ if TYPE_CHECKING:
15
+ from starlette.requests import Request
16
+
17
+ from branding.settings import BrandingSettings
18
+
19
+
20
+ def file_url(file_id: str) -> str | None:
21
+ """Build the file_storage download URL for a stored file id (or None)."""
22
+ return FILE_DOWNLOAD_URL.format(file_id=file_id) if file_id else None
23
+
24
+
25
+ def branding_payload(settings: BrandingSettings) -> dict:
26
+ """The camelCase branding block shared with the frontend."""
27
+ return {
28
+ "appName": settings.app_name,
29
+ "primaryColor": settings.primary_color or None,
30
+ "logoUrl": file_url(settings.logo_file_id),
31
+ "faviconUrl": file_url(settings.favicon_file_id),
32
+ }
33
+
34
+
35
+ def branding_shared_props(request: Request) -> dict:
36
+ """Provider: emit ``{"branding": {...}}`` from the live module settings.
37
+
38
+ Defensive — returns ``{}`` if the branding state isn't mounted yet, so a
39
+ half-booted app never errors a page render.
40
+ """
41
+ services = getattr(request.app.state, "branding", None)
42
+ if services is None:
43
+ return {}
44
+ return {"branding": branding_payload(services.settings)}
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple_module_branding
3
+ Version: 0.0.24
4
+ Summary: Customisable application branding (name, logo, favicon, primary colour) for simple_module apps
5
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python
6
+ Author-email: Anto Subash <antosubash@live.com>
7
+ License-Expression: MIT
8
+ Keywords: branding,simple-module,theming,white-label
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: simple-module-core==0.0.24
11
+ Requires-Dist: simple-module-db==0.0.24
12
+ Requires-Dist: simple-module-file-storage==0.0.24
13
+ Requires-Dist: simple-module-hosting==0.0.24
14
+ Requires-Dist: simple-module-settings==0.0.24
15
+ Description-Content-Type: text/markdown
16
+
17
+ # simple_module_branding
18
+
19
+ Customisable application branding for [simple_module_python](https://github.com/antosubash/simple_module_python) apps.
20
+
21
+ An administrator can set the **application name**, **logo**, **favicon** and
22
+ **primary brand colour** from the admin UI (`/branding`), and those values are
23
+ applied everywhere the framework would otherwise show the default identity —
24
+ the sidebar/header logo and name, the browser tab title, the favicon, and the
25
+ primary accent colour.
26
+
27
+ ## Screenshots
28
+
29
+ The admin page at `/branding`, and the same app rebranded as "Acme Analytics"
30
+ (custom logo + name + primary colour) across the sidebar and dashboard:
31
+
32
+ | Admin page | Branding applied | Across the app |
33
+ |---|---|---|
34
+ | ![Branding admin](../../docs/branding/screenshots/01-branding-admin-default.png) | ![Branding applied](../../docs/branding/screenshots/02-branding-admin-applied.png) | ![Branded dashboard](../../docs/branding/screenshots/03-branded-dashboard.png) |
35
+
36
+ ## Install
37
+
38
+ The module ships with the default app. To add it to a custom host, declare it
39
+ as a dependency and let entry-point discovery pick it up:
40
+
41
+ ```toml
42
+ # host/pyproject.toml
43
+ dependencies = ["simple_module_branding"]
44
+
45
+ [tool.uv.sources]
46
+ simple_module_branding = { workspace = true }
47
+ ```
48
+
49
+ Then `uv sync --all-packages`. It requires the `Settings` and `FileStorage`
50
+ modules to be installed too.
51
+
52
+ ## Usage
53
+
54
+ 1. Sign in as an admin and open **Branding** in the sidebar (or visit
55
+ `/branding`).
56
+ 2. Set the application name, pick a primary colour, and upload a logo and/or
57
+ favicon. Changes apply immediately across the app.
58
+
59
+ Programmatically, the current branding is available on every page through the
60
+ `branding` Inertia shared prop (`appName`, `primaryColor`, `logoUrl`,
61
+ `faviconUrl`).
62
+
63
+ ## How it works
64
+
65
+ - **Storage.** The four values are persisted via the `settings` module's store
66
+ (SYSTEM scope) — there is no branding database table. They hydrate into
67
+ `app.state.branding.settings` at boot and hot-swap on save.
68
+ - **Images.** Logo and favicon uploads are stored through the `file_storage`
69
+ module (referenced by UUID) and served from its download endpoint.
70
+ - **Delivery.** A registered Inertia shared-props provider injects a `branding`
71
+ block into every page's shared props (authenticated *and* guest), which the
72
+ frontend reads for the name, logo, favicon and colour.
73
+
74
+ ## Permissions
75
+
76
+ - `branding.view` — view the branding admin page.
77
+ - `branding.manage` — change branding (name, colour, logo, favicon).
78
+
79
+ ## Dependencies
80
+
81
+ Depends on the `Settings` and `FileStorage` modules.
82
+
83
+ ## Notes
84
+
85
+ - Branding is currently SYSTEM-scoped (one identity per deployment). The
86
+ settings store already supports tenant/user scope, leaving room for
87
+ per-tenant branding later.
88
+ - The primary colour overrides the `--primary` / `--sidebar-primary` CSS
89
+ variables from a single hex; the full OKLCH colour scale is not regenerated.
90
+
91
+ License: MIT
@@ -0,0 +1,21 @@
1
+ branding/__init__.py,sha256=Oa-2vuIajUpsnnyueXSEHc9vcvd6_2nSMdlO7r53lRs,23
2
+ branding/constants.py,sha256=-sTqm2wlKsxyZwjhIVqzWlnGeU06FAb2ExbC6iFZMbo,2358
3
+ branding/deps.py,sha256=fOSykBZE9Oo5CxMUiJ-3XnX8znkc8Vkzq0H55W4rU9w,534
4
+ branding/module.py,sha256=RPI7t7jYwVf6e7MMldPhwZYxd3iZa0SFvpipEhCEU9Y,2565
5
+ branding/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ branding/service.py,sha256=fCmCTrdmBtf8AOHBZi0itLuymBMKm3U8HNy5ac5Lzf0,2313
7
+ branding/services.py,sha256=9vIoBm7XXajTKrKSbtzLFZUQd8GFSjbCy2a40SeiHow,483
8
+ branding/settings.py,sha256=IFl0nQf-_otR1xTlSn6osZ-xGtfpJmrsKW4km0RPSSs,1520
9
+ branding/shared_props.py,sha256=ams6xwtLuVHOFTTp479B0cZlsnPDxQg2z6pvLauA7qU,1477
10
+ branding/contracts/__init__.py,sha256=t-odwPDL0_VvnvZeTE_-kf9r_DlkJ8ChLCgb0hgXaDA,189
11
+ branding/contracts/schemas.py,sha256=du41MIj9PKRC7ik5gSH4AvALW1CcJBXIpDO4eLQlXJs,1490
12
+ branding/endpoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ branding/endpoints/api.py,sha256=HfWR3ntnx7T4s98QDUOr1K5j8l9FzuKBd-WuolP6tlk,2661
14
+ branding/endpoints/views.py,sha256=TFYFKBB48DhQ5uajOV4WZm6-kitACq8_jbpAn44stc0,1026
15
+ branding/locales/en.json,sha256=YefFECIyrc9ws9ug2Dvpj5CK1xoa9pjYrDA9M5RoPv4,966
16
+ branding/pages/Manage.tsx,sha256=LWUnGaz7YAfos9O1k2VlObQKplmLQF-4ZUhruZ5uaJ8,9156
17
+ branding/package.json,sha256=JL1APdonxmY0KeDEQToAiSVsLP3G1Ez3L1MI9tpapps,379
18
+ simple_module_branding-0.0.24.dist-info/METADATA,sha256=x4yAeJM6mf0WpQsbmi88sTnmxFe_mYhBvr77JN1F1A0,3572
19
+ simple_module_branding-0.0.24.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
20
+ simple_module_branding-0.0.24.dist-info/entry_points.txt,sha256=G-jt7Ul0Kc8Q3xE72Dts_LmwGG-s8MDQe3HEWoPysfk,58
21
+ simple_module_branding-0.0.24.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [simple_module]
2
+ branding = branding.module:BrandingModule