Spaces:
Sleeping
Sleeping
| ο»Ώ""" | |
| 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"<User(email='{self.email}', name='{self.name}', role='{self.role}')>" | |
| def full_name(self) -> str: | |
| """Get user's full name""" | |
| return self.display_name or self.name or self.email or "Unknown" | |
| 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 "" | |
| 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 "" | |