Coverage for app/routers/users.py: 86.67%

75 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-25 15:51 +0000

1from uuid import UUID 

2 

3from fastapi import APIRouter, Depends, HTTPException, status 

4from sqlalchemy.orm import Session 

5 

6from app.crud import apps as crud_apps 

7from app.crud import deployments as crud_deployments 

8from app.crud import users as crud_users 

9from app.database import get_db 

10from app.models import User, UserRole 

11from app.schemas import UserResponse, UserStatistics, UserUpdate, UserWithCourse 

12from app.utils.capabilities import ensure_change_user_role, ensure_view_user 

13from app.utils.keycloak_auth import ( 

14 get_current_user_keycloak, 

15 get_keycloak_users_by_ids, 

16 search_keycloak_users, 

17) 

18from app.utils.permissions import ( 

19 require_staff, 

20) 

21 

22router = APIRouter() 

23 

24# ---------------------------------------------------------------- 

25# GET CURRENT USER 

26# ---------------------------------------------------------------- 

27@router.get("/me", response_model=UserWithCourse) 

28def get_me(current_user: User = Depends(get_current_user_keycloak)): 

29 """Get current authenticated user with course information""" 

30 return current_user 

31 

32# ---------------------------------------------------------------- 

33# GET ALL USERS (TEACHER/ADMIN ONLY) 

34# ---------------------------------------------------------------- 

35@router.get("/", response_model=list[UserResponse]) 

36def list_users( 

37 skip: int = 0, 

38 limit: int = 100, 

39 role: UserRole | None = None, 

40 course_id: UUID | None = None, 

41 db: Session = Depends(get_db), 

42 current_user: User = Depends(require_staff) 

43): 

44 """ 

45 Get all users with optional filters 

46 - **Requires**: TEACHER or ADMIN role 

47 """ 

48 users = crud_users.get_users(db, skip=skip, limit=limit, role=role, course_id=course_id) 

49 # Enrich users with Keycloak names when keycloak_id is present 

50 kc_ids = [u.keycloak_id for u in users if getattr(u, 'keycloak_id', None)] 

51 kc_map = {} 

52 if kc_ids: 

53 try: 

54 kc_map = get_keycloak_users_by_ids(kc_ids) 

55 except HTTPException: 

56 # If enrichment fails, continue returning base users 

57 kc_map = {} 

58 

59 result = [] 

60 for u in users: 

61 user_obj = { 

62 "userId": u.userId, 

63 "email": u.email, 

64 "username": u.username, 

65 "role": u.role, 

66 "courseId": u.courseId, 

67 "created_at": u.created_at, 

68 "keycloak_id": getattr(u, 'keycloak_id', None), 

69 # default empty strings if not available 

70 "firstName": None, 

71 "lastName": None, 

72 } 

73 if user_obj["keycloak_id"] and user_obj["keycloak_id"] in kc_map: 

74 kc = kc_map[user_obj["keycloak_id"]] 

75 user_obj["firstName"] = kc.get("firstName") 

76 user_obj["lastName"] = kc.get("lastName") 

77 result.append(user_obj) 

78 

79 return result 

80 

81# ---------------------------------------------------------------- 

82# SEARCH USERS FROM KEYCLOAK 

83# ---------------------------------------------------------------- 

84@router.get("/search") 

85def search_users_keycloak( 

86 query: str, 

87 limit: int = 10, 

88 db: Session = Depends(get_db), 

89 current_user: User = Depends(require_staff) 

90): 

91 """ 

92 Search users directly from Keycloak by username, email, or name 

93 - **Requires**: TEACHER or ADMIN role 

94 - Returns users from Keycloak (not local DB) 

95 

96 Response: 

97 - id: Keycloak user ID 

98 - username: Username 

99 - email: Email address 

100 - firstName: First name 

101 - lastName: Last name 

102 - enabled: Account enabled status 

103 """ 

104 if not query or len(query) < 2: 

105 raise HTTPException( 

106 status_code=status.HTTP_400_BAD_REQUEST, 

107 detail="Search query must be at least 2 characters" 

108 ) 

109 

110 # ``db`` is injected via Depends(get_db) so test dependency overrides 

111 # apply and the route never writes against the dev DB. 

112 from app.utils.keycloak_auth import sync_user_from_keycloak 

113 

114 keycloak_users = search_keycloak_users(query, limit) 

115 results = [] 

116 for kc_user in keycloak_users: 

117 # Create/update the user in the local DB 

118 db_user = sync_user_from_keycloak(db, kc_user) 

