Coverage for chalicelib/core/projects.py: 78%

231 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:56 +0000

1import json 

2import logging 

3from collections import Counter 

4from typing import Optional, List 

5 

6from cachetools import TTLCache, cached 

7from decouple import config 

8from fastapi import HTTPException, status 

9 

10import schemas 

11from chalicelib.core import users 

12from chalicelib.utils import pg_client, helper 

13from chalicelib.utils.TimeUTC import TimeUTC 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18# Ignore tenant_id for all queries because this version is for FOSS, it supports 1 single tenant. 

19 

20def __exists_by_name(name: str, exclude_id: Optional[int]) -> bool: 

21 with pg_client.PostgresClient() as cur: 

22 query = cur.mogrify(f"""SELECT EXISTS(SELECT 1 

23 FROM public.projects 

24 WHERE deleted_at IS NULL 

25 AND name ILIKE %(name)s 

26 {"AND project_id!=%(exclude_id)s" if exclude_id else ""}) AS exists;""", 

27 {"name": name, "exclude_id": exclude_id}) 

28 

29 cur.execute(query=query) 

30 row = cur.fetchone() 

31 return row["exists"] 

32 

33 

34def __update(tenant_id, project_id, changes): 

35 if len(changes.keys()) == 0: 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true

36 return None 

37 

38 sub_query = [] 

39 for key in changes.keys(): 

40 sub_query.append(f"{helper.key_to_snake_case(key)} = %({key})s") 

41 with pg_client.PostgresClient() as cur: 

42 query = cur.mogrify(f"""UPDATE public.projects  

43 SET {" ,".join(sub_query)}  

44 WHERE project_id = %(project_id)s 

45 AND deleted_at ISNULL 

46 RETURNING project_id,name,gdpr;""", 

47 {"project_id": project_id, **changes}) 

48 cur.execute(query=query) 

49 return helper.dict_to_camel_case(cur.fetchone()) 

50 

51 

52def __create(tenant_id, data): 

53 with pg_client.PostgresClient() as cur: 

54 query = cur.mogrify(f"""INSERT INTO public.projects (name, platform, active) 

55 VALUES (%(name)s,%(platform)s,TRUE) 

56 RETURNING project_id;""", 

57 data) 

58 cur.execute(query=query) 

59 project_id = cur.fetchone()["project_id"] 

60 return get_project(tenant_id=tenant_id, project_id=project_id, include_gdpr=True) 

61 

62 

63cache = TTLCache(maxsize=5000, ttl=config("PROJECTS_CACHE_TTL_S", cast=int, default=20)) 

64 

65 

66@cached(cache) 

67def get_projects(tenant_id: int, gdpr: bool = False, recorded: bool = False): 

68 with pg_client.PostgresClient() as cur: 

69 extra_projection = "" 

70 if gdpr: 70 ↛ 72line 70 didn't jump to line 72 because the condition on line 70 was always true

71 extra_projection += ',s.gdpr' 

72 if recorded: 72 ↛ 82line 72 didn't jump to line 82 because the condition on line 72 was always true

73 extra_projection += """,\nCOALESCE(EXTRACT(EPOCH FROM s.first_recorded_session_at) * 1000::BIGINT, 

74 (SELECT MIN(sessions.start_ts) 

75 FROM public.sessions 

76 WHERE sessions.project_id = s.project_id 

77 AND sessions.start_ts >= (EXTRACT(EPOCH  

78 FROM COALESCE(s.sessions_last_check_at, s.created_at)) * 1000-%(check_delta)s) 

79 AND sessions.start_ts <= %(now)s 

80 )) AS first_recorded""" 

81 

82 query = cur.mogrify(f"""{"SELECT *, first_recorded IS NOT NULL AS recorded FROM (" if recorded else ""} 

83 SELECT s.project_id, s.name, s.project_key, s.save_request_payloads, s.first_recorded_session_at, 

84 s.created_at, s.sessions_last_check_at, s.sample_rate, s.platform, 

85 (SELECT count(*) FROM projects_conditions WHERE project_id = s.project_id) AS conditions_count  

86 {extra_projection} 

87 FROM public.projects AS s 

88 WHERE s.deleted_at IS NULL 

89 ORDER BY s.name {") AS raw" if recorded else ""};""", 

90 {"now": TimeUTC.now(), "check_delta": TimeUTC.MS_HOUR * 4}) 

