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

1"""Editing a theme must never change it for everybody else. 

2 

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. 

7 

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. 

14 

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. 

17 

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. 

25 

26Developer: Allan Ninal 

27Date: 2026-09-23 

28""" 

29 

30from datetime import datetime, timezone 

31 

32from server.models.themes import Theme 

33 

34 

35def merge_nested(stored, sent) -> dict: 

36 """The full font/layout dict: the STORED values with whatever was sent laid over. 

37 

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)} 

44 

45 

46def is_owned_by(theme, user_id) -> bool: 

47 """True when `user_id` owns this theme outright and may edit it in place. 

48 

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) 

66 

67 

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. 

70 

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