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
« 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
6from cachetools import TTLCache, cached
7from decouple import config
8from fastapi import HTTPException, status
10import schemas
11from chalicelib.core import users
12from chalicelib.utils import pg_client, helper
13from chalicelib.utils.TimeUTC import TimeUTC
15logger = logging.getLogger(__name__)
18# Ignore tenant_id for all queries because this version is for FOSS, it supports 1 single tenant.
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})
29 cur.execute(query=query)
30 row = cur.fetchone()
31 return row["exists"]
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
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())
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)
63cache = TTLCache(maxsize=5000, ttl=config("PROJECTS_CACHE_TTL_S", cast=int, default=20))
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"""
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")
120 return helper.list_to_camel_case(rows)
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)
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())}
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())}
168def delete(tenant_id, user_id, project_id):
169 admin = users.get_user(user_id=user_id, tenant_id=tenant_id)
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"}}
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
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
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)
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
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())
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)
263 return changes
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"]]
295 return row
298def validate_conditions(conditions: List[schemas.ProjectConditions]) -> List[str]:
299 errors = []
300 names = [condition.name for condition in conditions]
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")
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}")
312 return errors
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)
320 conditions = []
321 for condition in changes.conditions:
322 conditions.append(condition.model_dump())
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)
337 return update_project_conditions(project_id, changes.conditions)
340def create_project_conditions(project_id, conditions):
341 rows = []
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 )
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 """
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()
367 return rows
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)
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 """
389 with pg_client.PostgresClient() as cur:
390 query = cur.mogrify(sql, params)
391 cur.execute(query)
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 """
402 with pg_client.PostgresClient() as cur:
403 query = cur.mogrify(sql, {"project_id": project_id, "ids": tuple(ids)})
404 cur.execute(query)
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
411 existing = get_conditions(project_id)["conditions"]
412 existing_ids = {c.condition_id for c in existing}
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}
418 if to_be_deleted:
419 delete_project_condition(project_id, to_be_deleted)
421 if to_be_created:
422 create_project_conditions(project_id, to_be_created)
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)
427 return get_conditions(project_id)
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]
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);"""
455 with pg_client.PostgresClient() as cur:
456 query = cur.mogrify(sql, {"project_id": project_id, "metadata_key": metadata_key})
457 cur.execute(query)
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);"""
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)
481# TODO: make project conditions use metadata-column-name instead of metadata-key