Coverage for server / models / users.py: 87%

331 statements  

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

1""" 

2User Models Module 

3 

4This module contains Pydantic models and MongoDB document schemas for user management 

5in the FastAPI application. It handles various user types (students and teachers), 

6authentication, and account management. 

7 

8Key Features: 

9- User authentication and authorization models 

10- Student and Teacher specific models 

11- Password reset and account activation functionality 

12- Contact and educational background information 

13- Response models for API endpoints 

14 

15Technical Details: 

16- Uses Beanie for MongoDB ODM integration 

17- Implements Pydantic for data validation 

18- Supports both sync and async operations 

19- Includes comprehensive input validation 

20 

21""" 

22 

23import re 

24from datetime import datetime, timezone 

25from typing import List, Optional, Any 

26from beanie import Document, PydanticObjectId, Indexed 

27from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator 

28from server.validators.accounts_validator import password_must_be_valid 

29from server.utilities.html_sanitizer import strip_html 

30from fastapi import HTTPException, status 

31 

32 

33def is_not_empty(cls, values): 

34 """ 

35 Validates that required fields in a model are not empty strings or whitespace. 

36 

37 Args: 

38 cls: The class reference (automatically provided by validator decorator) 

39 values: Dictionary of field names and their values to validate 

40 

41 Returns: 

42 dict: The validated values dictionary if all checks pass 

43 

44 Raises: 

45 ValueError: If any required field is empty or contains only whitespace 

46 

47 Validation Rules: 

48 - Skips validation for optional fields: 

49 - middle_name 

50 - created_by 

51 - updated_by 

52 - phone_number 

53 - contact_person 

54 - All other fields must contain non-whitespace characters 

55 - Leading/trailing whitespace is trimmed before validation 

56 

57 Example: 

58 ```python 

59 # Valid data 

60 values = { 

61 "first_name": "John", 

62 "last_name": "Smith", 

63 "middle_name": "", # Optional field, skipped 

64 "email": "john@example.com" 

65 } 

66 result = is_not_empty(cls, values) # Returns values dict 

67 

68 # Invalid data 

69 values = { 

70 "first_name": " ", # Empty after strip() 

71 "last_name": "Smith" 

72 } 

73 result = is_not_empty(cls, values) # Raises ValueError 

74 ``` 

75 

76 Usage Notes: 

77 - Typically used as a model validator with mode="before" 

78 - Processes all fields before any field-specific validation 

79 - Maintains optional field flexibility while ensuring required fields 

80 """ 

81 # Define set of fields that are allowed to be empty 

82 optional_field_names = { 

83 "middle_name", # Middle name is always optional 

84 "created_by", # Creator ID may not be available for new records 

85 "updated_by", # Updater ID may not be available for new records 

86 "phone_number", # Phone number is optional contact info 

87 "contact_person", # Emergency contact details are optional 

88 } 

89 

90 # Iterate through all field-value pairs in the input 

91 for field_name, field_value in values.items(): 

92 # Skip validation for optional fields 

93 if field_name in optional_field_names: 

94 continue 

95 

96 # Check if required field is empty after removing whitespace 

97 if field_value.strip() == "": 

98 raise HTTPException( 

99 status_code=status.HTTP_400_BAD_REQUEST, 

100 detail=f"{field_name} field should not be empty", 

101 ) 

102 

103 # Return validated values if all checks pass 

104 return values 

105 

106 

107# The User class represents a user with various attributes such as subscriber ID, name, role, status, 

108# email, password, and timestamps for creation and update. 

109class User(Document): 

110 """ 

111 Base user document schema for MongoDB. 

112 

113 This model serves as the foundation for all user types in the system, 

114 containing common fields and validation logic. 

115 

116 Attributes: 

117 client_id: Optional reference to associated client document 

118 school_id: Optional reference to associated school document 

119 first_name: User's legal first name 

120 middle_name: Optional middle name 

121 last_name: User's legal last name 

122 role: User's system role ('student' or 'teacher') 

123 status: Account status ('active', 'inactive', etc.) 

124 email: Unique email address, indexed for quick lookups 

125 password: Securely hashed password string (optional for Auth0 users) 

126 auth0_user_id: Auth0 user identifier (e.g., 'auth0|123456') 

127 auth_provider: Authentication provider ('local' or 'auth0') 

128 profile_picture: Optional URL to user's profile image 

129 total_usage_time_in_minutes: Cumulative platform usage time 

130 total_no_of_visits: Total number of platform logins 

131 created_at: UTC timestamp of account creation 

132 updated_at: UTC timestamp of last modification 

133 

134 Database Configuration: 

135 - Collection name: 'user_collection' 

136 - Indexes: 

137 - Role (ascending) 

138 - Email (unique) 

139 

140 Validation: 

141 - Email must be unique and valid format 

142 - Timestamps automatically set in UTC 

143 - Optional fields properly handle null values 

144 

145 Usage Notes: 

146 - Use for inheritance by specific user types 

147 - Handles both sync and async operations 

148 - Supports automatic timestamp management 

149 - Includes built-in MongoDB indexing 

150 

151 Example: 

152 ```python 

153 user = User( 

154 first_name="John", 

155 last_name="Doe", 

156 role="student", 

157 status="active", 

158 email="john.doe@example.com", 

159 password="hashed_password_string" 

160 ) 

161 ``` 

162 """ 

163 

164 client_id: Optional[PydanticObjectId] = None 

165 school_id: Optional[PydanticObjectId] = None 

166 school: Optional[str] = None 

167 first_name: str 

168 middle_name: Optional[str] = None 

169 last_name: str 

170 role: str 

171 status: Optional[str] = None 

172 email: str = Field(unique=True, index=True) 

