Coverage for app/schemas.py: 100.00%

290 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-25 15:51 +0000

1from datetime import datetime 

2from typing import Any, Literal, Optional 

3from uuid import UUID 

4 

5from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator 

6 

7from app.models import AppVersionApprovalStatus, OpenStackAuthType, TaskStatus, TaskType, UserRole 

8 

9 

10# ---------------------------------------------------------------- 

11# USER SCHEMAS 

12# ---------------------------------------------------------------- 

13class UserBase(BaseModel): 

14 email: EmailStr 

15 username: str 

16 

17 

18class UserCreate(UserBase): 

19 role: UserRole = UserRole.STUDENT 

20 courseId: UUID | None = None 

21 

22 

23class UserUpdate(BaseModel): 

24 # Profile fields are managed exclusively through Keycloak; the only 

25 # update path here is ``role`` and it is admin-only. 

26 role: UserRole | None = None 

27 courseId: UUID | None = None 

28 

29 

30class UserResponse(UserBase): 

31 userId: UUID 

32 role: UserRole 

33 courseId: UUID | None = None 

34 keycloak_id: str | None = None 

35 firstName: str | None = None 

36 lastName: str | None = None 

37 created_at: datetime 

38 

39 model_config = ConfigDict(from_attributes=True) 

40 

41 

42class UserWithCourse(UserResponse): 

43 course: Optional["CourseResponse"] = None 

44 

45 model_config = ConfigDict(from_attributes=True) 

46 

47 

48class Token(BaseModel): 

49 access_token: str 

50 token_type: str 

51 

52 

53# ---------------------------------------------------------------- 

54# COURSE SCHEMAS 

55# ---------------------------------------------------------------- 

56class CourseBase(BaseModel): 

57 name: str 

58 

59 

60class CourseCreate(CourseBase): 

61 pass 

62 

63 

64class CourseUpdate(BaseModel): 

65 name: str | None = None 

66 

67 

68class CourseResponse(CourseBase): 

69 courseId: UUID 

70 

71 model_config = ConfigDict(from_attributes=True) 

72 

73 

74class CourseWithUsers(CourseResponse): 

75 users: list[UserResponse] = [] 

76 

77 model_config = ConfigDict(from_attributes=True) 

78 

79 

80class CourseMembersUpdate(BaseModel): 

81 """Body for bulk-adding members to a course. 

82 

83 Course membership lives on ``users.courseId``, so a "member" is a 

84 user-id whose FK is (re-)pointed to this course; an existing 

85 enrollment on another course is overwritten. 

86 """ 

87 userIds: list[UUID] = [] 

88 

89 

90# ---------------------------------------------------------------- 

91# APP SCHEMAS 

92# ---------------------------------------------------------------- 

93class AppBase(BaseModel): 

94 name: str 

95 description: str | None = None 

96 git_link: str | None = None 

97 is_private: bool = False 

98 

99 

100class AppCreate(AppBase): 

101 # Image is sent as a full data-URL string, e.g. 

102 # ``"data:image/png;base64,iVBORw0KG..."``. The router decodes the 

103 # base64 part to bytes and stores mime + bytes in two columns. 

104 image: str | None = None 

105 # When True and is_private=False, all existing Git tags are 

106 # automatically submitted for review right after creation. 

107 submit_all_versions: bool = False 

108 

109 

110class AppUpdate(BaseModel): 

111 # ``git_link`` is intentionally NOT editable — once an app has 

112 # deployments, changing the repo would make the version history 

113 # inconsistent. Callers may include it; it is silently ignored. 

114 name: str | None = None 

115 description: str | None = None 

116 is_private: bool | None = None 

117 # Same data-URL convention as ``AppCreate``. Pass ``""`` to clear 

118 # the image; ``None`` (the default) leaves it unchanged. 

119 image: str | None = None 

120 

121 model_config = ConfigDict(extra="ignore") 

122 

123 

124class AppResponse(AppBase): 

125 appId: UUID 

126 userId: UUID 

