Coverage for app/crud/apps.py: 100.00%
52 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1from uuid import UUID
3from sqlalchemy.orm import Session
5from app.models import App, AppVersionApproval, AppVersionApprovalStatus
6from app.schemas import AppCreate, AppUpdate
7from app.utils.time import utcnow
10def get_app(
11 db: Session,
12 app_id: UUID,
13 include_deleted: bool = False,
14) -> App | None:
15 """Get app by ID. Hides soft-deleted apps by default.
17 ``include_deleted=True`` is reserved for the rare audit lookup —
18 the HTTP API never sets it.
19 """
20 q = db.query(App).filter(App.appId == app_id)
21 if not include_deleted:
22 q = q.filter(App.deleted_at.is_(None))
23 return q.first()
26def get_apps(
27 db: Session,
28 skip: int = 0,
29 limit: int = 100,
30 user_id: UUID | None = None,
31 include_deleted: bool = False,
32) -> list[App]:
33 """Get apps with optional user filter. Hides soft-deleted by default."""
34 query = db.query(App)
36 if not include_deleted:
37 query = query.filter(App.deleted_at.is_(None))
38 if user_id:
39 query = query.filter(App.userId == user_id)
41 return query.offset(skip).limit(limit).all()
44def get_visible_apps(
45 db: Session,
46 requesting_user_id: UUID,
47 skip: int = 0,
48 limit: int = 100,
49) -> list[App]:
50 """Return apps visible to the requesting user.
52 Visibility rules:
53 - Always: apps owned by the requesting user (regardless of is_private)
54 - Additionally: public apps (is_private=False) that have at least one
55 APPROVED version
56 """
57 approved_app_ids = (
58 db.query(AppVersionApproval.appId)
59 .filter(AppVersionApproval.status == AppVersionApprovalStatus.APPROVED)
60 .distinct()
61 .scalar_subquery()
62 )
64 return (
65 db.query(App)
66 .filter(App.deleted_at.is_(None))
67 .filter(
68 (App.userId == requesting_user_id)
69 | (
70 App.is_private.is_(False)
71 & App.appId.in_(approved_app_ids)
72 )
73 )
74 .offset(skip)
75 .limit(limit)
76 .all()
77 )
80def create_app(db: Session, app: AppCreate, user_id: UUID) -> App:
81 """Create a new app."""
82 db_app = App(
83 name=app.name,
84 description=app.description,
85 git_link=app.git_link,
86 is_private=app.is_private,
87 userId=user_id,
88 )
89 db.add(db_app)
90 db.commit()
91 db.refresh(db_app)
92 return db_app
95def update_app(db: Session, app_id: UUID, app_update: AppUpdate) -> App | None:
96 """Update app information.
98 The ``image`` field is intentionally NOT applied here — the router
99 decodes the data-URL via ``parse_image_data_url`` and calls
100 ``set_app_image`` directly. ``model_dump`` would otherwise pass
101 the data-URL string straight into the ``LargeBinary`` column.
103 ``git_link`` is intentionally excluded as well: once an app has
104 deployments, changing the repo would make existing deployments
105 point at a different repo than they originally deployed.
106 ``AppUpdate`` already drops the field from the schema; this
107 exclude is defense-in-depth in case the schema is replaced or
108 extended later.
109 """
110 db_app = get_app(db, app_id)
111 if not db_app:
112 return None
114 update_data = app_update.model_dump(
115 exclude_unset=True, exclude={"image", "git_link"}
116 )
117 for field, value in update_data.items():
118 setattr(db_app, field, value)
120 db.commit()
121 db.refresh(db_app)
122 return db_app
125def set_app_image(
126 db: Session,
127 app_id: UUID,
128 image_bytes: bytes | None,
129 image_mime: str | None,
130) -> App | None:
131 """Persist the image bytes + mime atomically.
133 Both args ``None`` clears the image. Otherwise both must be set —
134 the router enforces that via ``parse_image_data_url`` before
135 calling here, so this function trusts its inputs.
136 """
137 db_app = get_app(db, app_id)
138 if not db_app:
139 return None
140 db_app.image = image_bytes
141 db_app.image_mime = image_mime
142 db.commit()
143 db.refresh(db_app)
144 return db_app
147def soft_delete_app(db: Session, app_id: UUID) -> bool:
148 """Mark an app as deleted without removing the row.
150 The row stays so existing ``deployments.appId`` foreign keys keep
151 resolving, but list queries skip it. Restoring an app means clearing
152 ``deleted_at`` directly via SQL.
153 """
154 db_app = get_app(db, app_id)
155 if not db_app:
156 return False
157 db_app.deleted_at = utcnow()
158 db.commit()
159 return True