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

1from fastapi import APIRouter, Depends 

2from pydantic import BaseModel 

3from sqlalchemy import func, or_ 

4from sqlalchemy.orm import Session 

5 

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 

21 

22router = APIRouter() 

23 

24 

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 

34 

35 

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. 

43 

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. 

47 

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: 

51 

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. 

55 

56 Apps counter MUST mirror the role-branched visibility of 

57 ``GET /apps`` (see ``routers/apps.py``): 

58 

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. 

67 

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 ) 

75 

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 ) 

99 

100 deployments_total = deployments_q.scalar() or 0 

101 

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 

131 

132 courses_total = db.query(func.count(Course.courseId)).scalar() or 0 

133 

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 

147 

148 return DashboardStatsResponse( 

149 deployments=deployments_total, 

150 apps=apps_total, 

151 courses=courses_total, 

152 courseScopeDeployments=course_scope_deployments, 

153 )