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

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 

14 

15 

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) 

32 

33 

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 

52 

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) 

57 

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) 

62 

63 class Settings: 

64 name = "account_collection" 

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

66 

67 

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 

72 

73 

74def validate_email_format(email: str) -> str: 

75 """ 

76 Validates and sanitizes email address format. 

77 

78 Args: 

79 email (str): The email address to validate 

80 

81 Returns: 

82 str: The validated and cleaned email address 

83 

84 Raises: 

85 ValueError: If email is empty, contains illegal characters, or has invalid format 

86 

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) 

92 

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") 

101 

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") 

107 

108 if not all(c in ALLOWED_EMAIL_CHARS for c in email): 

109 raise ValueError("Email contains illegal characters") 

110 

111 # Check format 

112 if not re.fullmatch(EMAIL_PATTERN, email): 

113 raise ValueError("Invalid email format") 

114 

115 return email 

116 

117 

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) 

138 

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) 

143 

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) 

148 

149 

150class SubscriberRegistration(Registration): 

151 school: str 

152 

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 } 

167 

168 

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) 

184 

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) 

189 

190 

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 

195 

196 

197class AccountResponseModel(BaseModel): 

198 """ 

199 Response model for account data representation in the API. 

200 """ 

201 

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 

217 

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 

225 

226 

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