173 password: Optional[str] = None # Optional for Auth0 users 

174 auth0_user_id: Optional[str] = Field(None, index=True) # Auth0 user identifier 

175 auth_provider: Optional[str] = Field(default="local") # 'local' or 'auth0' 

176 profile_picture: Optional[str] = None 

177 # Added by Allan Ninal — 2026-09-24 (EI-T78). 

178 # WHY: `account_update` writes this with a raw `$set`, so Mongo holds it and 

179 # the UPDATE response shows it (hand-injected there). But the field was 

180 # never declared on this model, and `user_account_fetch` serialises via 

181 # `account.model_dump(mode="json")` — Pydantic emits only DECLARED 

182 # fields, so `contact_person` was silently dropped on every read. A 

183 # student could save an emergency contact, see it echoed once, and never 

184 # see it again; the data was in the database the whole time. 

185 # Forward reference because ContactPerson is defined further down this 

186 # file; User.model_rebuild() runs after that definition. 

187 contact_person: Optional["ContactPerson"] = None 

188 total_usage_time_in_minutes: Optional[float] = 0 

189 total_no_of_visits: Optional[int] = 0 

190 created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 

191 updated_at: Optional[datetime] = None 

192 

193 @field_validator("first_name") 

194 @classmethod 

195 def validate_first_name(model_class, first_name): 

196 if first_name: 

197 if len(first_name) > 50: 

198 raise HTTPException( 

199 status_code=status.HTTP_400_BAD_REQUEST, 

200 detail="First name must be no more than 50 characters long", 

201 ) 

202 return first_name 

203 else: 

204 raise HTTPException( 

205 status_code=status.HTTP_401_UNAUTHORIZED, detail="First name is invalid" 

206 ) 

207 

208 @field_validator("middle_name") 

209 @classmethod 

210 def validate_middle_name(model_class, middle_name): 

211 if middle_name: 

212 if len(middle_name) > 25: 

213 raise HTTPException( 

214 status_code=status.HTTP_400_BAD_REQUEST, 

215 detail="Middle name must be no more than 25 characters long", 

216 ) 

217 return middle_name 

218 

219 @field_validator("last_name") 

220 @classmethod 

221 def validate_last_name(model_class, last_name): 

222 if last_name: 

223 if len(last_name) > 50: 

224 raise HTTPException( 

225 status_code=status.HTTP_400_BAD_REQUEST, 

226 detail="Last name must be no more than 50 characters long", 

227 ) 

228 return last_name 

229 

230 @field_validator("school") 

231 @classmethod 

232 def validate_school(model_class, school): 

233 if school: 

234 # The most common university names in Texas range from about 14-36 characters: 

235 # Shorter names (14-20 characters): Rice University, Baylor University, Texas Tech University 

236 # Medium names (21-25 characters): University of Texas at Austin, Prairie View A&M University 

237 # Longer names (26-40 characters): Texas A&M University–Central Texas, Texas A&M University–Corpus Christi 

238 if len(school) > 40: 

239 raise HTTPException( 

240 status_code=status.HTTP_400_BAD_REQUEST, 

241 detail="School must be no more than 40 characters long", 

242 ) 

243 if len(school) < 3: 

244 raise HTTPException( 

245 status_code=status.HTTP_400_BAD_REQUEST, 

246 detail="School must be at least 3 characters long", 

247 ) 

248 return school 

249 return None 

250 

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

252 @classmethod 

253 def set_updated_at_now(cls, current_timestamp): 

254 """ 

255 Sets the updated_at timestamp to current UTC time if not provided. 

256 

257 Args: 

258 cls: The class reference (automatically provided by decorator) 

259 current_timestamp: The timestamp value to validate/update 

260 

261 Returns: 

262 datetime: Either the provided timestamp or current UTC timestamp 

263 

264 Example: 

265 ```python 

266 timestamp = set_updated_at_now(None) # Returns current UTC time 

267 timestamp = set_updated_at_now(existing_timestamp) # Returns existing_timestamp 

268 ``` 

269 """ 

270 return current_timestamp or datetime.now(timezone.utc) 

271 

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

273 @classmethod 

274 def set_created_at_now(cls, current_timestamp): 

275 """ 

276 Sets the created_at timestamp to current UTC time if not provided. 

277 

278 Args: 

279 cls: The class reference (automatically provided by decorator) 

280 current_timestamp: The timestamp value to validate/update 

281 

282 Returns: 

283 datetime: Either the provided timestamp or current UTC timestamp 

284 

285 Example: 

286 ```python 

287 timestamp = set_created_at_now(None) # Returns current UTC time 

288 timestamp = set_created_at_now(existing_timestamp) # Returns existing_timestamp 

289 ``` 

290 """ 

291 return current_timestamp or datetime.now(timezone.utc) 

292 

293 @field_validator("email") 

294 @classmethod 

295 def validate_email(model_class, email_value): 