91 cur.execute(query) 

92 rows = cur.fetchall() 

93 # if recorded is requested, check if it was saved or computed 

94 if recorded: 94 ↛ 116line 94 didn't jump to line 116 because the condition on line 94 was always true

95 u_values = [] 

96 params = {} 

97 for i, r in enumerate(rows): 

98 r["sessions_last_check_at"] = TimeUTC.datetime_to_timestamp(r["sessions_last_check_at"]) 

99 r["created_at"] = TimeUTC.datetime_to_timestamp(r["created_at"]) 

100 if r["first_recorded_session_at"] is None \ 100 ↛ 103line 100 didn't jump to line 103 because the condition on line 100 was never true

101 and r["sessions_last_check_at"] is not None \ 

102 and (TimeUTC.now() - r["sessions_last_check_at"]) > TimeUTC.MS_HOUR: 

103 u_values.append(f"(%(project_id_{i})s,to_timestamp(%(first_recorded_{i})s/1000))") 

104 params[f"project_id_{i}"] = r["project_id"] 

105 params[f"first_recorded_{i}"] = r["first_recorded"] if r["recorded"] else None 

106 r.pop("first_recorded_session_at") 

107 r.pop("first_recorded") 

108 r.pop("sessions_last_check_at") 

109 if len(u_values) > 0: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true

110 query = cur.mogrify(f"""UPDATE public.projects  

111 SET sessions_last_check_at=(now() at time zone 'utc'), first_recorded_session_at=u.first_recorded 

112 FROM (VALUES {",".join(u_values)}) AS u(project_id,first_recorded) 

113 WHERE projects.project_id=u.project_id;""", params) 

114 cur.execute(query) 

115 else: 

116 for r in rows: 

117 r["created_at"] = TimeUTC.datetime_to_timestamp(r["created_at"]) 

118 r.pop("sessions_last_check_at") 

119 

120 return helper.list_to_camel_case(rows) 

121 

122 

123def get_project(tenant_id, project_id, include_last_session=False, include_gdpr=None): 

124 with pg_client.PostgresClient() as cur: 

125 extra_select = "" 

126 if include_last_session: 

127 extra_select += """,(SELECT max(ss.start_ts)  

128 FROM public.sessions AS ss  

129 WHERE ss.project_id = %(project_id)s) AS last_recorded_session_at""" 

130 if include_gdpr: 

131 extra_select += ",s.gdpr" 

132 query = cur.mogrify(f"""SELECT s.project_id, 

133 s.project_key, 

134 s.name, 

135 s.save_request_payloads, 

136 s.platform 

137 {extra_select} 

138 FROM public.projects AS s 

139 WHERE s.project_id =%(project_id)s 

140 AND s.deleted_at IS NULL 

141 LIMIT 1;""", 

142 {"project_id": project_id}) 

143 cur.execute(query=query) 

144 row = cur.fetchone() 

145 return helper.dict_to_camel_case(row) 

146 

147 

148def create(tenant_id, user_id, data: schemas.CreateProjectSchema, skip_authorization=False): 

149 if __exists_by_name(name=data.name, exclude_id=None): 

150 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"name already exists.") 

151 if not skip_authorization: 151 ↛ 155line 151 didn't jump to line 155 because the condition on line 151 was always true

152 admin = users.get_user(user_id=user_id, tenant_id=tenant_id) 

153 if not admin["admin"] and not admin["superAdmin"]: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true

154 return {"errors": ["unauthorized"]} 

155 return {"data": __create(tenant_id=tenant_id, data=data.model_dump())} 

156 

157 

158def edit(tenant_id, user_id, project_id, data: schemas.CreateProjectSchema): 

159 if __exists_by_name(name=data.name, exclude_id=project_id): 

160 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"name already exists.") 

161 admin = users.get_user(user_id=user_id, tenant_id=tenant_id) 

162 if not admin["admin"] and not admin["superAdmin"]: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true

163 return {"errors": ["unauthorized"]} 

164 return {"data": __update(tenant_id=tenant_id, project_id=project_id, 

165 changes=data.model_dump())} 

166 

167 

