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
« 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
5from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
7from app.models import AppVersionApprovalStatus, OpenStackAuthType, TaskStatus, TaskType, UserRole
10# ----------------------------------------------------------------
11# USER SCHEMAS
12# ----------------------------------------------------------------
13class UserBase(BaseModel):
14 email: EmailStr
15 username: str
18class UserCreate(UserBase):
19 role: UserRole = UserRole.STUDENT
20 courseId: UUID | None = None
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
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
39 model_config = ConfigDict(from_attributes=True)
42class UserWithCourse(UserResponse):
43 course: Optional["CourseResponse"] = None
45 model_config = ConfigDict(from_attributes=True)
48class Token(BaseModel):
49 access_token: str
50 token_type: str
53# ----------------------------------------------------------------
54# COURSE SCHEMAS
55# ----------------------------------------------------------------
56class CourseBase(BaseModel):
57 name: str
60class CourseCreate(CourseBase):
61 pass
64class CourseUpdate(BaseModel):
65 name: str | None = None
68class CourseResponse(CourseBase):
69 courseId: UUID
71 model_config = ConfigDict(from_attributes=True)
74class CourseWithUsers(CourseResponse):
75 users: list[UserResponse] = []
77 model_config = ConfigDict(from_attributes=True)
80class CourseMembersUpdate(BaseModel):
81 """Body for bulk-adding members to a course.
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] = []
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
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
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
121 model_config = ConfigDict(extra="ignore")
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
133 model_config = ConfigDict(from_attributes=True)
136class AppWithUser(AppResponse):
137 user: UserResponse
139 model_config = ConfigDict(from_attributes=True)
142class AppWithVersions(AppWithUser):
143 versions: list[dict[str, str]] = []
145 model_config = ConfigDict(from_attributes=True)
148# ----------------------------------------------------------------
149# APP VERSION APPROVAL SCHEMAS
150# ----------------------------------------------------------------
151class AppVersionApprovalSubmit(BaseModel):
152 diff_url: str | None = None
153 notes: str | None = None
156class AppVersionApprovalDecision(BaseModel):
157 rejection_reason: str
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
172 model_config = ConfigDict(from_attributes=True)
175class AppVersionApprovalWithApp(AppVersionApprovalResponse):
176 app: AppResponse
178 model_config = ConfigDict(from_attributes=True)
181# ----------------------------------------------------------------
182# DEPLOYMENT SCHEMAS
183# ----------------------------------------------------------------
184class Team(BaseModel):
185 name: str
186 userIds: list[str] = []
189class DeploymentBase(BaseModel):
190 name: str
191 appId: UUID
194class DeploymentFileUpload(BaseModel):
195 """Single file uploaded by the teacher in the deploy wizard.
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``.
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
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] = []
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
232 model_config = ConfigDict(from_attributes=True)
235class DeploymentWithRelations(DeploymentResponse):
236 user: UserResponse
237 app: AppResponse
239 model_config = ConfigDict(from_attributes=True)
242# Team response for deployment details (from DB)
243class DeploymentTeamMember(BaseModel):
244 userId: UUID
245 email: str
246 username: str
248 model_config = ConfigDict(from_attributes=True)
251class DeploymentTeamResponse(BaseModel):
252 teamId: UUID
253 name: str
254 members: list[DeploymentTeamMember] = []
256 model_config = ConfigDict(from_attributes=True)
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
272 model_config = ConfigDict(from_attributes=True)
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
280 model_config = ConfigDict(from_attributes=True)
283# Per-member self-access response
284class MyAccessResponse(BaseModel):
285 """A single team member's own access credentials.
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.
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]] = {}
300 model_config = ConfigDict(from_attributes=True)
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
311 model_config = ConfigDict(from_attributes=True)
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.
324class LifecycleStatesSchema(BaseModel):
325 """Server lifecycle quad + optional fault message.
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
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
350class NetworkAddressSchema(BaseModel):
351 network: str
352 fixed_ip: str | None = None
353 floating_ip: str | None = None
354 mac: str | None = None
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] = []
366class SecurityGroupSummarySchema(BaseModel):
367 id: str
368 name: str
369 description: str | None = None
370 ingress_rules: int
371 egress_rules: int
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
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).
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
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
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
436class TaskCreate(TaskBase):
437 pass
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
450class TaskResponse(TaskBase):
451 taskId: UUID
452 created_at: datetime
454 model_config = ConfigDict(from_attributes=True)
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
469class TeamCreate(TeamBase):
470 userIds: list[UUID] = []
473class TeamUpdate(BaseModel):
474 name: str | None = None
477class TeamResponse(TeamBase):
478 teamId: UUID
480 model_config = ConfigDict(from_attributes=True)
483class TeamWithMembers(TeamResponse):
484 users: list[UserResponse] = []
486 model_config = ConfigDict(from_attributes=True)
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
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
516class OpenStackCredentialUpsert(OpenStackCredentialBase):
517 """Body of PUT /me/openstack-credentials.
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")
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
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 )
546class OpenStackCredentialResponse(OpenStackCredentialBase):
547 """Masked response — never returns identifier/secret material.
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
564 model_config = ConfigDict(from_attributes=True)