Spaces:
Sleeping
Sleeping
| from datetime import datetime | |
| from enum import Enum | |
| from typing import List, Optional | |
| from pydantic import BaseModel, EmailStr, Field, field_validator | |
| from .alert import VolunteerSkill # shared enum | |
| class UserRole(str, Enum): | |
| reporter = "reporter" | |
| volunteer = "volunteer" | |
| class GeoPoint(BaseModel): | |
| type: str = Field(default="Point") | |
| coordinates: List[float] = Field(min_length=2, max_length=2) | |
| def validate_lng_lat(cls, v: list[float]) -> list[float]: | |
| lng, lat = v | |
| if not (-180 <= lng <= 180): | |
| raise ValueError("longitude must be between -180 and 180") | |
| if not (-90 <= lat <= 90): | |
| raise ValueError("latitude must be between -90 and 90") | |
| return v | |
| def force_point(cls, v: str) -> str: | |
| if v != "Point": | |
| raise ValueError("GeoPoint.type must be 'Point'") | |
| return v | |
| class EmergencyContact(BaseModel): | |
| """A trusted contact pinged client-side on SOS or "need help" check-ins. | |
| Kept minimal — the backend never sends SMS/email itself; the client | |
| opens the relevant tel:/sms:/mailto: link on tap.""" | |
| name: str = Field(min_length=1, max_length=80) | |
| phone: Optional[str] = Field(default=None, max_length=32) | |
| email: Optional[EmailStr] = None | |
| def strip_name(cls, v: str) -> str: | |
| return v.strip() | |
| def strip_phone(cls, v: Optional[str]) -> Optional[str]: | |
| if v is None: | |
| return v | |
| v = v.strip() | |
| return v or None | |
| class UserCreate(BaseModel): | |
| name: str = Field(min_length=2, max_length=80) | |
| email: EmailStr | |
| # Strengthened from 6 chars to 8 with at-least-one-letter, | |
| # at-least-one-digit. Doesn't force special chars (which the OWASP | |
| # 2024 guidance now considers an anti-pattern that drives users | |
| # toward `Password!1` clones), but blocks the lowest-effort | |
| # passwords like "123456" or "password". | |
| password: str = Field(min_length=8, max_length=128) | |
| role: UserRole | |
| location: GeoPoint | |
| # Volunteer-only optional fields. Ignored for reporters. | |
| skills: List[VolunteerSkill] = Field(default_factory=list) | |
| has_vehicle: bool = False | |
| emergency_contacts: List[EmergencyContact] = Field(default_factory=list, max_length=5) | |
| def strip_name(cls, v: str) -> str: | |
| stripped = v.strip() | |
| if len(stripped) < 2: | |
| raise ValueError("name must be at least 2 non-space characters") | |
| return stripped | |
| def password_complexity(cls, v: str) -> str: | |
| # Require at least one letter and one digit. Avoids the worst | |
| # passwords without the security-theatre of mandatory specials. | |
| if not any(ch.isalpha() for ch in v) or not any(ch.isdigit() for ch in v): | |
| raise ValueError( | |
| "password must contain at least one letter and one digit" | |
| ) | |
| return v | |
| class UserLogin(BaseModel): | |
| email: EmailStr | |
| password: str = Field(min_length=1, max_length=128) | |
| class LocationUpdate(BaseModel): | |
| location: GeoPoint | |
| class ProfileUpdate(BaseModel): | |
| """Partial update for fields a user can change post-registration. Any | |
| None field is left untouched.""" | |
| skills: Optional[List[VolunteerSkill]] = None | |
| has_vehicle: Optional[bool] = None | |
| emergency_contacts: Optional[List[EmergencyContact]] = Field(default=None, max_length=5) | |
| class UserOut(BaseModel): | |
| id: str | |
| name: str | |
| email: str | |
| role: UserRole | |
| location: GeoPoint | |
| skills: List[VolunteerSkill] = Field(default_factory=list) | |
| has_vehicle: bool = False | |
| emergency_contacts: List[EmergencyContact] = Field(default_factory=list) | |
| created_at: datetime | |