296 """ 

297 Validates email format and ensures it meets security requirements. 

298 

299 This validator ensures that email addresses conform to standard format 

300 requirements and security best practices for user authentication. 

301 

302 Args: 

303 model_class: Class reference (provided by field_validator decorator) 

304 email_value: Email address to validate 

305 

306 Returns: 

307 str: Validated email address if all checks pass 

308 

309 Raises: 

310 HTTPException: 

311 - 401 UNAUTHORIZED: If email is empty 

312 - 401 UNAUTHORIZED: If email format is invalid 

313 

314 Validation Rules: 

315 - Email must not be empty or None 

316 - Must be a string type 

317 - Must contain exactly one @ symbol 

318 - Local part must contain only allowed characters: 

319 - Alphanumeric (a-z, A-Z, 0-9) 

320 - Dots (.), underscores (_), hyphens (-) 

321 - Percentage (%), plus (+) 

322 - Domain part must: 

323 - Contain at least one dot (.) 

324 - End with valid TLD (2+ characters) 

325 - Use only alphanumeric and hyphen characters 

326 

327 Examples: 

328 Valid emails: 

329 >>> validate_email("user@example.com") 

330 "user@example.com" 

331 >>> validate_email("user.name+tag@domain.co.uk") 

332 "user.name+tag@domain.co.uk" 

333 

334 Invalid emails: 

335 >>> validate_email("") # Raises 401 

336 >>> validate_email("invalid-email") # Raises 401 

337 >>> validate_email("user@domain") # Raises 401 

338 >>> validate_email(None) # Raises 401 

339 

340 Notes: 

341 - Uses regex pattern 

342 - 401 UNAUTHORIZED is used instead of 400 BAD REQUEST to prevent email enumeration 

343 - Validation is case-insensitive for domain part 

344 """ 

345 if email_value: 

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

347 if isinstance(email_value, str) and re.fullmatch( 

348 email_pattern, email_value 

349 ): 

350 return email_value 

351 else: 

352 raise HTTPException( 

353 status_code=status.HTTP_401_UNAUTHORIZED, detail="email is invalid" 

354 ) 

355 else: 

356 raise HTTPException( 

357 status_code=status.HTTP_401_UNAUTHORIZED, 

358 detail="email field should not be empty", 

359 ) 

360 

361 # The class "Settings" defines the name of a collection and its indexes. 

362 class Settings: 

363 name = "user_collection" 

364 indexes = [[("role", 1)]] 

365 

366 

367class UserBio(BaseModel): 

368 """ 

369 Model for representing user's basic biographical information. 

370 

371 Attributes: 

372 first_name (str): Required. The first name of the user 

373 middle_name (str, optional): Optional. The middle name of the user 

374 last_name (str): Required. The last name of the user 

375 """ 

376 

377 first_name: str = Field(..., description="Required. The first name of the user.") 

378 middle_name: Optional[str] = Field( 

379 None, description="Optional. The middle name of the user." 

380 ) 

381 last_name: str = Field(..., description="Required. The last name of the user.") 

382 

383 @field_validator("first_name") 

384 @classmethod 

385 def first_name_must_be_nonempty(cls, value: str) -> str: 

386 """ 

387 Validates that the first name field is not empty or whitespace. 

388 

389 Args: 

390 cls: The class reference (automatically provided by decorator) 

391 value: The first name to validate 

392 

393 Returns: 

394 str: The validated and cleaned first name 

395 

396 Raises: 

397 ValueError: If first name is empty, non-string, or contains only whitespace 

398 

399 Example: 

400 ```python 

401 name = first_name_must_be_nonempty("John") # Returns "John" 

402 name = first_name_must_be_nonempty("") # Raises ValueError 

403 name = first_name_must_be_nonempty(" ") # Raises ValueError 

404 ``` 

405 """ 

406 if not value or not isinstance(value, str): 

407 raise ValueError("First name is required") 

408 

409 cleaned = value.strip() 

410 if not cleaned: 

411 raise ValueError("First name cannot be empty or whitespace") 

412 

413 return cleaned 

414 

415 @field_validator("middle_name") 

416 @classmethod 

417 def middle_name_must_be_nonempty(cls, value: Optional[str]) -> Optional[str]: 

418 """ 

419 Validates middle name field, converting empty strings to None. 

420 

421 Args: 

422 cls: The class reference (automatically provided by decorator) 

423 value: The middle name to validate 

424 

425 Returns: 

426 Optional[str]: The validated and cleaned middle name or None if empty 

427 

428 Raises: 

429 ValueError: If middle name contains only whitespace 

430 

431 Example: 

432 ```python 

433 name = middle_name_must_be_nonempty("James") # Returns "James" 

434 name = middle_name_must_be_nonempty("") # Returns None 

435 name = middle_name_must_be_nonempty(" ") # Raises ValueError 

436 ``` 

437 """ 

438 if not value: 

439 return None 

440 

441 if not isinstance(value, str): 

442 raise ValueError("Middle name must be a string") 

443 

444 cleaned = value.strip() 

445 if not cleaned: 

446 raise ValueError("Middle name cannot be whitespace only") 

447 

448 return cleaned 

449 

450 @field_validator( 

451 "last_name", 

452 ) 

453 @classmethod 

454 def last_name_must_be_nonempty(cls, value: str) -> str: 

455 """ 

456 Validates that the last name field is not empty or whitespace. 

457 

458 Args: 

459 cls: The class reference (automatically provided by decorator) 

460 value: The last name to validate 

461 

462 Returns: 

463 str: The validated and cleaned last name 

464 

465 Raises: 

466 ValueError: If last name is empty, non-string, or contains only whitespace 

467 

468 Example: 

469 ```python 

470 name = last_name_must_be_nonempty("Smith") # Returns "Smith" 

471 name = last_name_must_be_nonempty("") # Raises ValueError 

472 name = last_name_must_be_nonempty(" ") # Raises ValueError 

473 ``` 

474 """ 

475 if not value or not isinstance(value, str): 

476 raise ValueError("Last name is required") 

477 

478 cleaned = value.strip() 

479 if not cleaned: 

480 raise ValueError("Last name cannot be empty or whitespace") 

481 

482 return cleaned 

483 

484 

485# The ContactPerson class represents a person's contact information including their name, 

486# relationship, address, and phone number. 

487class ContactPerson(BaseModel): 

