Coverage for app/backend/src/couchers/resources.py: 93%

89 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-21 21:52 +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 

8 

9from sqlalchemy import select 

10from sqlalchemy.orm import Session 

11from sqlalchemy.sql import delete, text 

12 

13from couchers.config import config 

14from couchers.db import session_scope 

15from couchers.models import Language, Region, TimezoneArea 

16 

17logger = logging.getLogger(__name__) 

18 

19resources_folder = Path(__file__).parent / ".." / ".." / "resources" 

20 

21 

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

29 

30 

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

37 

38 

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

46 

47 

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} 

58 

59 

60def region_is_allowed(code: str) -> bool: 

61 """ 

62 Check a region code is valid 

63 """ 

64 return code in get_region_dict() 

65 

66 

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

74 

75 

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) 

84 

85 

86@dataclass(frozen=True, slots=True, kw_only=True) 

87class Badge: 

88 """Defines a profile badge that can be awarded to users.""" 

89 

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 

96 

97 

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} 

105 

106 

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) 

114 

115 

116def language_is_allowed(code: str) -> bool: 

117 """ 

118 Check a language code is valid 

119 """ 

120 return code in get_language_dict() 

121 

122 

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

129 

130 

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

137 

138 

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

145 

146 

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

153 

154 

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. 

158 

159 Foreign key constraints that refer to resource tables need to be set to DEFERRABLE. 

160 

161 We sync as follows: 

162 

163 1. Lock the table to be updated fully 

164 2. Defer all constraints 

165 3. Truncate the table 

166 4. Re-insert everything 

167 

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

172 

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

178 

179 timezone_areas_file = resources_folder / "timezone_areas.sql" 

180 

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

184 

185 timezone_areas_file = resources_folder / "timezone_areas.sql-fake" 

186 logger.info("Using fake timezone areas") 

187 

188 with open(timezone_areas_file, "r") as f: 

189 tz_sql = f.read() 

190 

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

193 

194 session.execute(delete(Region)) 

195 for code, name in regions: 

196 session.add(Region(code=code, name=name)) 

197 

198 session.execute(delete(Language)) 

199 for code, name in languages: 

200 session.add(Language(code=code, name=name)) 

201 

202 session.execute(delete(TimezoneArea)) 

203 session.execute(text(tz_sql))