127 created_at: datetime 

128 is_private: bool 

129 # Data-URL or null. Populated from ``app.image`` + ``app.image_mime`` 

130 # by ``serialize_app_image``; the raw bytes never leave the backend. 

131 image: str | None = None 

132 

133 model_config = ConfigDict(from_attributes=True) 

134 

135 

136class AppWithUser(AppResponse): 

137 user: UserResponse 

138 

139 model_config = ConfigDict(from_attributes=True) 

140 

141 

142class AppWithVersions(AppWithUser): 

143 versions: list[dict[str, str]] = [] 

144 

145 model_config = ConfigDict(from_attributes=True) 

146 

147 

148# ---------------------------------------------------------------- 

149# APP VERSION APPROVAL SCHEMAS 

150# ---------------------------------------------------------------- 

151class AppVersionApprovalSubmit(BaseModel): 

152 diff_url: str | None = None 

153 notes: str | None = None 

154 

155 

156class AppVersionApprovalDecision(BaseModel): 

157 rejection_reason: str 

158 

159 

160class AppVersionApprovalResponse(BaseModel): 

161 approvalId: UUID 

162 appId: UUID 

163 version_tag: str 

164 status: AppVersionApprovalStatus 

165 diff_url: str | None = None 

166 notes: str | None = None 

167 rejection_reason: str | None = None 

168 reviewed_by: UUID | None = None 

169 reviewed_at: datetime | None = None 

170 created_at: datetime 

171 

172 model_config = ConfigDict(from_attributes=True) 

173 

174 

175class AppVersionApprovalWithApp(AppVersionApprovalResponse): 

176 app: AppResponse 

177 

178 model_config = ConfigDict(from_attributes=True) 

179 

180 

181# ---------------------------------------------------------------- 

182# DEPLOYMENT SCHEMAS 

183# ---------------------------------------------------------------- 

184class Team(BaseModel): 

185 name: str 

186 userIds: list[str] = [] 

187 

188 

189class DeploymentBase(BaseModel): 

190 name: str 

191 appId: UUID 

192 

193 

194class DeploymentFileUpload(BaseModel): 

195 """Single file uploaded by the teacher in the deploy wizard. 

196 

197 Persisted verbatim into ``userInputVar.terraform`` keyed by the 

198 variable name (and recipient key for team/user scope) so the worker 

199 passes it to terraform and cloud-init decodes it into ``write_files``. 

200 

201 ``content_b64`` MUST be standard (RFC 4648) base64. The value sent 

202 to Terraform stays base64 for cloud-init's ``encoding: b64``. 

203 """ 

204 name: str 

205 content_b64: str 

206 size: int 

207 content_type: str | None = None 

208 

209 

210class DeploymentCreate(DeploymentBase): 

211 releaseTag: str | None = None 

212 userInputVar: dict[str, Any] | None = None 

213 # Files-map keyed by ``@openstack:file:<scope>``-marked variable 

214 # name. Inner key: 

215 # * scope = all → exactly one inner key, conventionally "all" 

216 # * scope = team → one entry per team name 

217 # * scope = user → one entry per ``Team-User`` composite key 

218 # The backend doesn't auto-route — what the wizard puts in here 

219 # ends up 1:1 in the Terraform variable. 

220 files: dict[str, dict[str, DeploymentFileUpload]] | None = None 

221 teams: list[Team] = [] 

222 

223 

224class DeploymentResponse(DeploymentBase): 

225 deploymentId: UUID 

226 userId: UUID 

227 releaseTag: str | None = None 

228 userInputVar: dict[str, Any] | None = None 

229 status: str | None = None # From latest task 

230 created_at: datetime | None = None 

231 

232 model_config = ConfigDict(from_attributes=True) 

233 

234 

235class DeploymentWithRelations(DeploymentResponse): 

236 user: UserResponse 

237 app: AppResponse 

238 

239 model_config = ConfigDict(from_attributes=True) 

240 

241 

242# Team response for deployment details (from DB) 

243class DeploymentTeamMember(BaseModel): 

