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

1import enum 

2import uuid 

3 

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 

18 

19from app.database import Base 

20from app.utils.time import utcnow 

21 

22 

23# ---------------------------------------------------------------- 

24# ENUMS 

25# ---------------------------------------------------------------- 

26class UserRole(str, enum.Enum): 

27 STUDENT = "student" 

28 TEACHER = "teacher" 

29 ADMIN = "admin" 

30 

31 

32class AppVersionApprovalStatus(str, enum.Enum): 

33 PENDING = "pending" 

34 APPROVED = "approved" 

35 REJECTED = "rejected" 

36 

37 

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" 

48 

49 

50class TaskStatus(str, enum.Enum): 

51 PENDING = "pending" 

52 RUNNING = "running" 

53 SUCCESS = "success" 

54 FAILED = "failed" 

55 CANCELLED = "cancelled" 

56 

57 

58class OpenStackAuthType(str, enum.Enum): 

59 APPLICATION_CREDENTIAL = "v3applicationcredential" 

60 PASSWORD = "password" 

61 

62 

63# ---------------------------------------------------------------- 

64# COURSE MODEL 

65# ---------------------------------------------------------------- 

66class Course(Base): 

67 __tablename__ = "courses" 

68 

69 courseId = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 

70 name = Column(String, nullable=False) 

71 

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 ) 

83 

84 

85# ---------------------------------------------------------------- 

86# USER MODEL 

87# ---------------------------------------------------------------- 

88class User(Base): 

89 __tablename__ = "users" 

90 

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) 

100 

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 ) 

120 

121 

122# ---------------------------------------------------------------- 

123# APP MODEL 

124# ---------------------------------------------------------------- 

125class App(Base): 

126 __tablename__ = "apps" 

127 

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) 

141 

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 ) 

151 

152 

153# ---------------------------------------------------------------- 

154# DEPLOYMENT MODEL 

155# ---------------------------------------------------------------- 

156class Deployment(Base): 

157 __tablename__ = "deployments" 

158 

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) 

170 

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 ) 

192 

193 

194# ---------------------------------------------------------------- 

195# TASK MODEL 

196# ---------------------------------------------------------------- 

197class Task(Base): 

198 __tablename__ = "tasks" 

199 

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) 

221 

222 # Relationships 

223 deployment = relationship("Deployment", back_populates="tasks") 

224 

225 

226# ---------------------------------------------------------------- 

227# USERTODEPLOYMENT MODEL 

228# ---------------------------------------------------------------- 

229class UserToDeployment(Base): 

230 __tablename__ = "user_to_deployments" 

231 

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 ) 

245 

246 # Relationships 

247 user = relationship("User", back_populates="user_to_deployments") 

248 deployment = relationship("Deployment", back_populates="user_to_deployments") 

249 

250 

251# ---------------------------------------------------------------- 

252# TEAM MODEL 

253# ---------------------------------------------------------------- 

254class Team(Base): 

255 __tablename__ = "teams" 

256 

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 ) 

265 

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 ) 

274 

275 

276# ---------------------------------------------------------------- 

277# USERTOTEAM MODEL 

278# ---------------------------------------------------------------- 

279class UserToTeam(Base): 

280 __tablename__ = "user_to_teams" 

281 

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 ) 

295 

296 # Relationships 

297 user = relationship("User", back_populates="user_to_teams") 

298 team = relationship("Team", back_populates="user_to_teams") 

299 

300 

301# ---------------------------------------------------------------- 

302# USER OPENSTACK CREDENTIAL MODEL 

303# ---------------------------------------------------------------- 

304class UserOpenStackCredential(Base): 

305 __tablename__ = "user_openstack_credentials" 

306 

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 ) 

323 

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) 

333 

334 # Encrypted (Fernet ciphertext) — never logged, never returned via API 

335 encrypted_identifier = Column(LargeBinary, nullable=False) 

336 encrypted_secret = Column(LargeBinary, nullable=False) 

337 

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) 

342 

343 user = relationship("User", back_populates="openstack_credential") 

344 

345 

346# ---------------------------------------------------------------- 

347# APP VERSION APPROVAL MODEL 

348# ---------------------------------------------------------------- 

349class AppVersionApproval(Base): 

350 __tablename__ = "app_version_approvals" 

351 

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) 

376 

377 __table_args__ = ( 

378 UniqueConstraint("appId", "version_tag", name="uq_app_version_approval"), 

379 ) 

380 

381 # Relationships 

382 app = relationship("App", back_populates="version_approvals") 

383 reviewer = relationship("User", foreign_keys=[reviewed_by]) 

384 

385 

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" 

395 

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 ) 

409 

410 # Relationships 

411 course = relationship("Course", back_populates="course_teachers") 

412 user = relationship("User", back_populates="course_teacher_links")