Spaces:
Sleeping
Sleeping
File size: 6,092 Bytes
74de430 212bf52 67bed24 74de430 c591eda 212bf52 d9b075a 74de430 212bf52 67bed24 212bf52 67bed24 13ca341 67bed24 e36ac68 212bf52 67bed24 c591eda 212bf52 67bed24 212bf52 67bed24 212bf52 dd750b7 212bf52 67bed24 212bf52 67bed24 212bf52 67bed24 212bf52 67bed24 8169ef9 212bf52 d9b075a ae9649e d9b075a 212bf52 67bed24 212bf52 67bed24 340c4b4 67bed24 340c4b4 67bed24 340c4b4 67bed24 340c4b4 212bf52 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | ο»Ώ"""
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 ""
|