Coverage for server / models / account.py: 85%
131 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
1import re
2import string
3from datetime import datetime
4from enum import Enum
5from typing import Optional
6from beanie import Document, Indexed, PydanticObjectId
7from bson import ObjectId
8from pydantic import BaseModel, Field, model_validator, field_validator
9from server.validators.accounts_validator import (
10 validate_fields,
11 validate_update_fields,
12)
13from datetime import timezone
16MIN_PASSWORD_LENGTH: int = 10
17MAX_PASSWORD_LENGTH: int = 25
18PASSWORD_PATTERN: str = (
19 r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{10,25}$"
20)
21EMAIL_PATTERN: str = (
22 r"^([a-zA-Z][a-zA-Z0-9_\.]{7,29})" # Username: 8-30 alphanumeric chars, starts with a letter, allows underscores
23 r"@eruditiontx\.com$" # Domain: exactly "@eruditiontx.com"
24)
25VERIFICATION_CODE_LENGTH: int = 6
26PASSWORD_SPECIAL_CHARS: str = (
27 string.punctuation.replace('"', "").replace("'", "").replace(".", "")
28)
29ALLOWED_EMAIL_CHARS: set = set(
30 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.@_+-"
31)
34# The below class represents an account with various attributes such as first name, last name, role,
35# email, password, and timestamps for creation and update.
36class Account(Document):
37 first_name: str
38 middle_name: Optional[str] = None
39 last_name: str
40 role: str
41 email: str = Indexed(unique=True)
42 profile_picture: Optional[str] = ""
43 orgranization: Optional[str] = ""
44 status: Optional[str] = "active"
45 password: str
46 created_by: Optional[str] = None
47 created_at: Optional[datetime] = None
48 updated_by: Optional[str] = None
49 updated_at: Optional[datetime] = None
50 deleted_by: Optional[str] = None
51 deleted_at: Optional[datetime] = None
53 @field_validator("updated_at", mode="before")
54 @classmethod
55 def set_updated_at_now(cls, v):
56 return v or datetime.now(timezone.utc)
58 @field_validator("created_at", mode="before")
59 @classmethod
60 def set_created_at_now(cls, v):
61 return v or datetime.now(timezone.utc)
63 class Settings:
64 name = "account_collection"
65 indexes = [[("role", 1)]]
68# The `SubscriberAccount` class is a subclass of the `Account` class and includes an additional
69# attribute `school` of type string.
70class SubscriberAccount(Account):
71 school: str
74def validate_email_format(email: str) -> str:
75 """
76 Validates and sanitizes email address format.
78 Args:
79 email (str): The email address to validate
81 Returns:
82 str: The validated and cleaned email address
84 Raises:
85 ValueError: If email is empty, contains illegal characters, or has invalid format
87 Validation Rules:
88 - Must not be empty
89 - Must contain only allowed characters
90 - Domain must use ASCII characters only
91 - Must match standard email format (username@domain.tld)
93 Example:
94 ```python
95 clean_email = validate_email_format("user@example.com") # Returns "user@example.com"
96 clean_email = validate_email_format("invalid@domain") # Raises ValueError
97 ```
98 """
99 if not email or not isinstance(email, str):
100 raise ValueError("email field should not be empty")
102 # Check domain for illegal characters
103 if "@" in email:
104 domain = email.split("@")[1]
105 if any(ord(c) > 127 for c in domain):
106 raise ValueError("Illegal domain: Unicode characters not supported")
108 if not all(c in ALLOWED_EMAIL_CHARS for c in email):
109 raise ValueError("Email contains illegal characters")
111 # Check format
112 if not re.fullmatch(EMAIL_PATTERN, email):
113 raise ValueError("Invalid email format")
115 return email
118# The `Registration` class represents a user registration form with repeat password
119# validation.
120class Registration(BaseModel):
121 first_name: str
122 middle_name: Optional[str] = None
123 last_name: str
124 role: str
125 email: str
126 password: str
127 profile_picture: Optional[str] = ""
128 organization: Optional[str] = ""
129 status: Optional[str] = "active"
130 created_by: Optional[str] = None
131 created_at: Optional[datetime] = None
132 updated_by: Optional[str] = None
133 updated_at: Optional[datetime] = None
134 deleted_by: Optional[str] = None
135 deleted_at: Optional[datetime] = None
136 repeat_password: str
137 _validate_fields = model_validator(mode="before")(validate_fields)
139 @field_validator("updated_at", mode="before")
140 @classmethod
141 def set_updated_at_now(cls, v):
142 return v or datetime.now(timezone.utc)
144 @field_validator("created_at", mode="before")
145 @classmethod
146 def set_created_at_now(cls, v):
147 return v or datetime.now(timezone.utc)
150class SubscriberRegistration(Registration):
151 school: str
153 # The `Config` class contains a JSON schema with an example of a user's information.
154 class Config:
155 json_schema_extra = {
156 "example": {
157 "first_name": "John",
158 "middle_name": "David",
159 "last_name": "Doe",
160 "role": "staff",
161 "school": "this is optional (subscriber only)",
162 "email": "validemail@gmail.com",
163 "password": "HeyyWorld123!",
164 "repeat_password": "HeyyWorld123!",
165 }
166 }
169# The `UpdatedAccount` class is a model that represents an updated account with fields such as first
170# name, middle name, last name, role, email, and information about the update.
171class UpdatedAccount(BaseModel):
172 first_name: str
173 middle_name: Optional[str] = None
174 last_name: str
175 role: str
176 email: str
177 profile_picture: Optional[str] = None
178 organization: Optional[str] = None
179 updated_by: Optional[str] = None
180 updated_at: Optional[datetime] = None
181 deleted_by: Optional[str] = None
182 deleted_at: Optional[datetime] = None
183 _validate_fields = model_validator(mode="before")(validate_update_fields)
185 @field_validator("updated_at", mode="before")
186 @classmethod
187 def set_updated_at_now(cls, v):
188 return v or datetime.now(timezone.utc)
191# The class `UpdatedSubscriberAccount` is a subclass of `UpdatedAccount` that includes an additional
192# attribute `school` of type `str`.
193class UpdatedSubscriberAccount(UpdatedAccount):
194 school: str
197class AccountResponseModel(BaseModel):
198 """
199 Response model for account data representation in the API.
200 """
202 id: str = Field(alias="_id") # Convert ObjectId to string
203 first_name: str
204 middle_name: Optional[str] = None
205 last_name: str
206 role: str
207 email: str
208 status: Optional[str] = None
209 organization: Optional[str] = None
210 profile_picture: Optional[str] = None
211 created_by: Optional[str] = None
212 created_at: Optional[datetime] = None
213 updated_by: Optional[str] = None
214 updated_at: Optional[datetime] = None
215 deleted_by: Optional[str] = None
216 deleted_at: Optional[datetime] = None
218 @field_validator("id", "created_by", "updated_by", "deleted_by", mode="before")
219 @classmethod
220 def convert_object_id(cls, v):
221 """Convert MongoDB ObjectId fields to string."""
222 if isinstance(v, PydanticObjectId) or isinstance(v, ObjectId):
223 return str(v)
224 return v
227# The class `SubscriberAccountResponseModel` is a subclass of `AccountResponseModel` and includes an
228# additional attribute `school` of type `str`.
229class SubscriberAccountResponseModel(AccountResponseModel):
230 school: str