Coverage for server / services / common / theme_ownership.py: 100%
16 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""Editing a theme must never change it for everybody else.
3`themes_collection` is a CATALOG: a handful of seeded presets that many users
4point at through `user_themes_collection`. The per-user "update my theme"
5endpoints wrote straight into that shared row, so one user editing "their" theme
6edited it for everyone on it.
8That is not theoretical. Measured on QA before this fix:
9 * teacher-student : the single `is_default` theme was named
10 "Updated-1790105988" — test debris that had overwritten "Default Light" for
11 the 23 user_themes pointing at it.
12 * admin-staff : the same-id default was named "Theme-6633", shared by
13 **963 of 970** user_themes.
15OWASP calls this shape API1:2023 Broken Object Level Authorization — the write
16path resolved an id and mutated it without asking whose object it was.
18CLONE-ON-EDIT rather than a hard refusal
19----------------------------------------
20Editing a preset you do not own now COPIES it into a theme you do own, applies
21the edit to the copy, and repoints your UserTheme at it. Nobody loses the
22ability to customise, the shared row is never written, and the data model moves
23toward the one the schema already anticipated (`user_themes.custom_settings`,
24declared and never used). A theme you already own is still edited in place.
26Developer: Allan Ninal
27Date: 2026-09-23
28"""
30from datetime import datetime, timezone
32from server.models.themes import Theme
35def merge_nested(stored, sent) -> dict:
36 """The full font/layout dict: the STORED values with whatever was sent laid over.
38 #375 (Allan Ninal, 2026-10-04): font/layout updates are partial, so a field the
39 caller did not send (or sent as null) keeps the value already stored. `stored`
40 may be a model or a dict; `sent` is the update-only model.
41 """
42 base = stored if isinstance(stored, dict) else stored.model_dump()
43 return {**base, **sent.model_dump(exclude_none=True)}
46def is_owned_by(theme, user_id) -> bool:
47 """True when `user_id` owns this theme outright and may edit it in place.
49 A catalog preset carries no `created_by` — nobody owns it. `is_default` is
50 never editable in place whatever its owner says, because it is the row every
51 user falls back to.
52 """
53 created_by = (
54 theme.get("created_by")
55 if isinstance(theme, dict)
56 else getattr(theme, "created_by", None)
57 )
58 is_default = (
59 theme.get("is_default")
60 if isinstance(theme, dict)
61 else getattr(theme, "is_default", False)
62 )
63 if created_by is None or is_default:
64 return False
65 return str(created_by) == str(user_id)
68async def clone_theme_for_user(theme, user_id, update_fields: dict) -> Theme:
69 """Copy a shared preset into a theme `user_id` owns, with the edits applied.
71 The copy is never `is_default` and always carries `created_by`, so the next
72 edit of it goes in place via `is_owned_by` — a user accumulates one personal
73 theme, not one per edit.
74 """
75 now = datetime.now(timezone.utc)
76 clone = Theme(
77 theme_name=update_fields.get("theme_name", theme.theme_name),
78 description=getattr(theme, "description", None),
79 is_default=False,
80 is_active=True,
81 color_mode=getattr(theme, "color_mode", "light"),
82 created_at=now,
83 updated_at=now,
84 created_by=user_id,
85 colors=update_fields.get("colors", theme.colors),
86 font=update_fields.get("font", theme.font),
87 layout=update_fields.get("layout", theme.layout),
88 custom_properties=update_fields.get(
89 "custom_properties", getattr(theme, "custom_properties", None)
90 ),
91 )
92 await clone.insert()
93 return clone