168def delete(tenant_id, user_id, project_id): 

169 admin = users.get_user(user_id=user_id, tenant_id=tenant_id) 

170 

171 if not admin["admin"] and not admin["superAdmin"]: 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true

172 return {"errors": ["unauthorized"]} 

173 with pg_client.PostgresClient() as cur: 

174 query = cur.mogrify("""UPDATE public.projects 

175 SET deleted_at = timezone('utc'::text, now()), 

176 active = FALSE 

177 WHERE project_id = %(project_id)s;""", 

178 {"project_id": project_id}) 

179 cur.execute(query=query) 

180 return {"data": {"state": "success"}} 

181 

182 

183def get_gdpr(project_id): 

184 with pg_client.PostgresClient() as cur: 

185 query = cur.mogrify("""SELECT gdpr 

186 FROM public.projects AS s 

187 WHERE s.project_id = %(project_id)s 

188 AND s.deleted_at IS NULL;""", 

189 {"project_id": project_id}) 

190 cur.execute(query=query) 

191 row = cur.fetchone()["gdpr"] 

192 row["projectId"] = project_id 

193 return row 

194 

195 

196def edit_gdpr(project_id, gdpr: schemas.GdprSchema): 

197 with pg_client.PostgresClient() as cur: 

198 query = cur.mogrify("""UPDATE public.projects 

199 SET gdpr = gdpr || %(gdpr)s::jsonb 

200 WHERE project_id = %(project_id)s 

201 AND deleted_at ISNULL 

202 RETURNING gdpr;""", 

203 {"project_id": project_id, "gdpr": json.dumps(gdpr.model_dump())}) 

204 cur.execute(query=query) 

205 row = cur.fetchone() 

206 if not row: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 return {"errors": ["something went wrong"]} 

208 row = row["gdpr"] 

209 row["projectId"] = project_id 

210 return row 

211 

212 

213def get_by_project_key(project_key): 

214 with pg_client.PostgresClient() as cur: 

215 query = cur.mogrify("""SELECT project_id, 

216 project_key, 

217 platform, 

218 name 

219 FROM public.projects 

220 WHERE project_key = %(project_key)s 

221 AND deleted_at ISNULL;""", 

222 {"project_key": project_key}) 

223 cur.execute(query=query) 

224 row = cur.fetchone() 

225 return helper.dict_to_camel_case(row) 

226 

227 

228def get_project_key(project_id): 

229 with pg_client.PostgresClient() as cur: 

230 query = cur.mogrify("""SELECT project_key 

231 FROM public.projects 

232 WHERE project_id = %(project_id)s 

233 AND deleted_at ISNULL;""", 

234 {"project_id": project_id}) 

235 cur.execute(query=query) 

236 project = cur.fetchone() 

237 return project["project_key"] if project is not None else None 

238 

239 

240def get_capture_status(project_id): 

241 with pg_client.PostgresClient() as cur: 

242 query = cur.mogrify("""SELECT sample_rate AS rate, sample_rate = 100 AS capture_all 

243 FROM public.projects 

244 WHERE project_id = %(project_id)s 

245 AND deleted_at ISNULL;""", 

246 {"project_id": project_id}) 

247 cur.execute(query=query) 

248 return helper.dict_to_camel_case(cur.fetchone()) 

249 

250 

251def update_capture_status(project_id, changes: schemas.SampleRateSchema): 

252 sample_rate = changes.rate 

253 if changes.capture_all: 

254 sample_rate = 100 

255 with pg_client.PostgresClient() as cur: 

256 query = cur.mogrify("""UPDATE public.projects 

257 SET sample_rate= %(sample_rate)s 

258 WHERE project_id = %(project_id)s 

259 AND deleted_at ISNULL;""", 

260 {"project_id": project_id, "sample_rate": sample_rate}) 

261 cur.execute(query=query) 

262 

263 return changes 

264 

265 

266def get_conditions(project_id): 

267 with pg_client.PostgresClient() as cur: 