488 """ 

489 Schema for emergency contact information. 

490 

491 Used to store details of a person who can be contacted in case of emergencies 

492 or important notifications regarding a student. 

493 

494 Attributes: 

495 first_name: Contact person's first name 

496 middle_name: Optional middle name 

497 last_name: Contact person's last name 

498 relationship: Relationship to the primary user (e.g., 'Parent', 'Guardian') 

499 country: Full country name 

500 state: State/province/region name 

501 city: City/town name 

502 street: Optional street address details 

503 zip_code: Postal/ZIP code 

504 phone_number: Contact phone number with country code 

505 

506 Validation: 

507 - Required fields cannot be empty 

508 - Phone number must include country code 

509 - Address fields follow standardized format 

510 

511 Usage Notes: 

512 - Primarily used with Student accounts 

513 - Supports international addresses 

514 - Phone numbers should include country code 

515 - All fields are sanitized before storage 

516 

517 Example: 

518 ```python 

519 emergency_contact = ContactPerson( 

520 first_name="John", 

521 last_name="Doe", 

522 relationship="Parent", 

523 country="United States", 

524 state="California", 

525 city="San Francisco", 

526 zip_code="94105", 

527 phone_number="+1-555-0123" 

528 ) 

529 ``` 

530 """ 

531 

532 first_name: str 

533 middle_name: Optional[str] = None 

534 last_name: str 

535 relationship: str 

536 country: str 

537 state: str 

538 city: str 

539 street: Optional[str] = None 

540 zip_code: Optional[str] = None 

541 phone_number: str 

542 

543 @field_validator( 

544 "first_name", 

545 "middle_name", 

546 "last_name", 

547 "relationship", 

548 "country", 

549 "state", 

550 "city", 

551 "street", 

552 ) 

553 @classmethod 

554 def validate_required_fields(cls, value, info): 

555 field_name = info.field_name.replace("_", " ").capitalize() 

556 # Strip markup BEFORE the blank and length checks. A contact person is 

557 # plain text — the class docstring has always claimed "All fields are 

558 # sanitized before storage", but nothing enforced it, so a payload like 

559 # "<script>x</script>" was stored and echoed back byte-for-byte. 

560 # 

561 # Order matters twice over. The 25-character limit used to reject the 

562 # obvious probe "<script>alert('XSS');</script>" (30 chars) on LENGTH, 

563 # which reads like sanitisation but never looks at the markup — a 

564 # shorter payload sailed through. Stripping first means the limit now 

565 # applies to the visible text, so nobody can spend 30 characters of 

566 # markup inside a 25-character field either. A value that is nothing 

567 # but markup reduces to "" and is refused by the blank check below. 

568 # Added by Allan Ninal — 2026-09-24. `middle_name`, `street` and 

569 # `zip_code` are Optional[str] = None, and this validator lists 

570 # middle_name and street. The isinstance() guard below protects 

571 # strip_html, but `value.strip()` was called unconditionally on the very 

572 # next line, so any None reached it and raised 

573 # "'NoneType' object has no attribute 'strip'". 

574 # 

575 # Latent until 2026-09-24: nothing loaded a stored ContactPerson back 

576 # through the model, because `contact_person` was not declared on User 

577 # (EI-T78). Declaring it made User validate the stored value on every 

578 # read, and that turned /v1/student/account/fetch into a 500 for any 

579 # account whose contact person had no middle name. 

580 if value is None: 

581 return value 

582 if isinstance(value, str): 

583 value = strip_html(value) 

584 value = value.strip() 

585 if not value: 

586 raise HTTPException( 

587 status_code=status.HTTP_400_BAD_REQUEST, 

588 detail=f"{field_name} cannot contain blank values in contact person", 

589 ) 

590 if len(value) > 25: 

591 raise HTTPException( 

592 status_code=status.HTTP_400_BAD_REQUEST, 

593 detail=f"{field_name} must be no more than 25 characters long in contact person", 

594 ) 

595 return value 

596 

597 @field_validator("phone_number") 

598 @classmethod 

599 def validate_phone_number(cls, phone_number): 

600 phone_number = phone_number.strip() 

601 if not phone_number: 

602 raise HTTPException( 

603 status_code=status.HTTP_400_BAD_REQUEST, 

604 detail="Phone number cannot contain blank values in contact person", 

605 ) 

606 if not phone_number.isdigit(): 

607 raise HTTPException( 

608 status_code=status.HTTP_400_BAD_REQUEST, 

609 detail="Phone number must contain only numeric digits in contact person", 

610 ) 

611 if len(phone_number) != 11: 

612 raise HTTPException( 

613 status_code=status.HTTP_400_BAD_REQUEST, 

614 detail="Phone number must be exactly 11 digits long in contact person", 

615 ) 

616 return phone_number 

617 

618 @field_validator("zip_code") 

619 @classmethod 

620 def validate_zip_code(cls, zip_code): 

621 # Added by Allan Ninal — 2026-09-24. `zip_code` is Optional[str] = None 

622 # and this stripped unconditionally, so a stored contact person without a 

623 # zip code raised "'NoneType' object has no attribute 'strip'". Same 

624 # defect as validate_required_fields above, same cause: the field is 

625 # optional but the validator assumed a string. 

626 # 

627 # `phone_number` is deliberately NOT given this guard — it is a REQUIRED 

628 # str, so Pydantic's type check rejects None before the validator runs, 

629 # and that produces a clean ValidationError which 

630 # `_fetch_account_document` already falls back from. Returning None there 

631 # would instead let a required field through empty. 

632 if zip_code is None: 

633 return zip_code 

634 zip_code = zip_code.strip() 

635 if not zip_code: 

636 raise HTTPException( 

637 status_code=status.HTTP_400_BAD_REQUEST, 

638 detail="Zip code cannot contain blank values in contact person", 

639 ) 

640 if not zip_code.isdigit() or len(zip_code) not in [5, 6, 9]: 

