Coverage for app/routers/dashboard.py: 97.06%
34 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 fastapi import APIRouter, Depends
2from pydantic import BaseModel
3from sqlalchemy import func, or_
4from sqlalchemy.orm import Session
6from app.database import get_db
7from app.models import (
8 App,
9 AppVersionApproval,
10 AppVersionApprovalStatus,
11 Course,
12 Deployment,
13 Team,
14 User,
15 UserRole,
16 UserToDeployment,
17 UserToTeam,
18)
19from app.utils.capabilities import get_my_course_teacher_ids
20from app.utils.keycloak_auth import get_current_user_keycloak
22router = APIRouter()
25class DashboardStatsResponse(BaseModel):
26 deployments: int
27 apps: int
28 courses: int
29 # Counts deployments whose owner sits inside one of the requestor's
30 # taught courses. 0 for anyone not registered as a course-teacher.
31 # Always present so the frontend can render the scope tile without
32 # a separate request.
33 courseScopeDeployments: int = 0
36@router.get("/stats", response_model=DashboardStatsResponse)
37def get_dashboard_stats(
38 db: Session = Depends(get_db),
39 current_user: User = Depends(get_current_user_keycloak),
40):
41 """
42 Aggregate counts for the dashboard KPI strip.
44 Cheap DB-only aggregates — intentionally separated from the OpenStack
45 quota call (`GET /quotas/overview`) so the dashboard renders fast even
46 when the OpenStack API is slow or unavailable.
48 Deployments counter MUST mirror the visibility rules of
49 ``GET /deployments`` so the KPI matches what the user actually sees
50 on the Deployments page:
52 * Teacher/Admin: deployments they own.
53 * Student: deployments they own OR are a team member of OR
54 have a direct ``UserToDeployment`` mapping for.
56 Apps counter MUST mirror the role-branched visibility of
57 ``GET /apps`` (see ``routers/apps.py``):
59 * Admin: every non-deleted app — exactly what
60 ``crud_apps.get_apps`` returns when called without
61 ``user_id``. Admin keeps the plattform-wide view.
62 * Teacher/Student: own apps (regardless of ``is_private`` /
63 approval state) OR public apps (``is_private = False``)
64 with at least one APPROVED version — mirrors
65 ``crud_apps.get_visible_apps``. Teacher gets the student-style
66 filter.
68 Soft-deleted rows (``deleted_at IS NOT NULL``) are excluded on both
69 counters — same as the list endpoints.
70 """
71 deployments_q = (
72 db.query(func.count(Deployment.deploymentId))
73 .filter(Deployment.deleted_at.is_(None))
74 )
76 if current_user.role in (UserRole.TEACHER, UserRole.ADMIN):
77 deployments_q = deployments_q.filter(
78 Deployment.userId == current_user.userId
79 )
80 else:
81 # Owner OR team member OR direct mapping. Mirrors
82 # ``crud_deployments.get_deployments(member_user_id=...)``.
83 member_team_ids = db.query(UserToTeam.teamId).filter(
84 UserToTeam.userId == current_user.userId
85 )
86 member_deployment_ids_via_teams = db.query(Team.deploymentId).filter(
87 Team.teamId.in_(member_team_ids)
88 )
89 member_deployment_ids_direct = db.query(
90 UserToDeployment.deploymentId
91 ).filter(UserToDeployment.userId == current_user.userId)
92 deployments_q = deployments_q.filter(
93 or_(
94 Deployment.userId == current_user.userId,
95 Deployment.deploymentId.in_(member_deployment_ids_via_teams),
96 Deployment.deploymentId.in_(member_deployment_ids_direct),
97 )
98 )
100 deployments_total = deployments_q.scalar() or 0
102 # Apps visible to the user — role-branched, same gate as the
103 # ``/apps`` endpoint at ``routers/apps.py``. Admin sees everything
104 # non-deleted (mirrors ``crud_apps.get_apps``); everyone else
105 # sees own + public-approved (mirrors ``crud_apps.get_visible_apps``).
106 if current_user.role == UserRole.ADMIN:
107 apps_total = (
108 db.query(func.count(App.appId))
109 .filter(App.deleted_at.is_(None))
110 .scalar()
111 ) or 0
112 else:
113 approved_app_ids = (
114 db.query(AppVersionApproval.appId)
115 .filter(AppVersionApproval.status == AppVersionApprovalStatus.APPROVED)
116 .distinct()
117 .scalar_subquery()
118 )
119 apps_total = (
120 db.query(func.count(App.appId))
121 .filter(App.deleted_at.is_(None))
122 .filter(
123 or_(
124 App.userId == current_user.userId,
125 (App.is_private == False) # noqa: E712
126 & App.appId.in_(approved_app_ids),
127 )
128 )
129 .scalar()
130 ) or 0
132 courses_total = db.query(func.count(Course.courseId)).scalar() or 0
134 # Course-teacher scope counter. Counts deployments whose owner sits
135 # in one of the requestor's taught courses; soft-deleted deployments
136 # are excluded the same way as the primary counter.
137 course_scope_deployments = 0
138 my_course_ids = get_my_course_teacher_ids(current_user, db)
139 if my_course_ids:
140 course_scope_deployments = (
141 db.query(func.count(Deployment.deploymentId))
142 .join(User, User.userId == Deployment.userId)
143 .filter(Deployment.deleted_at.is_(None))
144 .filter(User.courseId.in_(my_course_ids))
145 .scalar()
146 ) or 0
148 return DashboardStatsResponse(
149 deployments=deployments_total,
150 apps=apps_total,
151 courses=courses_total,
152 courseScopeDeployments=course_scope_deployments,
153 )