119 results.append({ 

120 "userId": db_user.userId, 

121 "email": db_user.email, 

122 "username": db_user.username, 

123 "role": db_user.role, 

124 "courseId": db_user.courseId, 

125 "created_at": db_user.created_at, 

126 "keycloak_id": db_user.keycloak_id, 

127 "firstName": kc_user.get("firstName"), 

128 "lastName": kc_user.get("lastName"), 

129 }) 

130 return results 

131 

132# ---------------------------------------------------------------- 

133# GET USER BY ID 

134# ---------------------------------------------------------------- 

135@router.get("/{user_id}", response_model=UserWithCourse) 

136def get_user( 

137 user_id: UUID, 

138 db: Session = Depends(get_db), 

139 current_user: User = Depends(get_current_user_keycloak) 

140): 

141 """ 

142 Get user by ID 

143 - **Students**: Can only view their own profile 

144 - **Teachers/Admins**: Can view any profile 

145 """ 

146 user = crud_users.get_user(db, user_id) 

147 if not user: 

148 raise HTTPException( 

149 status_code=status.HTTP_404_NOT_FOUND, 

150 detail="User not found" 

151 ) 

152 

153 # Check access permission 

154 if current_user.role == UserRole.STUDENT and user_id != current_user.userId: 

155 raise HTTPException( 

156 status_code=status.HTTP_403_FORBIDDEN, 

157 detail="You can only view your own profile" 

158 ) 

159 

160 return user 

161 

162# ---------------------------------------------------------------- 

163# GET USER STATISTICS 

164# ---------------------------------------------------------------- 

165@router.get("/{user_id}/statistics", response_model=UserStatistics) 

166def get_user_statistics( 

167 user_id: UUID, 

168 db: Session = Depends(get_db), 

169 current_user: User = Depends(get_current_user_keycloak) 

170): 

171 """ 

172 Get user statistics 

173 - **Owner or Teacher/Admin** can view 

174 """ 

175 user = crud_users.get_user(db, user_id) 

176 if not user: 

177 raise HTTPException( 

178 status_code=status.HTTP_404_NOT_FOUND, 

179 detail="User not found" 

180 ) 

181 

182 # Check access permission 

183 ensure_view_user(current_user, user_id) 

184 

185 # Get statistics 

186 apps = crud_apps.get_apps(db, user_id=user_id, limit=1000) 

187 deployments = crud_deployments.get_deployments(db, user_id=user_id, limit=1000) 

188 

189 return UserStatistics( 

190 total_apps=len(apps), 

191 total_deployments=len(deployments), 

192 successful_deployments=len([d for d in deployments if d.status.value == "success"]), 

193 failed_deployments=len([d for d in deployments if d.status.value == "failed"]), 

194 pending_deployments=len([d for d in deployments if d.status.value == "pending"]) 

195 ) 

196 

197# ---------------------------------------------------------------- 

198# UPDATE USER (ROLE ONLY — ADMIN) 

199# ---------------------------------------------------------------- 

200@router.put("/{user_id}", response_model=UserResponse) 

201def update_user( 

202 user_id: UUID, 

203 user_update: UserUpdate, 

204 db: Session = Depends(get_db), 

205 current_user: User = Depends(get_current_user_keycloak) 

206): 

207 """Update a user record. 

208 

209 Profile edits (firstName/lastName/email/username) happen exclusively 

210 in Keycloak; ``UserUpdate`` only carries ``role`` and ``courseId``. 

211 Both are admin-only: ``role`` changes go through 

212 :func:`ensure_change_user_role` (403 with ``{code: "role_required", 

213 required: ["admin"]}``), and ``courseId`` changes require admin too. 

214 """ 

215 user = crud_users.get_user(db, user_id) 

216 if not user: 

217 raise HTTPException( 

218 status_code=status.HTTP_404_NOT_FOUND, 

219 detail="User not found" 

220 ) 

221 

222 # Any change to a non-profile column (role, courseId) is admin-only. 

223 payload = user_update.model_dump(exclude_unset=True) 

224 if "role" in payload: 

225 ensure_change_user_role(current_user) 

226 if "courseId" in payload and current_user.role != UserRole.ADMIN: 

227 raise HTTPException( 

228 status_code=status.HTTP_403_FORBIDDEN, 

229 detail={ 

230 "code": "role_required", 

231 "required": [UserRole.ADMIN.value], 

232 }, 

233 ) 

234 

235 updated_user = crud_users.update_user(db, user_id, user_update) 

236 return updated_user 

237 

238 

239# ---------------------------------------------------------------- 

240# DELETE USER 

241# ---------------------------------------------------------------- 

242# User deletion is handled exclusively in Keycloak; the app exposes no 

243# delete endpoint to avoid dangling user_ids on apps/deployments/tasks.