268 query = cur.mogrify("""SELECT p.sample_rate AS rate, 

269 p.conditional_capture, 

270 COALESCE( 

271 array_agg( 

272 json_build_object( 

273 'condition_id', pc.condition_id, 

274 'capture_rate', pc.capture_rate, 

275 'name', pc.name, 

276 'filters', pc.filters 

277 ) 

278 ) FILTER(WHERE pc.condition_id IS NOT NULL), 

279 ARRAY[] ::json[] 

280 ) AS conditions 

281 FROM public.projects AS p 

282 LEFT JOIN (SELECT * 

283 FROM public.projects_conditions 

284 WHERE project_id = %(project_id)s 

285 ORDER BY condition_id) AS pc ON p.project_id = pc.project_id 

286 WHERE p.project_id = %(project_id)s 

287 AND p.deleted_at IS NULL 

288 GROUP BY p.sample_rate, p.conditional_capture;""", 

289 {"project_id": project_id}) 

290 cur.execute(query=query) 

291 row = cur.fetchone() 

292 row = helper.dict_to_camel_case(row) 

293 row["conditions"] = [schemas.ProjectConditions(**c) for c in row["conditions"]] 

294 

295 return row 

296 

297 

298def validate_conditions(conditions: List[schemas.ProjectConditions]) -> List[str]: 

299 errors = [] 

300 names = [condition.name for condition in conditions] 

301 

302 # Check for empty strings 

303 if any(name.strip() == "" for name in names): 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true

304 errors.append("Condition names cannot be empty strings") 

305 

306 # Check for duplicates 

307 name_counts = Counter(names) 

308 duplicates = [name for name, count in name_counts.items() if count > 1] 

309 if duplicates: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true

310 errors.append(f"Duplicate condition names found: {duplicates}") 

311 

312 return errors 

313 

314 

315def update_conditions(project_id, changes: schemas.ProjectSettings): 

316 validation_errors = validate_conditions(changes.conditions) 

317 if validation_errors: 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true

318 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=validation_errors) 

319 

320 conditions = [] 

321 for condition in changes.conditions: 

322 conditions.append(condition.model_dump()) 

323 

324 with pg_client.PostgresClient() as cur: 

325 query = cur.mogrify("""UPDATE public.projects 

326 SET sample_rate= %(sample_rate)s, 

327 conditional_capture = %(conditional_capture)s 

328 WHERE project_id = %(project_id)s 

329 AND deleted_at IS NULL;""", 

330 { 

331 "project_id": project_id, 

332 "sample_rate": changes.rate, 

333 "conditional_capture": changes.conditional_capture 

334 }) 

335 cur.execute(query=query) 

336 

337 return update_project_conditions(project_id, changes.conditions) 

338 

339 

340def create_project_conditions(project_id, conditions): 

341 rows = [] 

342 

343 # insert all conditions rows with single sql query 

344 if len(conditions) > 0: 344 ↛ 367line 344 didn't jump to line 367 because the condition on line 344 was always true

345 columns = ( 

346 "project_id", 

347 "name", 

348 "capture_rate", 

349 "filters", 

350 ) 

351 

352 sql = f""" 

353 INSERT INTO projects_conditions 

354 (project_id, name, capture_rate, filters) 

355 VALUES {", ".join(["%s"] * len(conditions))} 

356 RETURNING condition_id, {", ".join(columns)} 

357 """ 

358 

359 with pg_client.PostgresClient() as cur: 

360 params = [ 

361 (project_id, c.name, c.capture_rate, json.dumps([filter_.model_dump() for filter_ in c.filters])) 

362 for c in conditions] 

363 query = cur.mogrify(sql, params) 

364 cur.execute(query) 

365 rows = cur.fetchall() 

366 

367 return rows 

368 

369 

370def update_project_condition(project_id, conditions): 

371 values = [] 

372 params = { 

373 "project_id": project_id, 

374 } 

375 for i in range(len(conditions)): 

376 values.append(f"(%(condition_id_{i})s, %(name_{i})s, %(capture_rate_{i})s, %(filters_{i})s::jsonb)") 

377 params[f"condition_id_{i}"] = conditions[i].condition_id 

378 params[f"name_{i}"] = conditions[i].name 

379 params[f"capture_rate_{i}"] = conditions[i].capture_rate 

380 params[f"filters_{i}"] = json.dumps(conditions[i].filters) 

381 

