Coverage for app/backend/src/couchers/resources.py: 93%
89 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 00:32 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 00:32 +0000
1import functools
2import json
3import logging
4from collections.abc import Mapping
5from dataclasses import dataclass
6from pathlib import Path
7from typing import Any, cast
9from sqlalchemy import select
10from sqlalchemy.orm import Session
11from sqlalchemy.sql import delete, text
13from couchers.config import config
14from couchers.db import session_scope
15from couchers.models import Language, Region, TimezoneArea
17logger = logging.getLogger(__name__)
19resources_folder = Path(__file__).parent / ".." / ".." / "resources"
22@functools.cache
23def get_terms_of_service() -> str:
24 """
25 Get the latest terms of service
26 """
27 with open(resources_folder / "terms_of_service.md", "r") as f:
28 return f.read()
31@functools.cache
32def get_icon(name: str) -> str:
33 """
34 Get an icon SVG by name
35 """
36 return (resources_folder / "icons" / name).read_text()
39@functools.cache
40def get_region_dict() -> Mapping[str, str]:
41 """
42 Get a list of allowed regions as a dictionary of {alpha3: name}.
43 """
44 with session_scope() as session:
45 return {region.code: region.name for region in session.execute(select(Region)).scalars().all()}
48@functools.cache
49def get_region_code_iso3166_alpha3_to_alpha2() -> Mapping[str, str]:
50 """
51 Gets a best-effort mapping table from ISO-3166 alpha3 to alpha2 codes.
52 The alpha3 keys of get_region_dict() are not guaranteed to be present in this table.
53 Valid region codes, this mapping, and localized strings are all versioned separately (database / repo / CLDR).
54 """
55 with open(resources_folder / "regions.json", "r") as f:
56 json_regions = json.load(f)
57 return {region["alpha3"]: region["alpha2"] for region in json_regions if "alpha2" in region}
60def region_is_allowed(code: str) -> bool:
61 """
62 Check a region code is valid
63 """
64 return code in get_region_dict()
67@functools.cache
68def get_language_dict() -> Mapping[str, str]:
69 """
70 Get a list of allowed languages as a dictionary of {code: name}.
71 """
72 with session_scope() as session:
73 return {language.code: language.name for language in session.execute(select(Language)).scalars().all()}
76@functools.cache
77def get_badge_data() -> Mapping[str, Any]:
78 """
79 Get a list of profile badges in form {id: Badge}
80 """
81 with open(resources_folder / "badges.json", "r") as f:
82 data = json.load(f)
83 return cast(dict[str, Any], data)
86@dataclass(frozen=True, slots=True, kw_only=True)
87class Badge:
88 """Defines a profile badge that can be awarded to users."""
90 id: str
91 color: str
92 admin_editable: bool
93 # if set, the badge is only awarded while this feature flag is on (the flag defaults to on, so
94 # the badge keeps being awarded until the flag is turned off)
95 flag: str | None = None
98@functools.cache
99def get_badge_dict() -> Mapping[str, Badge]:
100 """
101 Get a list of profile badges in form {id: Badge}
102 """
103 badges = [Badge(**b) for b in get_badge_data()["badges"]]
104 return {badge.id: badge for badge in badges}
107@functools.cache
108def get_static_badge_dict() -> Mapping[str, list[int]]:
109 """
110 Get a list of static badges in form {id: list(user_ids)}
111 """
112 data = get_badge_data()["static_badges"]
113 return cast(dict[str, list[int]], data)
116def language_is_allowed(code: str) -> bool:
117 """
118 Check a language code is valid
119 """
120 return code in get_language_dict()
123@functools.cache
124def get_postcard_front_image() -> bytes:
125 """
126 Returns the front image of the postcard as PNG bytes.
127 """
128 return (resources_folder / "postcard-front.png").read_bytes()
131@functools.cache
132def get_postcard_font() -> bytes:
133 """
134 Returns the font file for postcard text rendering.
135 """
136 return (resources_folder / "hack-bold.ttf").read_bytes()
139@functools.cache
140def get_postcard_metadata() -> Mapping[str, Any]:
141 """
142 Returns the postcard metadata (coordinates, sizes, etc.) from postcard-metadata.json.
143 """
144 return cast(dict[str, Any], json.loads((resources_folder / "postcard-metadata.json").read_text()))
147@functools.cache
148def get_postcard_back_left_template() -> bytes:
149 """
150 Returns the back left side template image for the postcard as PNG bytes.
151 """
152 return (resources_folder / "postcard-back-left.png").read_bytes()
155def copy_resources_to_database(session: Session) -> None:
156 """
157 Syncs the source-of-truth data from files into the database. Call this at the end of a migration.
159 Foreign key constraints that refer to resource tables need to be set to DEFERRABLE.
161 We sync as follows:
163 1. Lock the table to be updated fully
164 2. Defer all constraints
165 3. Truncate the table
166 4. Re-insert everything
168 Truncating and recreating guarantees the data is fully in sync.
169 """
170 with open(resources_folder / "regions.json", "r") as f:
171 regions = [(region["alpha3"], region["name"]) for region in json.load(f)]
173 with (
174 open(resources_folder / "languages-iso639.json", "r") as f1,
175 open(resources_folder / "languages-custom.json", "r") as f2,
176 ):
177 languages = [(language["alpha3"], language["name"]) for language in (json.load(f1) + json.load(f2))]
179 timezone_areas_file = resources_folder / "timezone_areas.sql"
181 if not timezone_areas_file.exists(): 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true
182 if not config.DEV:
183 raise Exception("Missing timezone_areas.sql and not running in dev")
185 timezone_areas_file = resources_folder / "timezone_areas.sql-fake"
186 logger.info("Using fake timezone areas")
188 with open(timezone_areas_file, "r") as f:
189 tz_sql = f.read()
191 # set all constraints marked as DEFERRABLE to be checked at the end of this transaction, not immediately
192 session.execute(text("SET CONSTRAINTS ALL DEFERRED"))
194 session.execute(delete(Region))
195 for code, name in regions:
196 session.add(Region(code=code, name=name))
198 session.execute(delete(Language))
199 for code, name in languages:
200 session.add(Language(code=code, name=name))
202 session.execute(delete(TimezoneArea))
203 session.execute(text(tz_sql))