641 raise HTTPException( 

642 status_code=status.HTTP_400_BAD_REQUEST, 

643 detail="Zip code must be a numeric value with a valid length in contact person (e.g., 5, 6, or 9 digits)", 

644 ) 

645 return zip_code 

646 

647 

648# The `OfficeDetails` class represents the details of an office, including its location, conference 

649# time (optional), and phone number. 

650# Resolve User.contact_person's forward reference now that ContactPerson is 

651# defined (EI-T78). Without this the annotation stays unresolved and the 

652# field is unusable. 

653User.model_rebuild() 

654 

655 

656class OfficeDetails(BaseModel): 

657 """ 

658 Schema for teacher's office information. 

659 

660 Contains details about a teacher's physical office location and availability. 

661 

662 Attributes: 

663 location: Physical office location (e.g., "Building A, Room 101") 

664 conference_time: Optional scheduled office hours (e.g., "Mon-Wed 2-4 PM") 

665 phone_number: Office contact number with extension if applicable 

666 

667 Validation: 

668 - Location must not be empty 

669 - Phone number should follow standard format with optional extension 

670 - Conference time should use consistent format (Day-Day Time-Time) 

671 

672 Usage Notes: 

673 - Used primarily with Teacher accounts 

674 - Conference times should specify timezone if applicable 

675 - Phone numbers should include building/department extensions 

676 - Location should follow institution's room numbering convention 

677 

678 Example: 

679 ```python 

680 office = OfficeDetails( 

681 location="Science Building, Room 305", 

682 conference_time="Tuesdays and Thursdays 3-5 PM", 

683 phone_number="555-0123 ext. 567" 

684 ) 

685 ``` 

686 """ 

687 

688 location: str 

689 conference_time: Optional[str] = None 

690 phone_number: str 

691 

692 

693# The `Education` class represents a person's educational background, including the school they 

694# attended, degree obtained (optional), area of study (optional), and the years they started and ended 

695# their education. 

696class Education(BaseModel): 

697 """ 

698 Schema for educational background records. 

699 

700 Represents a single educational qualification or period of study. 

701 

702 Attributes: 

703 school: Name of the educational institution 

704 degree: Optional qualification earned (e.g., "Bachelor of Science") 

705 area_of_study: Optional field of study or major 

706 year_started: Year when studies commenced 

707 year_ended: Year when studies were completed or expected completion 

708 

709 Validation: 

710 - School name must not be empty 

711 - Years must be valid and logical (start < end) 

712 - Years should not be in the future (except for expected completion) 

713 - Degree and area_of_study are optional but must be valid if provided 

714 

715 Usage Notes: 

716 - Multiple Education records can be linked to a single Teacher 

717 - Supports both completed and ongoing education 

718 - Years can be strings to support various date formats 

719 - Institution names should use official full names 

720 

721 Example: 

722 ```python 

723 education = Education( 

724 school="Stanford University", 

725 degree="Master of Education", 

726 area_of_study="Educational Technology", 

727 year_started="2018", 

728 year_ended="2020" 

729 ) 

730 ``` 

731 """ 

732 

733 school: str 

734 degree: Optional[str] = None 

735 area_of_study: Optional[str] = None 

736 year_started: str 

737 year_ended: str 

738 

739 

740# The `EducationList` class is a subclass of `BaseModel` and represents a list of `Education` objects. 

741class EducationList(BaseModel): 

742 """ 

743 Model for representing a collection of education records. 

744 

745 Attributes: 

746 education (List[Education]): List of education records 

747 """ 

748 

749 education: List[Education] 

750 

751 

752# The Student class is a subclass of the User class and has a contact_person attribute of type 

753# ContactPerson. 

754class Student(User): 

755 """ 

756 Student user model extending the base User class. 

757 

758 Represents a student account with additional fields specific to students, 

759 including emergency contact information. 

760 

761 Attributes: 

762 contact_person: Optional emergency contact details including relationship 

763 and contact information 

764 

765 Inheritance: 

766 Inherits all fields from User base class including: 

767 - Basic user information (name, email, etc.) 

768 - Authentication fields 

769 - Timestamps and tracking data 

770 

771 Validation: 

772 - All required fields must not be empty 

773 - Password must meet security requirements 

774 - Blank strings are converted to None for optional fields 

775 - Contact person details must be valid if provided 

776 

777 Database Notes: 

778 - Stored in the same collection as User 

779 - Automatically indexed by role="student" 

780 - Supports efficient querying by school_id 

781 

782 Security Features: 

783 - Password validation and hashing 

784 - Role-based access control 

785 - Activity tracking 

786 

787 Example: 

788 ```python 

789 student = Student( 

790 first_name="Jane", 

791 last_name="Smith", 

792 email="jane.smith@school.edu", 

793 role="student", 

794 status="active", 

795 password="SecurePass123!", 

796 contact_person=ContactPerson( 

797 first_name="John", 

798 last_name="Smith", 

799 relationship="Parent", 

800 country="USA", 

801 state="California", 

802 city="San Francisco", 

803 zip_code="94105", 

804 phone_number="+1-555-0123" 

805 ) 

806 ) 

807 ``` 

808 """ 

809 

810 contact_person: Optional[ContactPerson] = None 

811 _no_empty_required_fields = model_validator(mode="before")(is_not_empty) 

812 _check_password = model_validator(mode="before")(password_must_be_valid) 

813 

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

815 @classmethod 

816 def handle_blank_strings(cls, field_value): 

817 """ 

818 Converts empty strings to None and strips whitespace from string values. 

819 

820 Args: 

821 cls: The class reference (automatically provided by decorator) 

822 field_value: The field value to process 

823 

824 Returns: 

825 Optional[str]: The processed field value, None if empty string 

826 

827 Example: 

828 ```python 

829 value = handle_blank_strings(" text ") # Returns "text" 

830 value = handle_blank_strings("") # Returns None 

831 value = handle_blank_strings(123) # Returns 123 (non-string values unchanged) 

832 ``` 

833 """ 