244 userId: UUID 

245 email: str 

246 username: str 

247 

248 model_config = ConfigDict(from_attributes=True) 

249 

250 

251class DeploymentTeamResponse(BaseModel): 

252 teamId: UUID 

253 name: str 

254 members: list[DeploymentTeamMember] = [] 

255 

256 model_config = ConfigDict(from_attributes=True) 

257 

258 

259# Task summary for deployment details 

260class TaskSummary(BaseModel): 

261 taskId: UUID 

262 type: TaskType 

263 status: TaskStatus 

264 started_at: datetime | None = None 

265 finished_at: datetime | None = None 

266 created_at: datetime 

267 # Live-progress fields (mirror of Task model), used to render the 

268 # progress bar on page reload; SSE supplies real-time updates. 

269 current_phase: str | None = None 

270 progress_pct: int | None = None 

271 

272 model_config = ConfigDict(from_attributes=True) 

273 

274 

275# Terraform outputs parsed 

276class DeploymentOutputs(BaseModel): 

277 """Parsed Terraform outputs - structure depends on app""" 

278 raw: dict[str, Any] | None = None # Full outputs as dict 

279 

280 model_config = ConfigDict(from_attributes=True) 

281 

282 

283# Per-member self-access response 

284class MyAccessResponse(BaseModel): 

285 """A single team member's own access credentials. 

286 

287 Returned by ``GET /deployments/{id}/my-access`` so a member (student) 

288 can see their OWN credentials without the owner-view that gates the 

289 full outputs. Only the caller's own account is ever present. 

290 

291 Both maps mirror the raw terraform ``user_accounts`` / ``team_vms`` 

292 output shape (one key each: the member's account and their team's VM) 

293 so the frontend's existing account-matching pipeline consumes this 

294 unchanged. Empty maps mean "no credentials yet" (no successful deploy, 

295 or the app issued no per-user account) — a valid 200, not an error. 

296 """ 

297 user_accounts: dict[str, dict[str, Any]] = {} 

298 team_vms: dict[str, dict[str, Any]] = {} 

299 

300 model_config = ConfigDict(from_attributes=True) 

301 

302 

303# Full deployment detail response 

304class DeploymentDetail(DeploymentWithRelations): 

305 """Full deployment details with teams, task info, and outputs""" 

306 teams: list[DeploymentTeamResponse] = [] 

307 latest_task: TaskSummary | None = None 

308 outputs: DeploymentOutputs | None = None 

309 logs: str | None = None # Optional: can be excluded for large logs 

310 

311 model_config = ConfigDict(from_attributes=True) 

312 

313 

314# ---------------------------------------------------------------- 

315# DEPLOYMENT RESOURCE / INFRASTRUCTURE SCHEMAS 

316# ---------------------------------------------------------------- 

317# These mirror the dataclasses in ``app/services/deployment_status.py`` 

318# 1:1 so the conversion at the endpoint boundary is a plain 

319# ``model_validate(asdict(view))``. Defined separately from the 

320# dataclasses because Pydantic integrates better with FastAPI's OpenAPI 

321# emitter and the service module stays free of a Pydantic dependency. 

322 

323 

324class LifecycleStatesSchema(BaseModel): 

325 """Server lifecycle quad + optional fault message. 

326 

327 Fields are pass-through from OpenStack's Nova response (stable 

328 strings like ``"ACTIVE"`` / ``"BUILD"`` / ``"ERROR"``). The frontend 

329 renders a pill from ``status`` + ``task_state`` and shows 

330 ``fault_message`` as a banner when ``status == "ERROR"``. 

331 """ 

332 status: str | None = None 

333 task_state: str | None = None 

334 vm_state: str | None = None 

335 power_state: str | None = None 

336 fault_message: str | None = None 

337 

338 

339class HardwareSpecSchema(BaseModel): 

340 flavor_name: str | None = None 

341 ram_mb: int | None = None 

342 vcpus: int | None = None 

343 disk_gb: int | None = None 

