diff --git a/src/api/__init__.py b/src/api/__init__.py index 97f417b..e142710 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -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") @@ -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", diff --git a/src/api/lecturers.py b/src/api/lecturers.py new file mode 100644 index 0000000..ab9ddbe --- /dev/null +++ b/src/api/lecturers.py @@ -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, + ) diff --git a/src/celery_app.py b/src/celery_app.py index 817da77..99ed0f9 100644 --- a/src/celery_app.py +++ b/src/celery_app.py @@ -17,6 +17,7 @@ "src.tasks.deploy_tasks", "src.tasks.sync_tasks", "src.tasks.expiry_tasks", + "src.tasks.lecturer_tasks", ], ) diff --git a/src/schemas/lecturer.py b/src/schemas/lecturer.py new file mode 100644 index 0000000..5df9c26 --- /dev/null +++ b/src/schemas/lecturer.py @@ -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" + ) diff --git a/src/services/lecturer_service.py b/src/services/lecturer_service.py new file mode 100644 index 0000000..07e8d23 --- /dev/null +++ b/src/services/lecturer_service.py @@ -0,0 +1,257 @@ +"""Lecturer administration — admin-only listing, detail view, and cascade delete. + +Rationale: the codebase intentionally does not store user roles (Keycloak is +source-of-truth), so "who is a lecturer" is defined structurally as "a user +who owns templates or OpenStack projects." Students never satisfy this — +they cannot create either — which lets the /lecturers endpoints exclude +them without a role field. + +Deployment ownership is embedded in ``deployments.deployment_parameters`` +(JSON) rather than a FK column, so the counts here go through that JSON +via a Postgres JSONB path expression in production and a per-row Python +fallback in tests (SQLite has no JSONB). +""" +from __future__ import annotations + +import json +import logging +from typing import Optional + +from sqlalchemy import func, or_, text +from sqlalchemy.orm import Session + +from src.core.exceptions import BadRequestException, NotFoundException +from src.models.deployment import Deployment +from src.models.openstack_project import OpenstackProject +from src.models.template import Template +from src.models.template_version import TemplateVersion +from src.models.user import User + +logger = logging.getLogger(__name__) + + +def _is_postgres(db: Session) -> bool: + """Detect the DB dialect. We use Postgres-only JSONB queries where it + matters for performance, and fall back to a per-row Python scan on + SQLite so the unit tests don't need a real Postgres.""" + return db.bind is not None and db.bind.dialect.name == "postgresql" + + +def _deployments_for_external_id(db: Session, external_id: str) -> list[Deployment]: + """Every Deployment whose stored `teacher.id` == external_id. + + On Postgres we JSONB-index into ``deployment_parameters``. On SQLite we + load and JSON-parse in Python — fine for tests, unacceptable for prod + scale, hence the dialect split.""" + if _is_postgres(db): + return ( + db.query(Deployment) + .filter( + text("deployment_parameters::jsonb -> 'teacher' ->> 'id' = :ext_id") + ) + .params(ext_id=external_id) + .all() + ) + # SQLite fallback for tests: fetch all and filter in Python. + out: list[Deployment] = [] + for d in db.query(Deployment).all(): + if not d.deployment_parameters: + continue + try: + params = json.loads(d.deployment_parameters) + except (json.JSONDecodeError, TypeError): + continue + if params.get("teacher", {}).get("id") == external_id: + out.append(d) + return out + + +class LecturerService: + """Service for the admin-only lecturer management endpoints.""" + + def __init__(self, db: Session): + self.db = db + + # ------------------------------------------------------------------ + # List + # ------------------------------------------------------------------ + + def list_lecturers( + self, + skip: int = 0, + limit: int = 50, + search: Optional[str] = None, + ) -> tuple[list[dict], int]: + """List users who own templates or OpenStack projects. + + Args: + skip: Pagination offset. + limit: Pagination page size. + search: Optional case-insensitive substring match against + display_name, email, or username. + + Returns: + Tuple of (rows, total). Each row is a dict that maps directly + onto ``LecturerListItem``. + """ + # Aggregate counts in one pass: LEFT JOIN both ownership tables, + # then filter to rows that have at least one on either side. We + # deliberately compute the deployment_count in a second step because + # its dialect-specific query would explode the group-by. + template_count = func.count(func.distinct(Template.id)).label("template_count") + osp_count = func.count( + func.distinct(OpenstackProject.id) + ).label("openstack_project_count") + + base = ( + self.db.query( + User.id, + User.external_id, + User.display_name, + User.email, + User.username, + User.last_login_at, + template_count, + osp_count, + ) + .outerjoin(Template, Template.owner_id == User.id) + .outerjoin(OpenstackProject, OpenstackProject.owner_user_id == User.id) + .group_by(User.id) + .having(or_(template_count > 0, osp_count > 0)) + ) + + if search: + like = f"%{search}%" + base = base.filter( + or_( + User.display_name.ilike(like), + User.email.ilike(like), + User.username.ilike(like), + ) + ) + + # Total BEFORE pagination — subquery counts the filtered lecturer set. + total = base.count() + rows = base.order_by(User.display_name.asc().nulls_last() if _is_postgres(self.db) else User.display_name.asc()) \ + .offset(skip) \ + .limit(limit) \ + .all() + + results: list[dict] = [] + for r in rows: + deployment_count = len(_deployments_for_external_id(self.db, r.external_id)) + results.append({ + "id": r.id, + "external_id": r.external_id, + "display_name": r.display_name, + "email": r.email, + "username": r.username, + "last_login_at": r.last_login_at, + "template_count": r.template_count, + "deployment_count": deployment_count, + "openstack_project_count": r.openstack_project_count, + }) + return results, total + + # ------------------------------------------------------------------ + # Detail + # ------------------------------------------------------------------ + + def get_lecturer(self, user_id: str) -> dict: + """Detail view with the full owned/deployed resource lists. + + Raises NotFoundException if the user is not a lecturer (owns no + templates and no OSPs) — same visibility rule as list_lecturers so + the URL space is consistent. + """ + user = self.db.query(User).filter(User.id == user_id).first() + if not user: + raise NotFoundException(f"User {user_id} not found") + + templates = ( + self.db.query(Template).filter(Template.owner_id == user_id).all() + ) + osps = ( + self.db.query(OpenstackProject) + .filter(OpenstackProject.owner_user_id == user_id) + .all() + ) + if not templates and not osps: + raise NotFoundException(f"User {user_id} is not a lecturer") + + deployments = _deployments_for_external_id(self.db, user.external_id) + + # For each template, count active versions once per template so the + # detail view doesn't lie about "empty" templates. + version_counts: dict[str, int] = { + row[0]: row[1] + for row in self.db.query( + TemplateVersion.template_id, + func.count(TemplateVersion.id), + ) + .filter(TemplateVersion.template_id.in_([t.id for t in templates] or [""])) + .group_by(TemplateVersion.template_id) + .all() + } + + return { + "id": user.id, + "external_id": user.external_id, + "display_name": user.display_name, + "email": user.email, + "username": user.username, + "last_login_at": user.last_login_at, + "template_count": len(templates), + "deployment_count": len(deployments), + "openstack_project_count": len(osps), + "templates": [ + { + "id": t.id, + "name": t.name, + "visibility": t.visibility.value if hasattr(t.visibility, "value") else str(t.visibility), + "version_count": version_counts.get(t.id, 0), + } + for t in templates + ], + "deployments": [ + { + "id": d.id, + "name": d.name, + "status": d.status.value if hasattr(d.status, "value") else str(d.status), + "course_id": d.course_id, + "expires_at": d.expires_at, + "created_at": d.created_at, + } + for d in deployments + ], + "openstack_projects": [ + { + "id": op.id, + "openstack_project_name": op.openstack_project_name, + "region_name": op.region_name, + } + for op in osps + ], + } + + # ------------------------------------------------------------------ + # Delete (returns the counts; the actual work is enqueued by the API) + # ------------------------------------------------------------------ + + def preflight_delete(self, user_id: str, requesting_user_id: str) -> dict: + """Validate that the delete is legal and return the summary that + the API endpoint attaches to the 202 response. + + Raises: + NotFoundException: user does not exist or is not a lecturer. + BadRequestException: admin tries to delete themselves. + """ + if user_id == requesting_user_id: + raise BadRequestException("Admins cannot delete their own account") + + detail = self.get_lecturer(user_id) # raises NotFound if missing / not-lecturer + return { + "user_id": user_id, + "deployment_count": detail["deployment_count"], + "template_count": detail["template_count"], + } diff --git a/src/tasks/lecturer_tasks.py b/src/tasks/lecturer_tasks.py new file mode 100644 index 0000000..cd160c8 --- /dev/null +++ b/src/tasks/lecturer_tasks.py @@ -0,0 +1,172 @@ +"""Admin cascade-delete for a lecturer account. + +Ordering matters: OpenStack stacks must go down BEFORE their DB rows are +deleted, and every deployment must be torn down before its owning +template/OpenStack project — otherwise we orphan Heat stacks on +OpenStack and hit FK-constraint failures on the templates cascade. + +The task calls ``delete_deployment`` synchronously (``.apply()``) rather +than via ``.delay()`` so we can observe each step's outcome and bail out +if ONE stack cleanup fails. That mirrors the single-deployment contract +we introduced earlier: a failed Heat delete keeps the DB row around so +the admin can retry, and a failed cascade keeps the whole user around +for the same reason. +""" +from __future__ import annotations + +import logging + +from src.celery_app import celery_app +from src.core.database import SessionLocal +from src.models.openstack_project import OpenstackProject +from src.models.template import Template +from src.models.user import User +from src.services.lecturer_service import _deployments_for_external_id +from src.services.template_service import TemplateService +from src.tasks.deploy_tasks import delete_deployment + +logger = logging.getLogger(__name__) + + +@celery_app.task(bind=True, name="src.tasks.lecturer_tasks.cascade_delete_lecturer") +def cascade_delete_lecturer(self, user_id: str) -> dict: + """Delete every deployment, template, and OpenStack-project row owned + by ``user_id``, then the user row itself. + + Bail-out semantics: + * If any ``delete_deployment`` returns ``"stack_delete_failed"`` or + raises, the cascade stops there. The user survives so an admin + can inspect + retry. + * Templates and OSPs only get removed after all deployments are gone, + because those tables carry FKs the deployments reference. + + Returns: + dict summarising what happened, keyed by phase. + """ + task_id = self.request.id + db = SessionLocal() + + result = { + "user_id": user_id, + "task_id": task_id, + "status": "pending", + "deployments_deleted": 0, + "deployments_failed": 0, + "templates_deleted": 0, + "openstack_projects_deleted": 0, + } + + try: + user = db.query(User).filter(User.id == user_id).first() + if not user: + result["status"] = "user_not_found" + return result + + # ------------------------------------------------------------------ + # 1. Deployments — via delete_deployment.apply() so we can see the + # per-deployment outcome and abort on a Heat failure. + # ------------------------------------------------------------------ + deployments = _deployments_for_external_id(db, user.external_id) + logger.info( + f"cascade_delete_lecturer: tearing down {len(deployments)} deployment(s) " + f"for user {user_id}" + ) + for d in deployments: + dep_id = str(d.id) + try: + sub = delete_deployment.apply(args=[dep_id]).get(disable_sync_subtasks=False) + except Exception as e: + logger.error( + f"cascade_delete_lecturer: delete_deployment({dep_id}) crashed: {e}", + exc_info=True, + ) + result["deployments_failed"] += 1 + result["status"] = "aborted_on_deployment_failure" + return result + + if sub.get("status") in {"deleted", "not_found", "already_gone"}: + result["deployments_deleted"] += 1 + else: + # stack_delete_failed or anything else non-terminal — the + # deployment row is intentionally kept by delete_deployment + # so the admin can retry. Stop the cascade so we don't + # blow away templates that the surviving deployment might + # still need. + logger.warning( + f"cascade_delete_lecturer: aborting — deployment {dep_id} " + f"reported status {sub.get('status')!r}" + ) + result["deployments_failed"] += 1 + result["status"] = "aborted_on_deployment_failure" + return result + + # ------------------------------------------------------------------ + # 2. Templates — cascade via TemplateService.delete_template (which + # already handles the versions -> versions_files -> approvals + # chain and the "still-has-deployments" check we solved for + # normal template deletes). + # ------------------------------------------------------------------ + # Re-fetch after the deployment sweep so cascaded-away templates + # don't show up. + templates = db.query(Template).filter(Template.owner_id == user_id).all() + template_service = TemplateService(db) + for t in templates: + try: + template_service.delete_template( + template_id=t.id, + user_id=user_id, + is_admin=True, # cascade runs with admin authority + ) + result["templates_deleted"] += 1 + except Exception as e: + logger.error( + f"cascade_delete_lecturer: template delete {t.id} failed: {e}", + exc_info=True, + ) + result["status"] = "aborted_on_template_failure" + return result + + # ------------------------------------------------------------------ + # 3. OpenStack projects — our DB row only. We do NOT touch Keystone + # (see the spec discussion): the OSP itself is a Keycloak-managed + # resource that other systems may reference. + # ------------------------------------------------------------------ + osps = ( + db.query(OpenstackProject).filter(OpenstackProject.owner_user_id == user_id).all() + ) + for op in osps: + try: + db.delete(op) + db.flush() + result["openstack_projects_deleted"] += 1 + except Exception as e: + logger.error( + f"cascade_delete_lecturer: OSP delete {op.id} failed: {e}", + exc_info=True, + ) + db.rollback() + result["status"] = "aborted_on_osp_failure" + return result + + # ------------------------------------------------------------------ + # 4. User row — safe now, nothing left FK-referencing it (for the + # ownership tables we handled above; historical CourseMember + # rows for this lecturer are cleaned up by the same GC pass + # delete_deployment already runs at its tail). + # ------------------------------------------------------------------ + db.delete(user) + db.commit() + result["status"] = "deleted" + logger.info(f"cascade_delete_lecturer: user {user_id} fully removed") + return result + + except Exception as e: + logger.exception( + f"cascade_delete_lecturer: unexpected error for user {user_id}: {e}" + ) + db.rollback() + result["status"] = "failed" + result["error"] = str(e) + return result + finally: + db.close() diff --git a/tests/unit/test_lecturer_service.py b/tests/unit/test_lecturer_service.py new file mode 100644 index 0000000..cadf4f3 --- /dev/null +++ b/tests/unit/test_lecturer_service.py @@ -0,0 +1,253 @@ +"""Tests for LecturerService. + +Uses an in-memory SQLite database + real models. Deployment ownership +is embedded in ``deployment_parameters`` JSON — the service uses the +SQLite fallback path (per-row Python parse), so these tests exercise it +end-to-end. +""" +import json + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from src.core.database import Base +from src.core.exceptions import BadRequestException, NotFoundException +from src.models.course import Course +from src.models.deployment import Deployment, DeploymentStatus +from src.models.openstack_project import OpenstackProject +from src.models.template import Template, TemplateVisibility +from src.models.template_version import TemplateVersion +from src.models.user import User +from src.services.lecturer_service import LecturerService + + +@pytest.fixture +def db_session(): + """Fresh in-memory SQLite with the full schema.""" + # Register every model that Base metadata references. + import src.models.deployment_instance # noqa: F401 + import src.models.deployment_instance_access # noqa: F401 + import src.models.deployment_log # noqa: F401 + import src.models.template_version_file # noqa: F401 + import src.models.template_category # noqa: F401 + import src.models.template_category_assignment # noqa: F401 + import src.models.course_member # noqa: F401 + import src.models.course_group # noqa: F401 + import src.models.group_member # noqa: F401 + + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(engine) + + +def _lecturer(db, external_id, email, display_name): + u = User(external_id=external_id, email=email, display_name=display_name) + db.add(u) + db.flush() + return u + + +def _template(db, owner, name="t"): + t = Template( + name=name, + owner_id=owner.id, + repo_url="https://example.com/repo", + visibility=TemplateVisibility.PRIVATE, + ) + db.add(t) + db.flush() + return t + + +def _version(db, template, version="1.0.0"): + v = TemplateVersion( + template_id=template.id, version=version, git_commit_sha="abc" + ) + db.add(v) + db.flush() + return v + + +def _osp(db, owner, name="osp"): + op = OpenstackProject( + owner_user_id=owner.id, + openstack_project_id="ks-1", + openstack_project_name=name, + auth_url="https://example.com", + username="u", + password="p", + region_name="r", + ) + db.add(op) + db.flush() + return op + + +def _course(db, name="C"): + c = Course(name=name, keycloak_course_id="kc-course-1") + db.add(c) + db.flush() + return c + + +def _deployment_for(db, lecturer, template_version, osp, course, name="d"): + """Wire up a Deployment whose deployment_parameters.teacher.id matches + the lecturer's external_id — that's how the service maps ownership.""" + d = Deployment( + name=name, + template_version_id=template_version.id, + course_id=course.id, + openstack_project_id=osp.id, + status=DeploymentStatus.RUNNING, + deployment_parameters=json.dumps({ + "teacher": {"id": lecturer.external_id, "email": lecturer.email}, + }), + ) + db.add(d) + db.flush() + return d + + +# --------------------------------------------------------------------------- +# list_lecturers +# --------------------------------------------------------------------------- + +def test_list_lecturers_returns_owners_only_excludes_pure_students(db_session): + """A user without templates AND without OSPs must not appear.""" + lecturer = _lecturer(db_session, "kc-1", "l@x.de", "Lecturer 1") + _template(db_session, lecturer) + + # A student-like user with nothing owned + _lecturer(db_session, "kc-2", "s@x.de", "Just Student") + + svc = LecturerService(db_session) + rows, total = svc.list_lecturers() + + assert total == 1 + assert rows[0]["external_id"] == "kc-1" + assert rows[0]["template_count"] == 1 + + +def test_list_lecturers_counts_template_and_deployment(db_session): + lecturer = _lecturer(db_session, "kc-1", "l@x.de", "L1") + template = _template(db_session, lecturer) + version = _version(db_session, template) + osp = _osp(db_session, lecturer) + course = _course(db_session) + _deployment_for(db_session, lecturer, version, osp, course, name="d-1") + _deployment_for(db_session, lecturer, version, osp, course, name="d-2") + + svc = LecturerService(db_session) + rows, _ = svc.list_lecturers() + + only = rows[0] + assert only["template_count"] == 1 + assert only["deployment_count"] == 2 + assert only["openstack_project_count"] == 1 + + +def test_list_lecturers_search_filters_case_insensitively(db_session): + a = _lecturer(db_session, "kc-a", "alice@x.de", "Alice Prof") + _template(db_session, a) + b = _lecturer(db_session, "kc-b", "bob@x.de", "Bob Prof") + _template(db_session, b) + + svc = LecturerService(db_session) + rows, total = svc.list_lecturers(search="alice") + + assert total == 1 + assert rows[0]["external_id"] == "kc-a" + + +def test_list_lecturers_pagination(db_session): + for i in range(5): + u = _lecturer(db_session, f"kc-{i}", f"u{i}@x.de", f"User {i}") + _template(db_session, u, name=f"t-{i}") + + svc = LecturerService(db_session) + _, total = svc.list_lecturers(skip=0, limit=2) + rows_page2, _ = svc.list_lecturers(skip=2, limit=2) + rows_page3, _ = svc.list_lecturers(skip=4, limit=2) + + assert total == 5 + assert len(rows_page2) == 2 + assert len(rows_page3) == 1 + + +# --------------------------------------------------------------------------- +# get_lecturer +# --------------------------------------------------------------------------- + +def test_get_lecturer_returns_full_detail(db_session): + lecturer = _lecturer(db_session, "kc-1", "l@x.de", "L") + template = _template(db_session, lecturer, name="mytpl") + _version(db_session, template) + _version(db_session, template, version="1.1.0") + osp = _osp(db_session, lecturer, name="myosp") + course = _course(db_session) + _deployment_for(db_session, lecturer, template.versions[0], osp, course, name="d1") + + svc = LecturerService(db_session) + detail = svc.get_lecturer(lecturer.id) + + assert detail["template_count"] == 1 + assert detail["deployment_count"] == 1 + assert detail["openstack_project_count"] == 1 + assert detail["templates"][0]["name"] == "mytpl" + assert detail["templates"][0]["version_count"] == 2 + assert detail["deployments"][0]["name"] == "d1" + assert detail["openstack_projects"][0]["openstack_project_name"] == "myosp" + + +def test_get_lecturer_404_for_non_lecturer(db_session): + """A user with no owned resources isn't reachable through /lecturers/.""" + u = _lecturer(db_session, "kc-1", "s@x.de", "Just Student") + svc = LecturerService(db_session) + with pytest.raises(NotFoundException): + svc.get_lecturer(u.id) + + +def test_get_lecturer_404_for_unknown_id(db_session): + svc = LecturerService(db_session) + with pytest.raises(NotFoundException): + svc.get_lecturer("00000000-0000-0000-0000-000000000000") + + +# --------------------------------------------------------------------------- +# preflight_delete +# --------------------------------------------------------------------------- + +def test_preflight_delete_returns_counts(db_session): + lecturer = _lecturer(db_session, "kc-1", "l@x.de", "L") + template = _template(db_session, lecturer) + version = _version(db_session, template) + osp = _osp(db_session, lecturer) + course = _course(db_session) + _deployment_for(db_session, lecturer, version, osp, course) + + svc = LecturerService(db_session) + summary = svc.preflight_delete(user_id=lecturer.id, requesting_user_id="admin-1") + + assert summary["deployment_count"] == 1 + assert summary["template_count"] == 1 + + +def test_preflight_delete_rejects_self_delete(db_session): + lecturer = _lecturer(db_session, "kc-1", "l@x.de", "L") + _template(db_session, lecturer) + + svc = LecturerService(db_session) + with pytest.raises(BadRequestException): + svc.preflight_delete(user_id=lecturer.id, requesting_user_id=lecturer.id)