Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from src.api.openstack_flavors import router as openstack_flavors_router
from src.api.github_app import router as github_app_router
from src.api.student import router as student_router
from src.api.lecturers import router as lecturers_router

# Create main API router
api_router = APIRouter(prefix="/api/v1")
Expand All @@ -26,6 +27,7 @@
api_router.include_router(openstack_flavors_router)
api_router.include_router(github_app_router)
api_router.include_router(student_router)
api_router.include_router(lecturers_router)

__all__ = [
"api_router",
Expand Down
114 changes: 114 additions & 0 deletions src/api/lecturers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Admin-only /lecturers endpoints.

Provides three read/write operations against the User table filtered to
lecturers (= users that own templates or OpenStack projects). All routes
require the ``admin`` realm role — enforced by the router-level guard so
individual handlers don't repeat it.

The DELETE handler kicks off an async cascade via
``src.tasks.lecturer_tasks.cascade_delete_lecturer`` and returns 202 with
the task id. See that task for the exact ordering + bail-out rules.
"""
from __future__ import annotations

from fastapi import APIRouter, Depends, Query, status

from src.core.dependencies import CurrentUser, DBSession, RequestID, require_roles
from src.core.response_builder import ResponseBuilder
from src.models.user import UserRole
from src.schemas.lecturer import (
LecturerDeleteResponse,
LecturerDetail,
LecturerListItem,
)
from src.services.lecturer_service import LecturerService
from src.tasks.lecturer_tasks import cascade_delete_lecturer
router = APIRouter(
prefix="/lecturers",
tags=["lecturers"],
# Admin-only across the board — see module docstring for rationale.
dependencies=[Depends(require_roles(UserRole.ADMIN))],
)


@router.get("")
async def list_lecturers(
db: DBSession,
request_id: RequestID,
skip: int = Query(0, ge=0, description="Pagination offset"),
limit: int = Query(50, ge=1, le=200, description="Page size (max 200)"),
search: str | None = Query(
None,
description="Case-insensitive substring match against display_name/email/username",
),
):
"""List users who own at least one template or one OpenStack project.

Rows carry aggregate counts (templates / deployments / OSPs) so the
admin dashboard can render the list without a second round-trip per
row.
"""
service = LecturerService(db)
# The paginated response helper thinks in 1-indexed pages, but we
# expose skip/limit for consistency with the other admin endpoints.
# Compute the page number the helper needs from skip/limit.
page = (skip // limit) + 1 if limit else 1
rows, total = service.list_lecturers(skip=skip, limit=limit, search=search)

payload = [LecturerListItem(**r).model_dump(mode="json") for r in rows]
return ResponseBuilder.paginated(
data=payload,
page=page,
page_size=limit,
total=total,
message=f"Retrieved {len(payload)} lecturer(s)",
request_id=request_id,
)


@router.get("/{user_id}")
async def get_lecturer(
user_id: str,
db: DBSession,
request_id: RequestID,
):
"""Detail view: list-row fields + the full owned/deployed resource
lists (so the admin can review before hitting DELETE)."""
service = LecturerService(db)
detail = service.get_lecturer(user_id)
return ResponseBuilder.success(
data=LecturerDetail(**detail).model_dump(mode="json"),
message="Lecturer detail retrieved",
request_id=request_id,
)


@router.delete("/{user_id}", status_code=status.HTTP_202_ACCEPTED)
async def delete_lecturer(
user_id: str,
db: DBSession,
request_id: RequestID,
user: CurrentUser,
):
"""Enqueue cascade delete of a lecturer and all their resources.

Returns 202 with the Celery task id. The actual work — Heat teardown,
template + OSP + user removal — happens asynchronously and can be
monitored via the deployment log stream. An admin cannot delete their
own account (guarded up-front)."""
service = LecturerService(db)
summary = service.preflight_delete(user_id=user_id, requesting_user_id=user["user_id"])

async_result = cascade_delete_lecturer.delay(user_id)
payload = LecturerDeleteResponse(
task_id=async_result.id,
user_id=user_id,
deployment_count=summary["deployment_count"],
template_count=summary["template_count"],
)
return ResponseBuilder.success(
data=payload.model_dump(mode="json"),
message="Lecturer cascade delete enqueued",
request_id=request_id,
status_code=status.HTTP_202_ACCEPTED,
)
1 change: 1 addition & 0 deletions src/celery_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"src.tasks.deploy_tasks",
"src.tasks.sync_tasks",
"src.tasks.expiry_tasks",
"src.tasks.lecturer_tasks",
],
)

Expand Down
85 changes: 85 additions & 0 deletions src/schemas/lecturer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Schemas for the admin-only /lecturers endpoints."""
from datetime import datetime
from typing import Optional

from pydantic import BaseModel, ConfigDict, Field


class LecturerListItem(BaseModel):
"""One row in the lecturer list view.

Excludes any User row that owns neither templates nor OpenStack projects
— those are students or freshly-created accounts and belong in a
different UI.
"""

id: str = Field(..., description="Local DB user id")
external_id: str = Field(..., description="Keycloak sub claim")
display_name: Optional[str] = Field(None, description="Cached display name from Keycloak")
email: Optional[str] = Field(None, description="Cached email from Keycloak")
username: Optional[str] = Field(None, description="Cached preferred_username from Keycloak")
last_login_at: Optional[datetime] = Field(
None,
description="Last time the user's token was validated (proxy for 'still active in Keycloak')",
)
template_count: int = Field(..., description="Templates this user owns")
deployment_count: int = Field(
...,
description=(
"Deployments whose deployment_parameters.teacher.id matches this user's external_id"
),
)
openstack_project_count: int = Field(
..., description="OpenStack projects this user owns"
)

model_config = ConfigDict(from_attributes=True)


class LecturerTemplateSummary(BaseModel):
"""Minimal template info for the detail view."""

id: str
name: str
visibility: str
version_count: int


class LecturerDeploymentSummary(BaseModel):
"""Minimal deployment info for the detail view."""

id: str
name: str
status: str
course_id: Optional[str] = None
expires_at: Optional[datetime] = None
created_at: datetime


class LecturerOpenstackProjectSummary(BaseModel):
"""Minimal OpenStack project info for the detail view."""

id: str
openstack_project_name: str
region_name: str


class LecturerDetail(LecturerListItem):
"""Full detail view: list-row fields plus the owned/deployed resources."""

templates: list[LecturerTemplateSummary]
deployments: list[LecturerDeploymentSummary]
openstack_projects: list[LecturerOpenstackProjectSummary]


class LecturerDeleteResponse(BaseModel):
"""Response of DELETE /lecturers/{id} — the actual work is async."""

task_id: str = Field(..., description="Celery task id for the cascade delete")
user_id: str = Field(..., description="User row scheduled for deletion")
deployment_count: int = Field(
..., description="Number of deployments the cascade will tear down"
)
template_count: int = Field(
..., description="Number of templates the cascade will remove"
)
Loading
Loading