from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_serializer, field_validator from typing import Optional, Literal, Any from datetime import datetime, date from ..core.datetime import to_utc_naive, to_zulu_iso from ..models.models import ( UserRole, MembershipStatus, PaymentStatus, PaymentMethod, EspReaderProvisioningStatus, EspReaderType, EspTapAction, RfidWriteJobStatus, ) class UTCBaseModel(BaseModel): @field_validator("*", mode="before", check_fields=False) @classmethod def normalize_datetime_inputs(cls, value: Any) -> Any: if isinstance(value, datetime): return to_utc_naive(value) return value @field_validator("*", mode="after", check_fields=False) @classmethod def normalize_parsed_datetimes(cls, value: Any) -> Any: if isinstance(value, datetime): return to_utc_naive(value) return value @field_serializer("*", when_used="json", check_fields=False) def serialize_datetime_outputs(self, value: Any) -> Any: if isinstance(value, datetime): return to_zulu_iso(value) return value # User Schemas class UserBase(UTCBaseModel): email: EmailStr first_name: str = Field(..., min_length=1, max_length=100) last_name: str = Field(..., min_length=1, max_length=100) phone: Optional[str] = None address: Optional[str] = None class UserCreate(UserBase): password: str = Field(..., min_length=8) class UserUpdate(UTCBaseModel): email: Optional[EmailStr] = None first_name: Optional[str] = Field(None, min_length=1, max_length=100) last_name: Optional[str] = Field(None, min_length=1, max_length=100) phone: Optional[str] = None address: Optional[str] = None role: Optional[UserRole] = None volunteer_level: Optional[str] = Field(None, max_length=50) class UserResponse(UserBase): model_config = ConfigDict(from_attributes=True) id: int role: UserRole volunteer_level: Optional[str] = None is_active: bool created_at: datetime last_login: Optional[datetime] = None class UserInDB(UserResponse): hashed_password: str # Authentication Schemas class Token(UTCBaseModel): access_token: str token_type: str = "bearer" class TokenData(UTCBaseModel): user_id: Optional[int] = None class LoginRequest(UTCBaseModel): email: EmailStr password: str # Password Reset Schemas class ForgotPasswordRequest(UTCBaseModel): email: EmailStr class ResetPasswordRequest(UTCBaseModel): token: str = Field(..., min_length=1) new_password: str = Field(..., min_length=8) class ChangePasswordRequest(UTCBaseModel): current_password: str = Field(..., min_length=1) new_password: str = Field(..., min_length=8) # Membership Tier Schemas class MembershipTierBase(UTCBaseModel): name: str = Field(..., min_length=1, max_length=100) description: Optional[str] = None annual_fee: float = Field(..., ge=0) benefits: Optional[str] = None class MembershipTierCreate(MembershipTierBase): pass class MembershipTierUpdate(UTCBaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) description: Optional[str] = None annual_fee: Optional[float] = Field(None, ge=0) benefits: Optional[str] = None is_active: Optional[bool] = None class MembershipTierResponse(MembershipTierBase): model_config = ConfigDict(from_attributes=True) id: int is_active: bool created_at: datetime # Membership Schemas class MembershipBase(UTCBaseModel): tier_id: int auto_renew: bool = False class MembershipCreate(MembershipBase): start_date: date end_date: date class MembershipUpdate(UTCBaseModel): tier_id: Optional[int] = None status: Optional[MembershipStatus] = None end_date: Optional[date] = None auto_renew: Optional[bool] = None class MembershipResponse(UTCBaseModel): model_config = ConfigDict(from_attributes=True) id: int user_id: int tier_id: int status: MembershipStatus start_date: date end_date: date auto_renew: bool created_at: datetime tier: MembershipTierResponse # Payment Schemas class PaymentBase(UTCBaseModel): amount: float = Field(..., gt=0) payment_method: PaymentMethod notes: Optional[str] = None class PaymentCreate(PaymentBase): membership_id: Optional[int] = None class PaymentUpdate(UTCBaseModel): status: Optional[PaymentStatus] = None transaction_id: Optional[str] = None payment_date: Optional[datetime] = None notes: Optional[str] = None class PaymentResponse(UTCBaseModel): model_config = ConfigDict(from_attributes=True) id: int user_id: int membership_id: Optional[int] = None amount: float payment_method: PaymentMethod status: PaymentStatus transaction_id: Optional[str] = None payment_date: Optional[datetime] = None notes: Optional[str] = None created_at: datetime # Square Payment Schemas class SquarePaymentRequest(UTCBaseModel): """Request schema for Square payment processing""" source_id: str = Field(..., description="Payment source ID from Square Web Payments SDK") tier_id: int = Field(..., description="Membership tier ID to create membership for") amount: float = Field(..., gt=0, description="Payment amount in GBP") idempotency_key: Optional[str] = Field(None, description="Unique key to prevent duplicate payments") note: Optional[str] = Field(None, description="Optional payment note") billing_details: Optional[dict] = Field(None, description="Billing address and cardholder name for AVS") class SquarePaymentResponse(UTCBaseModel): """Response schema for Square payment""" success: bool payment_id: Optional[str] = None status: Optional[str] = None amount: Optional[float] = None currency: Optional[str] = None receipt_url: Optional[str] = None errors: Optional[list[str]] = None database_payment_id: Optional[int] = None membership_id: Optional[int] = Field(None, description="Created membership ID") class SquareRefundRequest(UTCBaseModel): """Request schema for Square payment refund""" payment_id: int = Field(..., description="Database payment ID") amount: Optional[float] = Field(None, gt=0, description="Amount to refund (None for full refund)") reason: Optional[str] = Field(None, description="Reason for refund") # Message Response class MessageResponse(UTCBaseModel): message: str detail: Optional[str] = None # Email Template Schemas class EmailTemplateBase(UTCBaseModel): template_key: str name: str subject: str html_body: str text_body: Optional[str] = None variables: Optional[str] = None class EmailTemplateCreate(EmailTemplateBase): pass class EmailTemplateUpdate(UTCBaseModel): name: Optional[str] = None subject: Optional[str] = None html_body: Optional[str] = None text_body: Optional[str] = None variables: Optional[str] = None is_active: Optional[bool] = None class EmailTemplateResponse(EmailTemplateBase): model_config = ConfigDict(from_attributes=True) id: int is_active: bool created_at: datetime updated_at: datetime # Event Schemas class EventBase(UTCBaseModel): title: str = Field(..., min_length=1, max_length=255) description: Optional[str] = None event_date: datetime event_time: Optional[str] = Field(None, pattern=r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$') location: Optional[str] = None max_attendees: Optional[int] = Field(None, gt=0) class EventCreate(EventBase): pass class EventUpdate(UTCBaseModel): title: Optional[str] = Field(None, min_length=1, max_length=255) description: Optional[str] = None event_date: Optional[datetime] = None event_time: Optional[str] = Field(None, pattern=r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$') location: Optional[str] = None max_attendees: Optional[int] = Field(None, gt=0) status: Optional[str] = None class EventResponse(EventBase): model_config = ConfigDict(from_attributes=True) id: int status: str created_by: int created_at: datetime updated_at: datetime # Event RSVP Schemas class EventRSVPBase(UTCBaseModel): status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$") notes: Optional[str] = None class EventRSVPUpdate(EventRSVPBase): pass class EventRSVPResponse(EventRSVPBase): model_config = ConfigDict(from_attributes=True) id: int event_id: int user_id: int attended: bool created_at: datetime updated_at: datetime # Profile Question Schemas ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"] class QuestionOption(UTCBaseModel): label: str = Field(..., min_length=1, max_length=100) value: str = Field(..., min_length=1, max_length=100) class ProfileQuestionBase(UTCBaseModel): key: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$") label: str = Field(..., min_length=2, max_length=255) help_text: Optional[str] = None input_type: ProfileQuestionInputType placeholder: Optional[str] = Field(None, max_length=255) options: Optional[list[QuestionOption]] = None is_required: bool = False is_active: bool = True admin_only_edit: bool = False display_order: int = 0 depends_on_question_id: Optional[int] = None depends_on_value: Optional[str] = Field(None, max_length=255) class ProfileQuestionCreate(ProfileQuestionBase): pass class ProfileQuestionUpdate(UTCBaseModel): key: Optional[str] = Field(None, min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$") label: Optional[str] = Field(None, min_length=2, max_length=255) help_text: Optional[str] = None input_type: Optional[ProfileQuestionInputType] = None placeholder: Optional[str] = Field(None, max_length=255) options: Optional[list[QuestionOption]] = None is_required: Optional[bool] = None is_active: Optional[bool] = None admin_only_edit: Optional[bool] = None display_order: Optional[int] = None depends_on_question_id: Optional[int] = None depends_on_value: Optional[str] = Field(None, max_length=255) class ProfileQuestionResponse(UTCBaseModel): model_config = ConfigDict(from_attributes=True) id: int key: str label: str help_text: Optional[str] = None input_type: ProfileQuestionInputType placeholder: Optional[str] = None options: list[QuestionOption] = [] is_required: bool is_active: bool admin_only_edit: bool display_order: int depends_on_question_id: Optional[int] = None depends_on_value: Optional[str] = None created_at: datetime updated_at: datetime class ProfileQuestionForUser(ProfileQuestionResponse): answer: Optional[Any] = None can_edit: bool = True class ProfileAnswerUpdate(UTCBaseModel): question_id: int value: Optional[Any] = None class ProfileAnswersUpdateRequest(UTCBaseModel): answers: list[ProfileAnswerUpdate] # ESP RFID Reader Schemas class EspReaderBase(UTCBaseModel): device_id: str = Field(..., min_length=2, max_length=100, pattern=r"^[A-Za-z0-9_.:-]+$") name: str = Field(..., min_length=1, max_length=255) location: Optional[str] = Field(None, max_length=255) reader_type: EspReaderType = EspReaderType.CHECKIN_CHECKOUT notes: Optional[str] = None is_active: bool = True can_write_cards: bool = False firmware_version: Optional[str] = Field(None, max_length=100) class EspReaderCreate(EspReaderBase): api_key: Optional[str] = Field(None, min_length=16, max_length=255) class EspReaderUpdate(UTCBaseModel): name: Optional[str] = Field(None, min_length=1, max_length=255) location: Optional[str] = Field(None, max_length=255) reader_type: Optional[EspReaderType] = None notes: Optional[str] = None is_active: Optional[bool] = None can_write_cards: Optional[bool] = None rotate_api_key: bool = False class EspReaderResponse(EspReaderBase): model_config = ConfigDict(from_attributes=True) id: int provisioning_status: EspReaderProvisioningStatus last_seen_at: Optional[datetime] = None approved_at: Optional[datetime] = None provisioned_at: Optional[datetime] = None created_at: datetime updated_at: datetime class EspReaderCreateResponse(EspReaderResponse): api_key: str class EspReaderRegistrationRequest(UTCBaseModel): device_id: str = Field(..., min_length=2, max_length=100, pattern=r"^[A-Za-z0-9_.:-]+$") name: str = Field(..., min_length=1, max_length=255) location: Optional[str] = Field(None, max_length=255) reader_type: EspReaderType = EspReaderType.CHECKIN_CHECKOUT can_write_cards: bool = False firmware_version: Optional[str] = Field(None, max_length=100) notes: Optional[str] = None class EspReaderRegistrationResponse(UTCBaseModel): device_id: str provisioning_status: EspReaderProvisioningStatus registration_token: str message: str poll_interval_seconds: int = 5 class EspReaderProvisioningResponse(UTCBaseModel): device_id: str provisioning_status: EspReaderProvisioningStatus message: str api_key: Optional[str] = None apiKey: Optional[str] = None poll_interval_seconds: int = 5 class RfidCardBase(UTCBaseModel): uid: str = Field(..., min_length=2, max_length=100) user_id: Optional[int] = None label: Optional[str] = Field(None, max_length=255) is_active: bool = True class RfidCardCreate(RfidCardBase): pass class RfidCardUpdate(UTCBaseModel): user_id: Optional[int] = None label: Optional[str] = Field(None, max_length=255) is_active: Optional[bool] = None class RfidCardResponse(RfidCardBase): model_config = ConfigDict(from_attributes=True) id: int created_at: datetime updated_at: datetime class RfidTapRequest(UTCBaseModel): card_uid: str = Field(..., min_length=2, max_length=100) tapped_at: Optional[datetime] = None reader_type: Optional[EspReaderType] = None class RfidTapResponse(UTCBaseModel): accepted: bool action: EspTapAction message: str server_time_utc: datetime tap_id: int session_id: Optional[int] = None user_id: Optional[int] = None user_name: Optional[str] = None checked_in_at: Optional[datetime] = None checked_out_at: Optional[datetime] = None duration_seconds: Optional[int] = None class RfidWriteJobCreate(UTCBaseModel): reader_id: int user_id: int label: str = Field(..., min_length=1, max_length=255) class RfidWriteJobCompleteRequest(UTCBaseModel): card_uid: Optional[str] = Field(None, min_length=2, max_length=100) success: bool error_message: Optional[str] = Field(None, max_length=500) class RfidWriteJobResponse(UTCBaseModel): model_config = ConfigDict(from_attributes=True) id: int reader_id: int user_id: int card_id: Optional[int] = None label: str status: RfidWriteJobStatus requested_by_user_id: int card_uid: Optional[str] = None write_payload: Optional[str] = None claimed_at: Optional[datetime] = None completed_at: Optional[datetime] = None error_message: Optional[str] = None created_at: datetime updated_at: datetime class EspTimeResponse(UTCBaseModel): server_time_utc: datetime unix_ms: int poll_interval_seconds: int = 3 class EspHeartbeatRequest(UTCBaseModel): mode: str = Field(..., max_length=50) message: Optional[str] = Field(None, max_length=255) wifi_rssi: Optional[int] = None free_heap: Optional[int] = None firmware_version: Optional[str] = Field(None, max_length=100) active_write_job_id: Optional[int] = None class EspHeartbeatResponse(UTCBaseModel): ok: bool server_time_utc: datetime unix_ms: int heartbeat_interval_seconds: int = 10 time_poll_interval_seconds: int = 3 write_job_poll_interval_seconds: int = 3 class EspDashboardLoginResponse(UTCBaseModel): valid: bool user_id: Optional[int] = None role: Optional[UserRole] = None user_name: Optional[str] = None class RfidTapAdminResponse(UTCBaseModel): model_config = ConfigDict(from_attributes=True) id: int reader_id: int card_id: Optional[int] = None user_id: Optional[int] = None card_uid: str action: EspTapAction accepted: bool message: Optional[str] = None tapped_at: datetime created_at: datetime class AttendanceSessionResponse(UTCBaseModel): model_config = ConfigDict(from_attributes=True) id: int user_id: int reader_id: int check_in_tap_id: int check_out_tap_id: Optional[int] = None checked_in_at: datetime checked_out_at: Optional[datetime] = None checkout_source: Optional[str] = None system_flag_reason: Optional[str] = None duration_seconds: Optional[int] = None is_open: bool created_at: datetime updated_at: datetime class StaleSessionCloseRequest(UTCBaseModel): cutoff_date: Optional[date] = None checkout_hour: int = Field(17, ge=0, le=23) class StaleSessionCloseResponse(UTCBaseModel): closed_count: int