"""This module declared the pydantic schema representation for users. Note that it is not a direct representation of how users are modeled in the database. Instead, the User schema class contains all attributes from all user classes as optional attributes. I haven't figured out a smart way to do this with pydantic yet, so behold the inheritance hellhole below. """ from datetime import datetime, date from abc import ABC from typing import Optional from pydantic import BaseModel, validator from backend.models.users import Gender class AbstractUserInfoValidation(BaseModel, ABC): """Base class providing common field validators.""" @validator('email', check_fields=False) def assert_email_is_valid(cls, email): if email is not None: if not len(email): raise ValueError("Email must not be empty.") # TODO implement more robust check return email @validator('first_name', check_fields=False) def assert_first_name_is_valid(cls, first_name): if first_name is not None: if not len(first_name): raise ValueError("First Name must not be empty.") return first_name @validator('last_name', check_fields=False) def assert_last_name_is_valid(cls, last_name): if last_name is not None: if not len(last_name): raise ValueError("Last Name must not be empty.") return last_name @validator('date_of_birth', check_fields=False) def assert_dob_is_valid(cls, dob): if dob is not None: if dob >= date.today(): raise ValueError("Date of birth cannot be in the future.") return dob class AbstractUser(AbstractUserInfoValidation, ABC): """Base class for attributes common to user creation and user representation. A user must be either a patient or an administrator. If a user is a patient, they must specify valid 'date_of_birth' and 'gender' attributes. """ email: str first_name: str last_name: str gender: Optional[Gender] date_of_birth: Optional[date] is_patient: Optional[bool] is_admin: Optional[bool] @validator('is_admin') def assert_tegridy(cls, is_admin, values): """Ensures logical model integrity when optional fields are set.""" if values['is_patient']: if is_admin: raise ValueError('User cannot be both patient and admin.') for key in ['gender', 'date_of_birth']: if key not in values or values[key] is None: raise ValueError(f"Must specify key '{key}' for patients.") if not values['is_patient'] and not is_admin: raise ValueError(f'User must either be patient or admin.') return is_admin class UserCreate(AbstractUser): """Scheme for user creation.""" password: str password_confirmation: str @validator('password_confirmation') def assert_passwords_match(cls, password_confirmation, values): if not password_confirmation == values['password']: raise ValueError("Passwords do not match.") if len(password_confirmation) < 1: # TODO use more robust password rules raise ValueError("Password must not be empty.") return password_confirmation class UserUpdate(AbstractUserInfoValidation): """Scheme for user updates. All fields here are optional, but passwords must match if at least one was provided. Note that even administrator updates can specify 'gender' and 'date_of_birth' fields, the function inserting the update into the db should handle this (and just ignore the fields). Switching user types is prohibited. """ email: Optional[str] first_name: Optional[str] last_name: Optional[str] gender: Optional[Gender] date_of_birth: Optional[date] password: Optional[str] = None password_confirmation: Optional[str] = None @validator('password_confirmation') def assert_passwords_match_or_are_both_none(cls, password_confirmation, values): password = values.get('password') if None not in [password, password_confirmation]: if not password == password_confirmation: raise ValueError("Passwords do not match.") if len(password_confirmation) < 1: # TODO use more robust password rules raise ValueError("Password must not be empty.") return password_confirmation class User(AbstractUser): """Final representation of all types of users, wrapped into one User schema. The id, created and updated fields are filled by the db during creation, so they are not needed in the parent classes. """ id: int created: datetime updated: datetime class Config: orm_mode = True