344 image_id: str | None = None 

345 image_name: str | None = None 

346 availability_zone: str | None = None 

347 launched_at: str | None = None 

348 

349 

350class NetworkAddressSchema(BaseModel): 

351 network: str 

352 fixed_ip: str | None = None 

353 floating_ip: str | None = None 

354 mac: str | None = None 

355 

356 

357class NetworkPortSchema(BaseModel): 

358 port_id: str 

359 network_id: str | None = None 

360 status: str | None = None 

361 mac: str | None = None 

362 fixed_ip: str | None = None 

363 security_group_ids: list[str] = [] 

364 

365 

366class SecurityGroupSummarySchema(BaseModel): 

367 id: str 

368 name: str 

369 description: str | None = None 

370 ingress_rules: int 

371 egress_rules: int 

372 

373 

374class VolumeAttachmentSchema(BaseModel): 

375 volume_id: str 

376 device: str | None = None 

377 size_gb: int | None = None 

378 bootable: bool | None = None 

379 status: str | None = None 

380 name: str | None = None 

381 

382 

383class DeploymentResourceSchema(BaseModel): 

384 """One row in the resource list (stage 1) or the response of the 

385 detail endpoint (stage 2 — same shape, more fields populated). 

386 

387 Non-instance categories (network, subnet, etc.) only populate 

388 ``address``, ``type``, ``category``, ``provider_id``, 

389 ``display_name``; the instance-only fields stay ``None`` / 

390 empty list. 

391 """ 

392 address: str 

393 type: str 

394 category: str 

395 team: str | None = None 

396 provider_id: str 

397 display_name: str 

398 drift: Literal["in_sync", "stale", "missing"] = "in_sync" 

399 # Stage 1 fields (instance-only) 

400 lifecycle: LifecycleStatesSchema | None = None 

401 hardware: HardwareSpecSchema | None = None 

402 addresses: list[NetworkAddressSchema] = [] 

403 # Stage 2 fields (instance-only, only set on the detail endpoint) 

404 ports: list[NetworkPortSchema] | None = None 

405 security_groups: list[SecurityGroupSummarySchema] | None = None 

406 volumes: list[VolumeAttachmentSchema] | None = None 

407 metadata: dict[str, str] | None = None 

408 

409 

410class DeploymentResourceListResponse(BaseModel): 

411 """Wrap the list in an object so we can add cursor/refresh-stamp 

412 metadata later without a breaking API change.""" 

413 resources: list[DeploymentResourceSchema] 

414 # True when the response was fetched with live OpenStack join. 

415 # False means the caller passed ``?refresh=false`` and the 

416 # response reflects only cached TF state. 

417 live: bool 

418 

419 

420# ---------------------------------------------------------------- 

421# Task SCHEMAS 

422# ---------------------------------------------------------------- 

423class TaskBase(BaseModel): 

424 deploymentId: UUID 

425 celeryTaskId: str 

426 type: TaskType 

427 status: TaskStatus 

428 started_at: datetime | None = None 

429 finished_at: datetime | None = None 

430 logs: str | None = None 

431 tf_state: str | None = None 

432 outputs: str | None = None 

433 current_phase: str | None = None 

434 progress_pct: int | None = None 

435 

436class TaskCreate(TaskBase): 

437 pass 

438 

439 

440class TaskUpdate(BaseModel): 

441 status: TaskStatus | None = None 

442 started_at: datetime | None = None 

443 finished_at: datetime | None = None 

444 logs: str | None = None 

445 tf_state: str | None = None 

446 outputs: str | None = None 

447 current_phase: str | None = None 

448 progress_pct: int | None = None 

449 

450class TaskResponse(TaskBase): 

451 taskId: UUID 

452 created_at: datetime 

453 

454 model_config = ConfigDict(from_attributes=True) 

455 

456 

457# ---------------------------------------------------------------- 

458# TEAM SCHEMAS 

459# 

460# Teams are bound directly to a deployment (FK 

461# ``deployments.deploymentId`` -> ``Team.deploymentId``). The schemas 