382 sql = f""" 

383 UPDATE projects_conditions 

384 SET name = c.name, capture_rate = c.capture_rate, filters = c.filters 

385 FROM (VALUES {','.join(values)}) AS c(condition_id, name, capture_rate, filters) 

386 WHERE c.condition_id = projects_conditions.condition_id AND project_id = %(project_id)s; 

387 """ 

388 

389 with pg_client.PostgresClient() as cur: 

390 query = cur.mogrify(sql, params) 

391 cur.execute(query) 

392 

393 

394def delete_project_condition(project_id, ids): 

395 sql = """ 

396 DELETE 

397 FROM projects_conditions 

398 WHERE condition_id IN %(ids)s 

399 AND project_id = %(project_id)s; \ 

400 """ 

401 

402 with pg_client.PostgresClient() as cur: 

403 query = cur.mogrify(sql, {"project_id": project_id, "ids": tuple(ids)}) 

404 cur.execute(query) 

405 

406 

407def update_project_conditions(project_id, conditions): 

408 if conditions is None: 408 ↛ 409line 408 didn't jump to line 409 because the condition on line 408 was never true

409 return 

410 

411 existing = get_conditions(project_id)["conditions"] 

412 existing_ids = {c.condition_id for c in existing} 

413 

414 to_be_updated = [c for c in conditions if c.condition_id in existing_ids] 

415 to_be_created = [c for c in conditions if c.condition_id not in existing_ids] 

416 to_be_deleted = existing_ids - {c.condition_id for c in conditions} 

417 

418 if to_be_deleted: 

419 delete_project_condition(project_id, to_be_deleted) 

420 

421 if to_be_created: 

422 create_project_conditions(project_id, to_be_created) 

423 

424 if to_be_updated: 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true

425 update_project_condition(project_id, to_be_updated) 

426 

427 return get_conditions(project_id) 

428 

429 

430def get_projects_ids(tenant_id): 

431 with pg_client.PostgresClient() as cur: 

432 query = f"""SELECT s.project_id 

433 FROM public.projects AS s 

434 WHERE s.deleted_at IS NULL 

435 ORDER BY s.project_id;""" 

436 cur.execute(query=query) 

437 rows = cur.fetchall() 

438 return [r["project_id"] for r in rows] 

439 

440 

441def delete_metadata_condition(project_id, metadata_key): 

442 sql = """ \ 

443 UPDATE public.projects_conditions 

444 SET filters=(SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb) 

445 FROM jsonb_array_elements(filters) AS elem 

446 WHERE NOT (elem ->> 'type' = 'metadata' 

447 AND elem ->> 'source' = %(metadata_key)s)) 

448 WHERE project_id = %(project_id)s 

449 AND jsonb_typeof(filters) = 'array' 

450 AND EXISTS (SELECT 1 

451 FROM jsonb_array_elements(filters) AS elem 

452 WHERE elem ->> 'type' = 'metadata' 

453 AND elem ->> 'source' = %(metadata_key)s);""" 

454 

455 with pg_client.PostgresClient() as cur: 

456 query = cur.mogrify(sql, {"project_id": project_id, "metadata_key": metadata_key}) 

457 cur.execute(query) 

458 

459 

460def rename_metadata_condition(project_id, old_metadata_key, new_metadata_key): 

461 sql = """ \ 

462 UPDATE public.projects_conditions 

463 SET filters = (SELECT jsonb_agg(CASE 

464 WHEN elem ->> 'type' = 'metadata' AND elem ->> 'source' = 

465 %(old_metadata_key)s 

466 THEN elem || ('{"source": "'|| %(new_metadata_key)s||'"}')::jsonb 

467 ELSE elem END) 

468 FROM jsonb_array_elements(filters) AS elem) 

469 WHERE project_id = %(project_id)s 

470 AND jsonb_typeof(filters) = 'array' 

471 AND EXISTS (SELECT 1 

472 FROM jsonb_array_elements(filters) AS elem 

473 WHERE elem ->> 'type' = 'metadata' 

474 AND elem ->> 'source' = %(old_metadata_key)s);""" 

475 

476 with pg_client.PostgresClient() as cur: 

477 query = cur.mogrify(sql, {"project_id": project_id, "old_metadata_key": old_metadata_key, 

478 "new_metadata_key": new_metadata_key}) 

479 cur.execute(query) 

480 

481# TODO: make project conditions use metadata-column-name instead of metadata-key