834 try: 

835 if isinstance(field_value, str): 

836 field_value = field_value.strip() 

837 if field_value == "": 

838 return None 

839 return field_value 

840 except Exception: 

841 # Bare re-raise: it already preserves the exception and traceback, 

842 # so binding it to a name added nothing. 

843 raise 

844 

845 

846class StudentResponseModel(BaseModel): 

847 """ 

848 Response model for student data returned by the API. 

849 

850 Attributes: 

851 id (PydanticObjectId): Unique identifier for the student 

852 school_id (PydanticObjectId, optional): Associated school identifier 

853 client_id (PydanticObjectId, optional): Associated client identifier 

854 first_name (str): Student's first name 

855 middle_name (str, optional): Student's middle name 

856 last_name (str): Student's last name 

857 role (str): User role (student) 

858 status (str): Account status 

859 email (str): Student's email address 

860 profile_picture (str, optional): URL to profile picture 

861 created_at (datetime, optional): Account creation timestamp 

862 updated_at (datetime, optional): Last update timestamp 

863 contact_person (ContactPerson, optional): Emergency contact information 

864 """ 

865 

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

867 school_id: Optional[PydanticObjectId] = None 

868 school: Optional[str] = None 

869 client_id: Optional[PydanticObjectId] = None 

870 first_name: str 

871 middle_name: Optional[str] = None 

872 last_name: str 

873 role: str 

874 status: str 

875 email: str 

876 profile_picture: Optional[str] = None 

877 created_at: Optional[datetime] = None 

878 updated_at: Optional[datetime] = None 

879 contact_person: Optional[ContactPerson] = None 

880 

881 

882class Teacher(User): 

883 """ 

884 Teacher user model extending the base User class with teacher-specific attributes. 

885 

886 Attributes: 

887 office_details (OfficeDetails, optional): Teacher's office information 

888 education (List[Education], optional): List of teacher's educational background 

889 """ 

890 

891 # Optional fields specific to teachers 

892 # OfficeDetails contains location, conference time, and phone number 

893 office_details: Optional[OfficeDetails] = None 

894 

895 # List of educational qualifications/background 

896 # Each Education object contains school, degree, area of study, and years 

897 education: Optional[List[Education]] = None 

898 

899 # Model validators applied before field validation 

900 # Ensures required fields are not empty strings or whitespace 

901 _no_empty_required_fields = model_validator(mode="before")(is_not_empty) 

902 

903 # Validates password complexity requirements 

904 # Must have uppercase, lowercase, numbers, and special characters 

905 _check_password = model_validator(mode="before")(password_must_be_valid) 

906 

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

908 @classmethod 

909 def blank_strings(cls, input_value) -> Any: 

910 """ 

911 Converts empty strings to None and strips whitespace from string values. 

912 

913 This validator processes all fields in the Teacher model, handling string 

914 sanitization and empty value standardization. 

915 

916 Args: 

917 cls: The class reference (automatically provided by decorator) 

918 input_value: The value to validate/sanitize (any type) 

919 

920 Returns: 

921 Any: The processed value: 

922 - Stripped string if input is non-empty string 

923 - None if input is empty string 

924 - Original value if input is non-string 

925 

926 Validation Rules: 

927 - Whitespace is trimmed from start/end of strings 

928 - Empty strings (including whitespace-only) become None 

929 - Non-string values pass through unchanged 

930 

931 Example: ```python 

932 result = blank_strings(" hello ") # Returns "hello" 

933 result = blank_strings("") # Returns None 

934 result = blank_strings(" ") # Returns None 

935 result = blank_strings(123) # Returns 123 

936 result = blank_strings(None) # Returns None ``` 

937 

938 Notes: 

939 - Used as a pre-processing validator for all Teacher model fields 

940 - Helps standardize empty/null value handling 

941 - Maintains data consistency for database storage 

942 - Preserves non-string field types (numbers, dates, etc.) 

943 

944 Raises: 

945 Exception: If string processing fails (passes through original exception) 

946 """ 

947 try: 

948 # If the value is a string, remove leading/trailing whitespace 

949 if isinstance(input_value, str): 

950 cleaned_value = input_value.strip() 

951 

952 # Convert empty strings to None for consistent null handling 

953 if cleaned_value == "": 

954 return None 

955 

956 return cleaned_value 

957 

958 # Return the original value if not a string 

959 return input_value 

960 

961 except Exception as validation_error: 

962 # Pass through any validation errors for proper error handling 

963 raise validation_error 

964 

965 

966class TeacherResponseModel(BaseModel): 

967 """ 

968 API response model for teacher data. 

969 

970 Defines the structure of teacher data returned by API endpoints, 

971 excluding sensitive information like passwords. 

972 

973 Attributes: 

974 id: MongoDB document identifier 

975 school_id: Optional reference to associated school 

976 client_id: Optional reference to associated client/organization 

977 first_name: Teacher's first name 

978 middle_name: Optional middle name 

979 last_name: Teacher's last name 

980 role: User role (always "teacher") 

981 status: Account status (e.g., "active", "inactive") 

982 email: Teacher's email address 

983 profile_picture: Optional URL to profile image 

984 created_at: Account creation timestamp (UTC) 

985 updated_at: Last modification timestamp (UTC) 

986 office_details: Optional teaching office information 

987 education: Optional list of educational qualifications 

988 

989 Data Handling: 

990 - Sensitive data is automatically excluded 

991 - Timestamps are converted to UTC 

992 - Optional fields return null if not set 

993 - Nested objects are fully expanded 

994 

995 Usage Notes: 

996 - Used for API responses only 

997 - Not for data modification 

998 - Supports JSON serialization 

999 - Handles nested document expansion 

1000 

1001 Example: 

1002 ```python 

1003 { 

1004 "id": "507f1f77bcf86cd799439011", 

1005 "first_name": "Robert", 

1006 "last_name": "Johnson", 

1007 "role": "teacher", 

1008 "status": "active", 

1009 "email": "robert.johnson@school.edu", 

1010 "office_details": { 

1011 "location": "Building A, Room 101", 

1012 "conference_time": "Mon/Wed 2-4 PM" 

1013 } 

1014 } 

1015 ``` 

1016 """ 