462# mirror the model 1:1. 

463# ---------------------------------------------------------------- 

464class TeamBase(BaseModel): 

465 name: str 

466 deploymentId: UUID 

467 

468 

469class TeamCreate(TeamBase): 

470 userIds: list[UUID] = [] 

471 

472 

473class TeamUpdate(BaseModel): 

474 name: str | None = None 

475 

476 

477class TeamResponse(TeamBase): 

478 teamId: UUID 

479 

480 model_config = ConfigDict(from_attributes=True) 

481 

482 

483class TeamWithMembers(TeamResponse): 

484 users: list[UserResponse] = [] 

485 

486 model_config = ConfigDict(from_attributes=True) 

487 

488 

489# ---------------------------------------------------------------- 

490# STATISTICS SCHEMAS 

491# ---------------------------------------------------------------- 

492class UserStatistics(BaseModel): 

493 total_apps: int 

494 total_deployments: int 

495 successful_deployments: int 

496 failed_deployments: int 

497 pending_deployments: int 

498 

499 

500# ---------------------------------------------------------------- 

501# OPENSTACK CREDENTIAL SCHEMAS 

502# ---------------------------------------------------------------- 

503class OpenStackCredentialBase(BaseModel): 

504 """Non-secret connection metadata. Mirrors the `clouds.yaml` shape.""" 

505 auth_type: OpenStackAuthType 

506 auth_url: str 

507 region_name: str | None = None 

508 interface: str | None = "public" 

509 identity_api_version: str | None = "3" 

510 project_id: str | None = None 

511 project_name: str | None = None 

512 user_domain_name: str | None = None 

513 project_domain_name: str | None = None 

514 

515 

516class OpenStackCredentialUpsert(OpenStackCredentialBase): 

517 """Body of PUT /me/openstack-credentials. 

518 

519 `identifier` is either a username (password auth) or an 

520 application-credential ID. `secret` is the corresponding password or 

521 application-credential secret. Both are stored encrypted at rest. 

522 """ 

523 identifier: str = Field(..., min_length=1, description="username OR application-credential ID") 

524 secret: str = Field(..., min_length=1, description="password OR application-credential secret") 

525 

526 @model_validator(mode="after") 

527 def _validate_required_fields(self) -> "OpenStackCredentialUpsert": 

528 if self.auth_type == OpenStackAuthType.PASSWORD: 

529 if not self.user_domain_name: 

530 raise ValueError("user_domain_name is required for password auth") 

531 if not (self.project_id or self.project_name): 

532 raise ValueError("project_id or project_name is required for password auth") 

533 # application-credential auth: project_id is recommended but not required; 

534 # the credential itself carries the project scope. 

535 return self 

536 

537 

538class OpenStackCredentialFromYaml(BaseModel): 

539 """Convenience body: paste/upload a `clouds.yaml` and let the server pick it apart.""" 

540 clouds_yaml: str = Field(..., min_length=1, description="raw clouds.yaml file contents") 

541 cloud_name: str | None = Field( 

542 None, description="Pick a specific cloud from the YAML; required when there is more than one" 

543 ) 

544 

545 

546class OpenStackCredentialResponse(OpenStackCredentialBase): 

547 """Masked response — never returns identifier/secret material. 

548 

549 When `has_credential` is False, base fields are absent/empty and the 

550 consumer should branch on `has_credential`. `is_locked` and 

551 `active_deployments` are populated regardless so the frontend can 

552 render the appropriate guard UI even before any credential exists. 

553 """ 

554 auth_type: OpenStackAuthType | None = None # type: ignore[assignment] 

555 auth_url: str | None = None # type: ignore[assignment] 

556 has_credential: bool = True 

557 last_validated_at: datetime | None = None 

558 last_validation_error: str | None = None 

559 created_at: datetime | None = None 

560 updated_at: datetime | None = None 

561 is_locked: bool = False 

562 active_deployments: int = 0 

563 

564 model_config = ConfigDict(from_attributes=True)