""" User Model - Maps to existing Supabase 'users' table Matches schema.sql exactly - field service management platform """ from sqlalchemy import Column, String, Boolean, Text, DateTime, Float, ForeignKey from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.orm import relationship from app.models.base import BaseModel class User(BaseModel): """ User model - Maps EXACTLY to users table in docs/schema/schema.sql Users are employees of either: - Clients (telecom operators who need field work done) - Contractors (companies that execute field work) - Platform admins (no org link) Authentication: Supabase Auth (auth.users table) This table stores business profile and authorization data USER LIFECYCLE STATE MACHINE: ┌─────────────────────────────────────────────────────────────────┐ │ Status Flow: │ │ │ │ invited → pending_setup → active → suspended │ │ ↓ ↓ ↓ ↓ │ │ (invited) (setup req) (operational) (disabled) │ └─────────────────────────────────────────────────────────────────┘ Status Definitions: - invited: User invited but hasn't accepted/registered yet (Used in invitation flow only, before user_invitations.accepted_at) - pending_setup: User accepted invitation but profile incomplete (Reserved for future: require certain fields before full access) - active: Fully operational user with complete access (Default after invitation acceptance, primary operational state) - suspended: Account temporarily disabled (is_active=False) (Can be reactivated by admin, preserves all data) Note: is_active field controls actual access: - active/pending_setup → is_active=True (can login) - suspended → is_active=False (blocked at API level) - invited → varies (typically False until acceptance) """ __tablename__ = "users" # Organization Links - User belongs to ONE organization (or is platform_admin) client_id = Column(UUID(as_uuid=True), ForeignKey('clients.id', ondelete='SET NULL'), nullable=True) contractor_id = Column(UUID(as_uuid=True), ForeignKey('contractors.id', ondelete='SET NULL'), nullable=True) # Role & Status role = Column(String(50), nullable=False) # app_role ENUM in DB status = Column(String(50), default='invited') # user_status ENUM in DB # Profile & Identity name = Column(String(255), nullable=False) # Full name (NOT first_name/last_name) phone = Column(String(50), nullable=True) phone_alternate = Column(String(50), nullable=True) email = Column(String(255), nullable=True) id_number = Column(String(255), nullable=True) display_name = Column(String(255), nullable=True) is_active = Column(Boolean, default=True) # Note: invited_at and activated_at removed - tracked in user_invitations table # See migration: 14_cleanup_users_table.sql # Health & Safety (for field agents) health_info = Column(JSONB, default={}) emergency_contact_name = Column(String(255), nullable=True) emergency_contact_phone = Column(String(50), nullable=True) ppe_sizes = Column(JSONB, default={}) # Current Location (for field agents - updated via mobile app) current_location_name = Column(String(255), nullable=True) current_country = Column(String(100), default='Kenya') current_region = Column(String(255), nullable=True) current_city = Column(String(255), nullable=True) current_address_line1 = Column(String(500), nullable=True) current_address_line2 = Column(String(500), nullable=True) current_maps_link = Column(String(1000), nullable=True) current_latitude = Column(Float, nullable=True) # DOUBLE PRECISION in DB current_longitude = Column(Float, nullable=True) # DOUBLE PRECISION in DB current_location_updated_at = Column(DateTime(timezone=True), nullable=True) # Additional Metadata additional_metadata = Column(JSONB, default={}) # Relationships - Using backref for most, explicit back_populates where needed # Note: Most relationships use backref in the referring model (one-way is sufficient) # Only add back_populates when bidirectional traversal is explicitly needed # Project team memberships (bidirectional) project_memberships = relationship("ProjectTeam", foreign_keys="[ProjectTeam.user_id]", lazy="dynamic") # User preferences (one-to-one) preferences = relationship("UserPreference", uselist=False, backref="user") # Sales orders where this user is the agent (explicit back_populates) sales_orders_as_agent = relationship("SalesOrder", foreign_keys="[SalesOrder.sales_agent_id]", back_populates="sales_agent", lazy="dynamic") def __repr__(self): return f"" @property def full_name(self) -> str: """Get user's full name""" return self.display_name or self.name or self.email or "Unknown" @property def first_name(self) -> str: """Extract first name from full name (computed property)""" if self.name: parts = self.name.split() return parts[0] if parts else self.name return "" @property def last_name(self) -> str: """Extract last name from full name (computed property)""" if self.name: parts = self.name.split() return " ".join(parts[1:]) if len(parts) > 1 else "" return ""