Coverage for server / models / clients.py: 89%

260 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-04 09:33 +0000

1import re 

2from datetime import datetime, timezone 

3from typing import Annotated, Optional 

4from beanie import Document, Indexed, PydanticObjectId 

5from fastapi import HTTPException 

6from pydantic import ( 

7 BaseModel, 

8 EmailStr, 

9 Field, 

10 ValidationInfo, 

11 field_validator, 

12 model_validator, 

13) 

14from server.validators.user_enum import DesignationEnum 

15 

16 

17class Client(Document): 

18 first_name: str 

19 middle_name: Optional[str] = None 

20 last_name: str 

21 full_name: str 

22 role: str = "client" 

23 email: Annotated[str, Indexed(unique=True)] 

24 password: str 

25 district: Optional[str] 

26 designation: Optional[str] 

27 office_address: Optional[str] 

28 contact_no: Optional[str] 

29 profile_picture: Optional[str] = None 

30 created_by: Optional[str] = None 

31 updated_by: Optional[str] = None 

32 created_at: Optional[datetime] = None 

33 updated_at: Optional[datetime] = None 

34 

35 class Settings: 

36 name = "client_collection" 

37 indexes = [[("role", 1)]] # Additional index on the role field 

38 

39 @field_validator("updated_at", mode="before", check_fields=True) 

40 def set_updated_at_now(v): 

41 return v or datetime.now(timezone.utc) 

42 

43 @field_validator("created_at", mode="before", check_fields=True) 

44 def set_created_at_now(v): 

45 return v or datetime.now(timezone.utc) 

46 

47 

48class ClientRegistrationRequestSchema(BaseModel): 

49 first_name: str 

50 middle_name: Optional[str] = None 

51 last_name: str 

52 full_name: Optional[str] = None 

53 email: EmailStr 

54 password: str 

55 repeat_password: str 

56 district: str 

57 designation: str 

58 office_address: str 

59 contact_no: str 

60 created_by: Optional[str] = None 

61 created_at: Optional[datetime] = None 

62 updated_at: Optional[datetime] = None 

63 

64 @field_validator("*", mode="before") 

65 def blank_strings(cls, v): 

66 if isinstance(v, str): 

67 v = v.strip() 

68 if v == "": 

69 return None 

70 return v 

71 

72 @model_validator(mode="before") 

73 def set_full_name(self): 

74 if "first_name" in self and "last_name" in self: 

75 first_name = self["first_name"] 

76 last_name = self["last_name"] 

77 if "middle_name" in self: 

78 middle_name = self["middle_name"] 

79 self["full_name"] = f"{first_name} {middle_name} {last_name}" 

80 else: 

81 self["full_name"] = f"{first_name} {last_name}" 

82 

83 return self 

84 

85 @field_validator("email") 

86 def validate_email(cls, v): 

