kamau1's picture
feat: hub tracking and last active project preference
ae9649e
ο»Ώ"""
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}')>"
@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 ""