NeighbourAid / app /models /user.py
Parth Kansal
commit
49e9f9d
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)
@field_validator("coordinates")
@classmethod
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
@field_validator("type")
@classmethod
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
@field_validator("name")
@classmethod
def strip_name(cls, v: str) -> str:
return v.strip()
@field_validator("phone")
@classmethod
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)
@field_validator("name")
@classmethod
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
@field_validator("password")
@classmethod
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