1017 

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

1019 school_id: Optional[PydanticObjectId] = None 

1020 school: Optional[str] = None 

1021 client_id: Optional[PydanticObjectId] = None 

1022 first_name: str 

1023 middle_name: Optional[str] = None 

1024 last_name: str 

1025 role: str 

1026 status: str 

1027 email: str 

1028 profile_picture: Optional[str] = None 

1029 created_at: Optional[datetime] = None 

1030 updated_at: Optional[datetime] = None 

1031 office_details: Optional[OfficeDetails] = None 

1032 education: Optional[List[Education]] = None 

1033 

1034 

1035# The `Registration` class is a model for creating a student account with required fields such as 

1036# first name, last name, role, school, email, password, and repeat password, along with an optional 

1037# middle name and contact person. 

1038class Registration(BaseModel): 

1039 """ 

1040 Model for student registration data. 

1041 

1042 Handles the initial registration process for new student accounts, 

1043 including validation of required fields and password requirements. 

1044 

1045 Attributes: 

1046 first_name: Student's first name 

1047 middle_name: Optional middle name 

1048 last_name: Student's last name 

1049 role: User role (must be 'student') 

1050 school: Associated school name 

1051 email: Student's email address 

1052 password: Account password 

1053 repeat_password: Password confirmation 

1054 contact_person: Optional emergency contact information 

1055 

1056 Validation: 

1057 - Role must be 'student' 

1058 - Email must be in valid format 

1059 - Passwords must match and meet security requirements 

1060 - Required fields cannot be empty 

1061 

1062 Example: 

1063 ```python 

1064 registration = Registration( 

1065 first_name="Jane", 

1066 last_name="Smith", 

1067 role="student", 

1068 school="Springfield High", 

1069 email="jane.smith@example.com", 

1070 password="SecurePass123!", 

1071 repeat_password="SecurePass123!", 

1072 contact_person=ContactPerson(...) 

1073 ) 

1074 ``` 

1075 """ 

1076 

1077 first_name: str 

1078 middle_name: Optional[str] = None 

1079 last_name: str 

1080 role: str 

1081 school: str 

1082 email: str 

1083 password: str 

1084 repeat_password: str 

1085 contact_person: Optional[ContactPerson] = None 

1086 _no_empty_required_fields = model_validator(mode="before")(is_not_empty) 

1087 _check_password = model_validator(mode="before")(password_must_be_valid) 

1088 

1089 @field_validator("role", mode="before") 

1090 @classmethod 

1091 def check_role(model_class, role_value): 

1092 """ 

1093 Validates user role assignment. 

1094 

1095 Args: 

1096 model_class: The class reference (automatically provided by decorator) 

1097 role_value: The role value to validate 

1098 

1099 Returns: 

1100 str: The validated role value 

1101 

1102 Raises: 

1103 ValueError: If role is missing or invalid 

1104 

1105 Valid Roles: 

1106 - 'student' 

1107 - 'teacher' 

1108 

1109 Example: 

1110 ```python 

1111 role = check_role("student") # Returns "student" 

1112 role = check_role("admin") # Raises ValueError 

1113 role = check_role("") # Raises ValueError 

1114 ``` 

1115 """ 

1116 if role_value: 

1117 if role_value not in ["student", "teacher"]: 

1118 raise ValueError("Role is invalid") 

1119 else: 

1120 raise ValueError("Role is required") 

1121 return role_value 

1122 

1123 @field_validator("email", mode="before") 

1124 @classmethod 

1125 def check_email(model_class, email_value): 

1126 if email_value: 

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

1128 email_pattern = r"^[\w\.-]+@[\w\.-]+\.\w+$" 

1129 if isinstance(email_value, str) and re.fullmatch( 

1130 email_pattern, email_value 

1131 ): 

1132 return email_value 

1133 else: 

1134 raise ValueError("email is invalid") 

1135 else: 

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

1137 

1138 

1139# The UserAccount class represents a user account with an email and role. 

1140class UserAccount(BaseModel): 

1141 """ 

1142 Basic user account information model. 

1143 

1144 Lightweight model used for user account listings and basic identity checks. 

1145 

1146 Attributes: 

1147 email: User's email address for identification 

1148 role: User's role in the system ('student' or 'teacher') 

1149 

1150 Example: 

1151 ```python 

1152 account = UserAccount( 

1153 email="user@example.com", 

1154 role="student" 

1155 ) 

1156 ``` 

1157 """ 

1158 

1159 email: str 

1160 role: str 

1161 

1162 

1163# The UserAccounts class is a subclass of BaseModel and contains a list of UserAccount objects. 

1164class UserAccounts(BaseModel): 

1165 """ 

1166 Collection of user accounts. 

1167 

1168 Used for bulk operations and listing multiple user accounts. 

1169 

1170 Attributes: 

1171 accounts: List of UserAccount objects 

1172 

1173 Example: 

1174 ```python 

1175 accounts = UserAccounts( 

1176 accounts=[ 

1177 UserAccount(email="student1@example.com", role="student"), 

1178 UserAccount(email="teacher1@example.com", role="teacher") 

1179 ] 

1180 ) 

1181 ``` 

1182 """ 

