Coverage for app/models.py: 100.00%
162 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
1import enum
2import uuid
4from sqlalchemy import (
5 Boolean,
6 Column,
7 DateTime,
8 Enum,
9 ForeignKey,
10 Integer,
11 LargeBinary,
12 String,
13 Text,
14 UniqueConstraint,
15)
16from sqlalchemy.dialects.postgresql import UUID
17from sqlalchemy.orm import relationship
19from app.database import Base
20from app.utils.time import utcnow
23# ----------------------------------------------------------------
24# ENUMS
25# ----------------------------------------------------------------
26class UserRole(str, enum.Enum):
27 STUDENT = "student"
28 TEACHER = "teacher"
29 ADMIN = "admin"
32class AppVersionApprovalStatus(str, enum.Enum):
33 PENDING = "pending"
34 APPROVED = "approved"
35 REJECTED = "rejected"
38class TaskType(str, enum.Enum):
39 DEPLOY = "deploy"
40 UPDATE = "update"
41 DESTROY = "destroy"
42 PAUSE = "pause"
43 RESUME = "resume"
44 # Single-resource redeploy of ONE Compute-Instance via
45 # ``terraform apply -replace=<addr> -target=<addr>``, without
46 # touching the other team VMs of the same deployment.
47 REDEPLOY = "redeploy"
50class TaskStatus(str, enum.Enum):
51 PENDING = "pending"
52 RUNNING = "running"
53 SUCCESS = "success"
54 FAILED = "failed"
55 CANCELLED = "cancelled"
58class OpenStackAuthType(str, enum.Enum):
59 APPLICATION_CREDENTIAL = "v3applicationcredential"
60 PASSWORD = "password"
63# ----------------------------------------------------------------
64# COURSE MODEL
65# ----------------------------------------------------------------
66class Course(Base):
67 __tablename__ = "courses"
69 courseId = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
70 name = Column(String, nullable=False)
72 # Relationships
73 users = relationship("User", back_populates="course")
74 # Many-to-many to User via the ``course_teachers`` join table:
75 # a course can have several teachers and a teacher several courses.
76 # Backs the ``is_course_teacher`` capability check.
77 course_teachers = relationship(
78 "CourseTeacher",
79 back_populates="course",
80 cascade="all, delete-orphan",
81 passive_deletes=True,
82 )
85# ----------------------------------------------------------------
86# USER MODEL
87# ----------------------------------------------------------------
88class User(Base):
89 __tablename__ = "users"
91 userId = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
92 keycloak_id = Column(String, unique=True, index=True, nullable=True) # Keycloak User ID (sub)
93 email = Column(String, unique=True, index=True, nullable=False)
94 username = Column(String, nullable=False)
95 firstName = Column(String, nullable=True)
96 lastName = Column(String, nullable=True)
97 role = Column(Enum(UserRole), nullable=False, default=UserRole.STUDENT)
98 courseId = Column(UUID(as_uuid=True), ForeignKey("courses.courseId"), nullable=True, index=True)
99 created_at = Column(DateTime, default=utcnow)
101 # Relationships
102 course = relationship("Course", back_populates="users")
103 apps = relationship("App", back_populates="user")
104 deployments = relationship("Deployment", back_populates="user")
105 user_to_deployments = relationship("UserToDeployment", back_populates="user")
106 user_to_teams = relationship("UserToTeam", back_populates="user")
107 course_teacher_links = relationship(
108 "CourseTeacher",
109 back_populates="user",
110 cascade="all, delete-orphan",
111 passive_deletes=True,
112 )
113 openstack_credential = relationship(
114 "UserOpenStackCredential",
115 back_populates="user",
116 uselist=False,
117 cascade="all, delete-orphan",
118 passive_deletes=True,
119 )
122# ----------------------------------------------------------------
123# APP MODEL
124# ----------------------------------------------------------------
125class App(Base):
126 __tablename__ = "apps"
128 appId = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
129 name = Column(String, nullable=False)
130 description = Column(String, nullable=True)
131 image = Column(LargeBinary, nullable=True) # raw bytes of the uploaded logo
132 image_mime = Column(String(64), nullable=True) # e.g. "image/png" — needed to build a data-URL on read
133 git_link = Column(String, nullable=True)
134 is_private = Column(Boolean, nullable=False, default=False)
135 userId = Column(UUID(as_uuid=True), ForeignKey("users.userId"), nullable=False, index=True)
136 created_at = Column(DateTime, default=utcnow)
137 # Soft-delete marker. When set, the app is hidden from default
138 # queries but the row stays so existing deployments keep their FK
139 # valid. Soft-delete is refused while the app has live deployments.
140 deleted_at = Column(DateTime, nullable=True)
142 # Relationships
143 user = relationship("User", back_populates="apps")
144 deployments = relationship("Deployment", back_populates="app")
145 version_approvals = relationship(
146 "AppVersionApproval",
147 back_populates="app",
148 cascade="all, delete-orphan",
149 passive_deletes=True,
150 )
153# ----------------------------------------------------------------
154# DEPLOYMENT MODEL
155# ----------------------------------------------------------------
156class Deployment(Base):
157 __tablename__ = "deployments"
159 deploymentId = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
160 name = Column(String, nullable=False)
161 releaseTag = Column(String, nullable=True)
162 userInputVar = Column(Text, nullable=True) # could also be JSON
163 userId = Column(UUID(as_uuid=True), ForeignKey("users.userId"), nullable=False, index=True)
164 appId = Column(UUID(as_uuid=True), ForeignKey("apps.appId"), nullable=False, index=True)
165 # Soft-delete marker. Set to hide the deployment from default
166 # queries while keeping the row for audit/restore. DELETE is only
167 # allowed in terminal states, so OpenStack resources are already
168 # gone by the time this is set.
169 deleted_at = Column(DateTime, nullable=True)
171 # Relationships
172 user = relationship("User", back_populates="deployments")
173 app = relationship("App", back_populates="deployments")
174 user_to_deployments = relationship(
175 "UserToDeployment",
176 back_populates="deployment",
177 cascade="all, delete-orphan",
178 passive_deletes=True,
179 )
180 teams = relationship(
181 "Team",
182 back_populates="deployment",
183 cascade="all, delete-orphan",
184 passive_deletes=True,
185 )
186 tasks = relationship(
187 "Task",
188 back_populates="deployment",
189 cascade="all, delete-orphan",
190 passive_deletes=True,
191 )
194# ----------------------------------------------------------------
195# TASK MODEL
196# ----------------------------------------------------------------
197class Task(Base):
198 __tablename__ = "tasks"
200 taskId = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
201 deploymentId = Column(
202 UUID(as_uuid=True),
203 ForeignKey("deployments.deploymentId", ondelete="CASCADE"),
204 nullable=False,
205 index=True,
206 )
207 celeryTaskId = Column(String, nullable=True)
208 type = Column(Enum(TaskType), nullable=False)
209 status = Column(Enum(TaskStatus), default=TaskStatus.PENDING)
210 started_at = Column(DateTime, nullable=True)
211 finished_at = Column(DateTime, nullable=True)
212 logs = Column(Text, nullable=True) # JSON or text
213 tf_state = Column(Text, nullable=True) # Terraform state as JSON/text
214 outputs = Column(Text, nullable=True) # Terraform outputs as JSON/text
215 # Live-progress columns, updated by the celery event listener on
216 # each ``task-progress`` event. Advisory only — the SSE stream is
217 # canonical; these let a page reload show the last known phase/pct.
218 current_phase = Column(String(50), nullable=True)
219 progress_pct = Column(Integer, nullable=True)
220 created_at = Column(DateTime, default=utcnow)
222 # Relationships
223 deployment = relationship("Deployment", back_populates="tasks")
226# ----------------------------------------------------------------
227# USERTODEPLOYMENT MODEL
228# ----------------------------------------------------------------
229class UserToDeployment(Base):
230 __tablename__ = "user_to_deployments"
232 userToDeploymentId = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
233 userId = Column(
234 UUID(as_uuid=True),
235 ForeignKey("users.userId", ondelete="CASCADE"),
236 nullable=False,
237 index=True,
238 )
239 deploymentId = Column(
240 UUID(as_uuid=True),
241 ForeignKey("deployments.deploymentId", ondelete="CASCADE"),
242 nullable=False,
243 index=True,
244 )
246 # Relationships
247 user = relationship("User", back_populates="user_to_deployments")
248 deployment = relationship("Deployment", back_populates="user_to_deployments")
251# ----------------------------------------------------------------
252# TEAM MODEL
253# ----------------------------------------------------------------
254class Team(Base):
255 __tablename__ = "teams"
257 teamId = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
258 name = Column(String, nullable=False)
259 deploymentId = Column(
260 UUID(as_uuid=True),
261 ForeignKey("deployments.deploymentId", ondelete="CASCADE"),
262 nullable=False,
263 index=True,
264 )
266 # Relationships
267 deployment = relationship("Deployment", back_populates="teams")
268 user_to_teams = relationship(
269 "UserToTeam",
270 back_populates="team",
271 cascade="all, delete-orphan",
272 passive_deletes=True,
273 )
276# ----------------------------------------------------------------
277# USERTOTEAM MODEL
278# ----------------------------------------------------------------
279class UserToTeam(Base):
280 __tablename__ = "user_to_teams"
282 userToTeamId = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
283 userId = Column(
284 UUID(as_uuid=True),
285 ForeignKey("users.userId", ondelete="CASCADE"),
286 nullable=False,
287 index=True,
288 )
289 teamId = Column(
290 UUID(as_uuid=True),
291 ForeignKey("teams.teamId", ondelete="CASCADE"),
292 nullable=False,
293 index=True,
294 )
296 # Relationships
297 user = relationship("User", back_populates="user_to_teams")
298 team = relationship("Team", back_populates="user_to_teams")
301# ----------------------------------------------------------------
302# USER OPENSTACK CREDENTIAL MODEL
303# ----------------------------------------------------------------
304class UserOpenStackCredential(Base):
305 __tablename__ = "user_openstack_credentials"
307 credentialId = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
308 userId = Column(
309 UUID(as_uuid=True),
310 ForeignKey("users.userId", ondelete="CASCADE"),
311 nullable=False,
312 unique=True,
313 index=True,
314 )
315 auth_type = Column(
316 Enum(
317 OpenStackAuthType,
318 name="openstackauthtype",
319 values_callable=lambda x: [e.value for e in x],
320 ),
321 nullable=False,
322 )
324 # Non-secret display fields (plaintext)
325 auth_url = Column(String, nullable=False)
326 region_name = Column(String, nullable=True)
327 interface = Column(String, nullable=True, default="public")
328 identity_api_version = Column(String, nullable=True, default="3")
329 project_id = Column(String, nullable=True)
330 project_name = Column(String, nullable=True)
331 user_domain_name = Column(String, nullable=True)
332 project_domain_name = Column(String, nullable=True)
334 # Encrypted (Fernet ciphertext) — never logged, never returned via API
335 encrypted_identifier = Column(LargeBinary, nullable=False)
336 encrypted_secret = Column(LargeBinary, nullable=False)
338 last_validated_at = Column(DateTime, nullable=True)
339 last_validation_error = Column(Text, nullable=True)
340 created_at = Column(DateTime, default=utcnow)
341 updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
343 user = relationship("User", back_populates="openstack_credential")
346# ----------------------------------------------------------------
347# APP VERSION APPROVAL MODEL
348# ----------------------------------------------------------------
349class AppVersionApproval(Base):
350 __tablename__ = "app_version_approvals"
352 approvalId = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
353 appId = Column(
354 UUID(as_uuid=True),
355 ForeignKey("apps.appId", ondelete="CASCADE"),
356 nullable=False,
357 index=True,
358 )
359 version_tag = Column(String, nullable=False)
360 status = Column(
361 Enum(AppVersionApprovalStatus),
362 nullable=False,
363 default=AppVersionApprovalStatus.PENDING,
364 )
365 diff_url = Column(String, nullable=True)
366 notes = Column(Text, nullable=True)
367 rejection_reason = Column(Text, nullable=True)
368 reviewed_by = Column(
369 UUID(as_uuid=True),
370 ForeignKey("users.userId", ondelete="SET NULL"),
371 nullable=True,
372 index=True,
373 )
374 reviewed_at = Column(DateTime, nullable=True)
375 created_at = Column(DateTime, default=utcnow, nullable=False)
377 __table_args__ = (
378 UniqueConstraint("appId", "version_tag", name="uq_app_version_approval"),
379 )
381 # Relationships
382 app = relationship("App", back_populates="version_approvals")
383 reviewer = relationship("User", foreign_keys=[reviewed_by])
386# ----------------------------------------------------------------
387# COURSE TEACHER MODEL
388# ----------------------------------------------------------------
389# Many-to-many join between courses and users. A row ``(course_id,
390# user_id)`` declares that ``user`` is one of the teachers responsible
391# for ``course``. Backs the per-course "course-teacher" capability.
392# Both FKs together form the composite primary key.
393class CourseTeacher(Base):
394 __tablename__ = "course_teachers"
396 courseId = Column(
397 "course_id",
398 UUID(as_uuid=True),
399 ForeignKey("courses.courseId", ondelete="CASCADE"),
400 primary_key=True,
401 )
402 userId = Column(
403 "user_id",
404 UUID(as_uuid=True),
405 ForeignKey("users.userId", ondelete="CASCADE"),
406 primary_key=True,
407 index=True,
408 )
410 # Relationships
411 course = relationship("Course", back_populates="course_teachers")
412 user = relationship("User", back_populates="course_teacher_links")