87 if not re.match(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", v): 

88 raise ValueError("Invalid email address") 

89 return v 

90 

91 @field_validator("designation", mode="before") 

92 @classmethod 

93 def validate_designation(cls, v): 

94 valid_des = [ 

95 "superintendent", 

96 "assistant superintendent", 

97 "director", 

98 "executive director", 

99 "technology administrator", 

100 "specialist", 

101 "principal", 

102 "assistant principal", 

103 "vice principal", 

104 "dean", 

105 "councilor", 

106 "other", 

107 ] 

108 # Normalize input to title case 

109 normalized_designation = v.strip().lower() 

110 

111 if normalized_designation not in valid_des: 

112 raise ValueError( 

113 f"Invalid designation. Valid designation options: {valid_des}" 

114 ) 

115 return normalized_designation 

116 

117 @field_validator("password") 

118 def validate_password(cls, v): 

119 if not re.match( 

120 r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{10,25}$", v 

121 ): 

122 raise ValueError( 

123 "Password must be between 10 and 25 characters long and contain at least one uppercase letter, one lowercase letter, one digit, and one special character" 

124 ) 

125 return v 

126 

127 @field_validator("repeat_password") 

128 def validate_repeat_password(cls, v, info: ValidationInfo, **kwargs): 

129 if "password" in info.data and v != info.data["password"]: 

130 raise HTTPException(status_code=400, detail="Passwords do not match") 

131 return v 

132 

133 @field_validator("updated_at", mode="before") 

134 @classmethod 

135 def set_updated_at_now(cls, v): 

136 return v or datetime.now(timezone.utc) 

137 

138 @field_validator("created_at", mode="before") 

139 @classmethod 

140 def set_created_at_now(cls, v): 

141 return v or datetime.now(timezone.utc) 

142 

143 class Config: 

144 json_schema_extra = { 

145 "example": { 

146 "first_name": "John", 

147 "middle_name": "David", 

148 "last_name": "Doe", 

149 "email": "validemail@gmail.com", 

150 "district": "district", 

151 "designation": "superintendent", 

152 "office_address": "office address", 

153 "contact_no": "contact number", 

154 "password": "HeyyWorld123!", 

155 "repeat_password": "HeyyWorld123!", 

156 } 

157 } 

158 

159 

160class ClientLoginRequest(BaseModel): 

161 email: Optional[str] = None 

162 password: Optional[str] = None 

163 

164 @field_validator("*", mode="before") 

165 def blank_strings(cls, v): 

166 if isinstance(v, str): 

167 v = v.strip() 

168 if v == "": 

169 return None 

170 return v 

171 

172 class Config: 

173 json_schema_extra = { 

174 "example": { 

175 "email": "example@gmail.com", 

176 "password": "password123!", 

177 } 

178 } 

179 

180 

181class IndividualClientSchema(BaseModel): 

182 id: PydanticObjectId = Field(alias="_id") 

183 first_name: str 

184 middle_name: Optional[str] = None 

185 last_name: str 

186 role: str 

187 email: str 

188 created_by: Optional[str] = None 

189 created_at: Optional[datetime] = None 

190 updated_by: Optional[str] = None 

191 updated_at: Optional[datetime] = None 

192 

193 

194class School(Document): 

195 client_id: Optional[PydanticObjectId] = None 

196 district_id: PydanticObjectId # Required reference to the district 

197 school_name: Annotated[str, Indexed(unique=True)] 

198 address: str 

199 state: str 

200 city: str 

201 zip_code: str 

202 street: Optional[str] = None # Made optional since we use address 

203 status: str = "active" # active, inactive, deleted 

204 phone: str 

205 email: Annotated[str, Indexed(unique=True)] 

206 school_logo: Optional[str] = None # URL for uploaded school logo 

207 bulk_users_file: Optional[str] = None # URL of the uploaded CSV file 

208 no_of_students: Optional[int] = 0 

209 no_of_teachers: Optional[int] = 0 

210 student_total_usage_time_in_minutes: Optional[float] = 0 

211 teacher_total_usage_time_in_minutes: Optional[float] = 0 

212 total_usage_time_in_minutes: Optional[float] = 0 

213 student_total_no_of_visits: Optional[int] = 0 

214 teacher_total_no_of_visits: Optional[int] = 0 

215 total_no_of_visits: Optional[int] = 0 

216 created_at: Optional[datetime] = None 

217 updated_at: Optional[datetime] = None 

218 created_by: Optional[PydanticObjectId] = None 

219 updated_by: Optional[PydanticObjectId] = None 

220 

221 class Settings: 

222 name = "school_collection" 

223 indexes = [ 

224 [("district_id", 1)], # Index on district_id for faster queries 

225 [("status", 1)], # Index on status for filtering 

226 [("school_name", 1), ("district_id", 1)] # Compound index for unique school names within a district 

227 ] 

228 

229 

230class StudentCSVData(BaseModel): 

231 """Schema for validating student data from CSV""" 

232 email: EmailStr 

233 first_name: str 

234 middle_name: Optional[str] = None 

235 last_name: str 

236 grade: Optional[str] = None 

237 section: Optional[str] = None # For class assignment 

238 semester: Optional[str] = "Fall" # Default semester 

239 status: Optional[str] = "active" # Student status in the system 

240 

241 class Config: 

242 json_schema_extra = { 

243 "example": { 

244 "email": "student@example.com", 

245 "first_name": "John", 

246 "middle_name": "M", 

247 "last_name": "Doe", 

248 "grade": "10", 

249 "section": "A", 

250 "semester": "Fall" 

251 } 

252 } 

253 

254 

255class SchoolRegistration(BaseModel): 

256 district_id: PydanticObjectId # Required reference to the district 

257 school_name: str 

258 state: str 

259 city: str 

260 zip_code: str 

261 street: str 

262 phone: str 

263 email: EmailStr 

264 school_logo: Optional[str] = None # URL for uploaded school logo 

265 created_by: Optional[PydanticObjectId] = None 

266 

267 @field_validator("email") 

268 @classmethod 

269 def check_email(cls, v): 

270 if v: 

271 regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" 

272 if re.fullmatch(regex, v): 

273 return v 

274 else: 

275 raise ValueError("email is invalid") 

276 else: 

277 raise ValueError("email field should not be empty") 

278 

279 class Config: 

280 json_schema_extra = { 

281 "example": { 

282 "district_id": "507f1f77bcf86cd799439011", 

283 "school_name": "Harvard University", 

284 "state": "Massachusetts", 

285 "city": "Cambridge", 

286 "zip_code": "02138", 

287 "street": "Massachusetts Hall", 

288 "phone": "0992929394", 

289 "email": "harvard@edu.com" 

290 } 

291 } 

292 

293 

294class SchoolResponseSchema(BaseModel): 

295 id: PydanticObjectId = Field(alias="_id") 

296 district_id: PydanticObjectId 

297 school_name: str 

298 state: str 

299 city: str 

300 zip_code: str 

301 street: str 

302 status: str 

303 phone: str 

304 email: str 

305 school_logo: Optional[str] = None 

306 no_of_students: Optional[int] = 0 

307 no_of_teachers: Optional[int] = 0 

308 student_total_usage_time_in_minutes: Optional[float] = 0 

309 teacher_total_usage_time_in_minutes: Optional[float] = 0 

310 total_usage_time_in_minutes: Optional[float] = 0 

311 student_total_no_of_visits: Optional[int] = 0 

312 teacher_total_no_of_visits: Optional[int] = 0 

313 total_no_of_visits: Optional[int] = 0 

314 created_at: Optional[datetime] = None 

315 updated_at: Optional[datetime] = None 

316 created_by: Optional[PydanticObjectId] = None 

317 updated_by: Optional[PydanticObjectId] = None 

318 

319 

320class ClientUserRegistrationRequestSchema(BaseModel): 

321 client_id: Optional[PydanticObjectId] = None 

322 school_id: PydanticObjectId 

323 first_name: str 

324 middle_name: Optional[str] = None 

325 last_name: str 

326 email: EmailStr 

327 role: str 

328 status: Optional[str] = "inactive" 

329 password: Optional[str] = None 

330 created_at: Optional[datetime] = None 

331 updated_at: Optional[datetime] = None 

332 

333 @field_validator("*", mode="before") 

334 def blank_strings(cls, v): 

335 if isinstance(v, str): 

336 v = v.strip() 

337 if v == "": 

338 return None 

339 return v 

340 

341 @field_validator("email") 

342 @classmethod 

343 def check_email(cls, v): 

344 if v: 

345 regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" 

346 if re.fullmatch(regex, v): 

347 return v 

348 else: 

349 raise ValueError("email is invalid") 

350 else: 

351 raise ValueError("email field should not be empty") 

352 

353 @field_validator("updated_at", mode="before") 

354 @classmethod 

355 def set_updated_at_now(cls, v): 

356 return v or datetime.now(timezone.utc) 

357 

358 @field_validator("created_at", mode="before") 

359 @classmethod 

360 def set_created_at_now(cls, v): 

361 return v or datetime.now(timezone.utc) 

362 

363 class Config: 

364 json_schema_extra = { 

365 "example": { 

366 "first_name": "John", 

367 "middle_name": "David", 

368 "last_name": "Doe", 

369 "email": "validemail@gmail.com", 

370 "role": "teacher", 

371 } 

372 } 

373 

374 

375class UserResponseSchema(BaseModel): 

376 id: PydanticObjectId = Field(alias="_id") 

377 first_name: str 

378 middle_name: Optional[str] = None 

379 last_name: str 

380 role: str 

381 email: str 

382 client_id: PydanticObjectId 

383 school_id: PydanticObjectId 

384 total_usage_time_in_minutes: Optional[float] = 0 

385 total_no_of_visits: Optional[int] = 0 

386 created_at: Optional[datetime] = None 

387 updated_at: Optional[datetime] = None 

388 

389 

390class ClientUserSessionDuration(BaseModel): 

391 session_duration_in_minutes: float 

392 

393 

394# Removed duplicate SchoolResponseSchema - using the complete one defined earlier