1183 

1184 accounts: List[UserAccount] 

1185 

1186 

1187# The class `UserResponseModel` represents a user response with various attributes such as id, 

1188# subscriber_id, first_name, last_name, role, status, email, created_by, created_at, updated_by, and 

1189# updated_at. 

1190class UserResponseModel(BaseModel): 

1191 """ 

1192 Generic user response model. 

1193 

1194 Attributes: 

1195 id (PydanticObjectId): Unique identifier 

1196 subscriber_id (str): Associated subscriber identifier 

1197 first_name (str): User's first name 

1198 middle_name (str, optional): User's middle name 

1199 last_name (str): User's last name 

1200 role (str): User's role 

1201 status (str): Account status 

1202 email (str): User's email 

1203 created_by (str, optional): Creator's identifier 

1204 created_at (datetime, optional): Creation timestamp 

1205 updated_by (str, optional): Last updater's identifier 

1206 updated_at (datetime, optional): Last update timestamp 

1207 """ 

1208 

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

1210 subscriber_id: str 

1211 first_name: str 

1212 middle_name: Optional[str] = None 

1213 last_name: str 

1214 role: str 

1215 status: str 

1216 email: str 

1217 created_by: Optional[str] = None 

1218 created_at: Optional[datetime] = None 

1219 updated_by: Optional[str] = None 

1220 updated_at: Optional[datetime] = None 

1221 

1222 

1223# The `InitialUserAccountResponseModel` class represents the response model for an initial user 

1224# account, including attributes such as ID, subscriber ID, role, status, email, creation date, and 

1225# update date. 

1226class InitialUserAccountResponseModel(BaseModel): 

1227 """ 

1228 Response model for newly created user accounts. 

1229 

1230 Attributes: 

1231 id (PydanticObjectId): Unique identifier 

1232 subscriber_id (str): Associated subscriber identifier 

1233 role (str): User's role 

1234 status (str): Account status 

1235 email (str): User's email 

1236 created_at (datetime, optional): Creation timestamp 

1237 updated_at (datetime, optional): Last update timestamp 

1238 """ 

1239 

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

1241 subscriber_id: str 

1242 role: str 

1243 status: str 

1244 email: str 

1245 created_at: Optional[datetime] = None 

1246 updated_at: Optional[datetime] = None 

1247 

1248 

1249# The class `UpdatedUserViaSubscriber` represents a user with updated information, including their 

1250# first name, middle name, last name, email, and details about who updated the user and when. 

1251class UpdatedUserViaSubscriber(BaseModel): 

1252 """ 

1253 Model for user updates via subscriber. 

1254 

1255 Attributes: 

1256 first_name (str): Updated first name 

1257 middle_name (str, optional): Updated middle name 

1258 last_name (str): Updated last name 

1259 email (str): Updated email address 

1260 updated_by (str, optional): Updater's identifier 

1261 updated_at (datetime, optional): Update timestamp 

1262 """ 

1263 

1264 first_name: str 

1265 middle_name: Optional[str] = None 

1266 last_name: str 

1267 email: str 

1268 updated_by: Optional[str] = None 

1269 updated_at: Optional[datetime] = None 

1270 

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

1272 @classmethod 

1273 def set_updated_at_now(cls, timestamp_value): 

1274 return timestamp_value or datetime.now(timezone.utc) 

1275 

1276 # The `Config` class contains a JSON schema with an example object. 

1277 class Config: 

1278 json_schema_extra = { 

1279 "example": { 

1280 "first_name": "Peter", 

1281 "middle_name": "John", 

1282 "last_name": "James", 

1283 "email": "peterjohnj@gmail.com", 

1284 } 

1285 } 

1286 

1287 

1288# The `UpdatedRole` class represents a role with optional fields for the user who updated it and the 

1289# timestamp of the update. 

1290class UpdatedRole(BaseModel): 

1291 """ 

1292 Model for role updates. 

1293 

1294 Attributes: 

1295 role (str): New role value 

1296 updated_by (str, optional): Updater's identifier 

1297 updated_at (datetime, optional): Update timestamp 

1298 """ 

1299 

1300 role: str 

1301 updated_by: Optional[str] = None 

1302 updated_at: Optional[datetime] = None 

1303 

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

1305 @classmethod 

1306 def set_updated_at_now(cls, timestamp_value): 

1307 return timestamp_value or datetime.now(timezone.utc) 

1308 

1309 # The `Config` class has a `json_schema_extra` attribute that contains an example JSON schema. 

1310 class Config: 

1311 json_schema_extra = { 

1312 "example": { 

1313 "role": "teacher", 

1314 } 

1315 } 

1316 

1317 

1318# The class `UpdatedStatus` represents an updated status with optional fields for the updater's name 

1319# and the update timestamp. 

1320class UpdatedStatus(BaseModel): 

1321 """ 

1322 Model for status updates. 

1323 

1324 Attributes: 

1325 status (str): New status value 

1326 updated_by (str, optional): Updater's identifier 

1327 updated_at (datetime, optional): Update timestamp 

1328 """ 

1329 

1330 status: str 

1331 updated_by: Optional[str] = None 

1332 updated_at: Optional[datetime] = None 

1333 

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

1335 @classmethod 

1336 def set_updated_at_now(cls, timestamp_value): 

1337 return timestamp_value or datetime.now(timezone.utc) 

1338 

1339 # The class `Config` has a `json_schema_extra` attribute that contains an example JSON schema. 

1340 class Config: 

1341 json_schema_extra = { 

1342 "example": { 

1343 "status": "active", 

1344 } 

1345 }