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
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""
2User Models Module
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.
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
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
21"""
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
33def is_not_empty(cls, values):
34 """
35 Validates that required fields in a model are not empty strings or whitespace.
37 Args:
38 cls: The class reference (automatically provided by validator decorator)
39 values: Dictionary of field names and their values to validate
41 Returns:
42 dict: The validated values dictionary if all checks pass
44 Raises:
45 ValueError: If any required field is empty or contains only whitespace
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
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
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 ```
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 }
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
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 )
103 # Return validated values if all checks pass
104 return values
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.
113 This model serves as the foundation for all user types in the system,
114 containing common fields and validation logic.
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
134 Database Configuration:
135 - Collection name: 'user_collection'
136 - Indexes:
137 - Role (ascending)
138 - Email (unique)
140 Validation:
141 - Email must be unique and valid format
142 - Timestamps automatically set in UTC
143 - Optional fields properly handle null values
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
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 """
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
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 )
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
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
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
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.
257 Args:
258 cls: The class reference (automatically provided by decorator)
259 current_timestamp: The timestamp value to validate/update
261 Returns:
262 datetime: Either the provided timestamp or current UTC timestamp
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)
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.
278 Args:
279 cls: The class reference (automatically provided by decorator)
280 current_timestamp: The timestamp value to validate/update
282 Returns:
283 datetime: Either the provided timestamp or current UTC timestamp
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)
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.
299 This validator ensures that email addresses conform to standard format
300 requirements and security best practices for user authentication.
302 Args:
303 model_class: Class reference (provided by field_validator decorator)
304 email_value: Email address to validate
306 Returns:
307 str: Validated email address if all checks pass
309 Raises:
310 HTTPException:
311 - 401 UNAUTHORIZED: If email is empty
312 - 401 UNAUTHORIZED: If email format is invalid
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
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"
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
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 )
361 # The class "Settings" defines the name of a collection and its indexes.
362 class Settings:
363 name = "user_collection"
364 indexes = [[("role", 1)]]
367class UserBio(BaseModel):
368 """
369 Model for representing user's basic biographical information.
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 """
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.")
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.
389 Args:
390 cls: The class reference (automatically provided by decorator)
391 value: The first name to validate
393 Returns:
394 str: The validated and cleaned first name
396 Raises:
397 ValueError: If first name is empty, non-string, or contains only whitespace
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")
409 cleaned = value.strip()
410 if not cleaned:
411 raise ValueError("First name cannot be empty or whitespace")
413 return cleaned
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.
421 Args:
422 cls: The class reference (automatically provided by decorator)
423 value: The middle name to validate
425 Returns:
426 Optional[str]: The validated and cleaned middle name or None if empty
428 Raises:
429 ValueError: If middle name contains only whitespace
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
441 if not isinstance(value, str):
442 raise ValueError("Middle name must be a string")
444 cleaned = value.strip()
445 if not cleaned:
446 raise ValueError("Middle name cannot be whitespace only")
448 return cleaned
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.
458 Args:
459 cls: The class reference (automatically provided by decorator)
460 value: The last name to validate
462 Returns:
463 str: The validated and cleaned last name
465 Raises:
466 ValueError: If last name is empty, non-string, or contains only whitespace
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")
478 cleaned = value.strip()
479 if not cleaned:
480 raise ValueError("Last name cannot be empty or whitespace")
482 return cleaned
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.
491 Used to store details of a person who can be contacted in case of emergencies
492 or important notifications regarding a student.
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
506 Validation:
507 - Required fields cannot be empty
508 - Phone number must include country code
509 - Address fields follow standardized format
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
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 """
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
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
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
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
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()
656class OfficeDetails(BaseModel):
657 """
658 Schema for teacher's office information.
660 Contains details about a teacher's physical office location and availability.
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
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)
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
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 """
688 location: str
689 conference_time: Optional[str] = None
690 phone_number: str
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.
700 Represents a single educational qualification or period of study.
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
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
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
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 """
733 school: str
734 degree: Optional[str] = None
735 area_of_study: Optional[str] = None
736 year_started: str
737 year_ended: str
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.
745 Attributes:
746 education (List[Education]): List of education records
747 """
749 education: List[Education]
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.
758 Represents a student account with additional fields specific to students,
759 including emergency contact information.
761 Attributes:
762 contact_person: Optional emergency contact details including relationship
763 and contact information
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
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
777 Database Notes:
778 - Stored in the same collection as User
779 - Automatically indexed by role="student"
780 - Supports efficient querying by school_id
782 Security Features:
783 - Password validation and hashing
784 - Role-based access control
785 - Activity tracking
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 """
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)
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.
820 Args:
821 cls: The class reference (automatically provided by decorator)
822 field_value: The field value to process
824 Returns:
825 Optional[str]: The processed field value, None if empty string
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
846class StudentResponseModel(BaseModel):
847 """
848 Response model for student data returned by the API.
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 """
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
882class Teacher(User):
883 """
884 Teacher user model extending the base User class with teacher-specific attributes.
886 Attributes:
887 office_details (OfficeDetails, optional): Teacher's office information
888 education (List[Education], optional): List of teacher's educational background
889 """
891 # Optional fields specific to teachers
892 # OfficeDetails contains location, conference time, and phone number
893 office_details: Optional[OfficeDetails] = None
895 # List of educational qualifications/background
896 # Each Education object contains school, degree, area of study, and years
897 education: Optional[List[Education]] = None
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)
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)
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.
913 This validator processes all fields in the Teacher model, handling string
914 sanitization and empty value standardization.
916 Args:
917 cls: The class reference (automatically provided by decorator)
918 input_value: The value to validate/sanitize (any type)
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
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
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 ```
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.)
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()
952 # Convert empty strings to None for consistent null handling
953 if cleaned_value == "":
954 return None
956 return cleaned_value
958 # Return the original value if not a string
959 return input_value
961 except Exception as validation_error:
962 # Pass through any validation errors for proper error handling
963 raise validation_error
966class TeacherResponseModel(BaseModel):
967 """
968 API response model for teacher data.
970 Defines the structure of teacher data returned by API endpoints,
971 excluding sensitive information like passwords.
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
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
995 Usage Notes:
996 - Used for API responses only
997 - Not for data modification
998 - Supports JSON serialization
999 - Handles nested document expansion
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 """
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
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.
1042 Handles the initial registration process for new student accounts,
1043 including validation of required fields and password requirements.
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
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
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 """
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)
1089 @field_validator("role", mode="before")
1090 @classmethod
1091 def check_role(model_class, role_value):
1092 """
1093 Validates user role assignment.
1095 Args:
1096 model_class: The class reference (automatically provided by decorator)
1097 role_value: The role value to validate
1099 Returns:
1100 str: The validated role value
1102 Raises:
1103 ValueError: If role is missing or invalid
1105 Valid Roles:
1106 - 'student'
1107 - 'teacher'
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
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")
1139# The UserAccount class represents a user account with an email and role.
1140class UserAccount(BaseModel):
1141 """
1142 Basic user account information model.
1144 Lightweight model used for user account listings and basic identity checks.
1146 Attributes:
1147 email: User's email address for identification
1148 role: User's role in the system ('student' or 'teacher')
1150 Example:
1151 ```python
1152 account = UserAccount(
1153 email="user@example.com",
1154 role="student"
1155 )
1156 ```
1157 """
1159 email: str
1160 role: str
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.
1168 Used for bulk operations and listing multiple user accounts.
1170 Attributes:
1171 accounts: List of UserAccount objects
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 """
1184 accounts: List[UserAccount]
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.
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 """
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
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.
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 """
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
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.
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 """
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
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)
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 }
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.
1294 Attributes:
1295 role (str): New role value
1296 updated_by (str, optional): Updater's identifier
1297 updated_at (datetime, optional): Update timestamp
1298 """
1300 role: str
1301 updated_by: Optional[str] = None
1302 updated_at: Optional[datetime] = None
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)
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 }
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.
1324 Attributes:
1325 status (str): New status value
1326 updated_by (str, optional): Updater's identifier
1327 updated_at (datetime, optional): Update timestamp
1328 """
1330 status: str
1331 updated_by: Optional[str] = None
1332 updated_at: Optional[datetime] = None
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)
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 }