kamau1 commited on
Commit
b06a903
·
1 Parent(s): fde895f

feat: user preferences support

Browse files
src/app/api/v1/auth.py CHANGED
@@ -13,6 +13,11 @@ from app.schemas.user import (
13
  UserCreate, UserResponse, UserUpdate, UserProfile,
14
  AdminOTPRequest, AdminRegistrationRequest
15
  )
 
 
 
 
 
16
  from app.models.user import User
17
  from app.core.supabase_auth import supabase_auth
18
  from app.services.audit_service import AuditService
@@ -794,3 +799,158 @@ async def logout(
794
  logger.info(f"User logged out: {current_user.email}")
795
 
796
  return MessageResponse(message="Logged out successfully")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  UserCreate, UserResponse, UserUpdate, UserProfile,
14
  AdminOTPRequest, AdminRegistrationRequest
15
  )
16
+ from app.schemas.user_preferences import (
17
+ UserPreferencesUpdate, UserPreferencesResponse,
18
+ DEFAULT_FAVORITE_APPS, AVAILABLE_APPS, DEFAULT_DASHBOARD_WIDGETS
19
+ )
20
+ from app.models.user_preference import UserPreference
21
  from app.models.user import User
22
  from app.core.supabase_auth import supabase_auth
23
  from app.services.audit_service import AuditService
 
799
  logger.info(f"User logged out: {current_user.email}")
800
 
801
  return MessageResponse(message="Logged out successfully")
802
+
803
+
804
+ # ============================================
805
+ # USER PREFERENCES ENDPOINTS
806
+ # ============================================
807
+
808
+ @router.get("/me/preferences", response_model=UserPreferencesResponse)
809
+ async def get_my_preferences(
810
+ current_user: User = Depends(get_current_active_user),
811
+ db: Session = Depends(get_db)
812
+ ):
813
+ """
814
+ Get current user's preferences from user_preferences table
815
+
816
+ Returns user preferences with role-based defaults if preferences don't exist yet.
817
+ Automatically creates preferences record if it doesn't exist (via database trigger).
818
+ """
819
+ # Get or create preferences
820
+ preferences = db.query(UserPreference).filter(
821
+ UserPreference.user_id == current_user.id,
822
+ UserPreference.deleted_at == None
823
+ ).first()
824
+
825
+ # If no preferences exist, create with role-based defaults
826
+ if not preferences:
827
+ preferences = UserPreference(
828
+ user_id=current_user.id,
829
+ favorite_apps=DEFAULT_FAVORITE_APPS.get(current_user.role, ['dashboard', 'tickets', 'projects', 'maps']),
830
+ dashboard_widgets=DEFAULT_DASHBOARD_WIDGETS.get(current_user.role, ['recent_tickets', 'team_performance', 'sla_metrics']),
831
+ theme='light',
832
+ language='en'
833
+ )
834
+ db.add(preferences)
835
+ db.commit()
836
+ db.refresh(preferences)
837
+ logger.info(f"Created default preferences for user: {current_user.email}")
838
+
839
+ return UserPreferencesResponse.from_orm(preferences)
840
+
841
+
842
+ @router.put("/me/preferences", response_model=UserPreferencesResponse)
843
+ async def update_my_preferences(
844
+ preferences_data: UserPreferencesUpdate,
845
+ request: Request,
846
+ current_user: User = Depends(get_current_active_user),
847
+ db: Session = Depends(get_db)
848
+ ):
849
+ """
850
+ Update current user's preferences in user_preferences table
851
+
852
+ Updates user preferences including favorite apps, theme, language, and notification settings.
853
+ Favorite apps are validated against role-specific available apps (max 6).
854
+ """
855
+ # Get or create preferences
856
+ preferences = db.query(UserPreference).filter(
857
+ UserPreference.user_id == current_user.id,
858
+ UserPreference.deleted_at == None
859
+ ).first()
860
+
861
+ if not preferences:
862
+ # Create new preferences record
863
+ preferences = UserPreference(
864
+ user_id=current_user.id,
865
+ favorite_apps=DEFAULT_FAVORITE_APPS.get(current_user.role, []),
866
+ dashboard_widgets=DEFAULT_DASHBOARD_WIDGETS.get(current_user.role, [])
867
+ )
868
+ db.add(preferences)
869
+ db.flush()
870
+
871
+ # Track changes for audit
872
+ changes = {'old': {}, 'new': {}}
873
+
874
+ # Update only provided fields
875
+ update_data = preferences_data.dict(exclude_unset=True)
876
+
877
+ # Validate favorite apps if provided
878
+ if 'favorite_apps' in update_data and update_data['favorite_apps'] is not None:
879
+ # Check max limit (6 apps)
880
+ if len(update_data['favorite_apps']) > 6:
881
+ raise HTTPException(
882
+ status_code=status.HTTP_400_BAD_REQUEST,
883
+ detail="Maximum 6 favorite apps allowed"
884
+ )
885
+
886
+ # Validate against role-specific available apps
887
+ available_apps = AVAILABLE_APPS.get(current_user.role, [])
888
+ invalid_apps = [app for app in update_data['favorite_apps'] if app not in available_apps]
889
+
890
+ if invalid_apps:
891
+ raise HTTPException(
892
+ status_code=status.HTTP_400_BAD_REQUEST,
893
+ detail=f"Invalid apps for {current_user.role}: {', '.join(invalid_apps)}. "
894
+ f"Available apps: {', '.join(available_apps)}"
895
+ )
896
+
897
+ changes['old']['favorite_apps'] = preferences.favorite_apps
898
+ changes['new']['favorite_apps'] = update_data['favorite_apps']
899
+ preferences.favorite_apps = update_data['favorite_apps']
900
+
901
+ # Update other fields
902
+ for field, value in update_data.items():
903
+ if field != 'favorite_apps' and value is not None:
904
+ old_value = getattr(preferences, field, None)
905
+ if old_value != value:
906
+ changes['old'][field] = old_value
907
+ changes['new'][field] = value
908
+ setattr(preferences, field, value)
909
+
910
+ db.commit()
911
+ db.refresh(preferences)
912
+
913
+ # Audit log
914
+ if changes['old']:
915
+ AuditService.log_action(
916
+ db=db,
917
+ action='update',
918
+ entity_type='user_preferences',
919
+ entity_id=str(preferences.id),
920
+ description=f"User updated preferences: {current_user.email}",
921
+ user=current_user,
922
+ request=request,
923
+ changes=changes
924
+ )
925
+
926
+ logger.info(f"Preferences updated for user: {current_user.email}")
927
+
928
+ return UserPreferencesResponse.from_orm(preferences)
929
+
930
+
931
+ @router.get("/me/preferences/available-apps", response_model=dict)
932
+ async def get_available_apps(
933
+ current_user: User = Depends(get_current_active_user),
934
+ db: Session = Depends(get_db)
935
+ ):
936
+ """
937
+ Get list of apps available for user to favorite based on their role
938
+
939
+ Returns currently favorited apps, all available apps for the role,
940
+ and role-based default favorites.
941
+ """
942
+ # Get current preferences
943
+ preferences = db.query(UserPreference).filter(
944
+ UserPreference.user_id == current_user.id,
945
+ UserPreference.deleted_at == None
946
+ ).first()
947
+
948
+ current_favorites = preferences.favorite_apps if preferences else DEFAULT_FAVORITE_APPS.get(current_user.role, [])
949
+
950
+ return {
951
+ "role": current_user.role,
952
+ "current_favorites": current_favorites,
953
+ "available_apps": AVAILABLE_APPS.get(current_user.role, []),
954
+ "default_favorites": DEFAULT_FAVORITE_APPS.get(current_user.role, []),
955
+ "max_favorites": 6
956
+ }
src/app/models/user_preference.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User Preferences Model
3
+ Stores user-specific UI preferences, favorites, theme, and settings
4
+ """
5
+ from sqlalchemy import Column, String, Boolean, Integer, Float, ForeignKey, ARRAY, Text
6
+ from sqlalchemy.dialects.postgresql import UUID, JSONB
7
+ from app.models.base import BaseModel
8
+
9
+
10
+ class UserPreference(BaseModel):
11
+ """
12
+ User Preferences model - One row per user
13
+ Stores UI preferences, favorites, theme, and settings
14
+ """
15
+ __tablename__ = "user_preferences"
16
+
17
+ # Link to user
18
+ user_id = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='CASCADE'), nullable=False, unique=True)
19
+
20
+ # Favorite Apps (for 9-dot app launcher)
21
+ # Array of app codes that appear in the top navigation bar
22
+ # Max: 6 favorites (enforced in application logic)
23
+ favorite_apps = Column(ARRAY(Text), default=['dashboard', 'tickets', 'projects', 'maps'])
24
+
25
+ # UI Preferences
26
+ theme = Column(String(50), default='light') # 'light', 'dark', 'auto'
27
+ language = Column(String(10), default='en') # 'en', 'sw' (Swahili), 'fr', etc.
28
+
29
+ # Notification Preferences
30
+ email_notifications = Column(Boolean, default=True)
31
+ push_notifications = Column(Boolean, default=True)
32
+ sms_notifications = Column(Boolean, default=False)
33
+
34
+ # Dashboard Layout (which widgets to show)
35
+ # Array of widget codes: ['recent_tickets', 'team_performance', 'sla_metrics', 'map_view']
36
+ dashboard_widgets = Column(ARRAY(Text), default=['recent_tickets', 'team_performance', 'sla_metrics'])
37
+
38
+ # Table/List Preferences
39
+ default_tickets_view = Column(String(50), default='list') # 'list', 'kanban', 'calendar'
40
+ tickets_per_page = Column(Integer, default=25) # Pagination size
41
+ default_sort_field = Column(String(100), default='created_at') # Default sort field
42
+ default_sort_order = Column(String(10), default='desc') # 'asc', 'desc'
43
+
44
+ # Map Preferences (for field agents and dispatchers)
45
+ default_map_zoom = Column(Integer, default=12)
46
+ default_map_center_lat = Column(Float, nullable=True) # User's preferred map center
47
+ default_map_center_lng = Column(Float, nullable=True)
48
+
49
+ # Additional settings (flexible JSONB for future expansion)
50
+ # Can store: sidebar_collapsed, compact_mode, color_blind_mode, etc.
51
+ additional_settings = Column(JSONB, default={})
52
+
53
+ def __repr__(self):
54
+ return f"<UserPreference(user_id='{self.user_id}', theme='{self.theme}')>"
src/app/schemas/user_preferences.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User Preferences Schemas
3
+ Handles user-specific settings like favorite apps, theme, notifications, etc.
4
+ Maps to user_preferences table in database
5
+ """
6
+ from pydantic import BaseModel, Field, validator
7
+ from typing import Optional, List, Dict, Any
8
+ from uuid import UUID
9
+
10
+
11
+ class UserPreferencesUpdate(BaseModel):
12
+ """Update user preferences"""
13
+ favorite_apps: Optional[List[str]] = Field(
14
+ None,
15
+ max_items=6,
16
+ description="List of favorite app codes (max 6) e.g., ['dashboard', 'tickets', 'projects']"
17
+ )
18
+ theme: Optional[str] = Field(
19
+ None,
20
+ description="UI theme: 'light', 'dark', 'auto'"
21
+ )
22
+ language: Optional[str] = Field(
23
+ None,
24
+ description="Preferred language code (e.g., 'en', 'sw')"
25
+ )
26
+ email_notifications: Optional[bool] = Field(
27
+ None,
28
+ description="Enable/disable email notifications"
29
+ )
30
+ push_notifications: Optional[bool] = Field(
31
+ None,
32
+ description="Enable/disable push notifications"
33
+ )
34
+ sms_notifications: Optional[bool] = Field(
35
+ None,
36
+ description="Enable/disable SMS notifications"
37
+ )
38
+ dashboard_widgets: Optional[List[str]] = Field(
39
+ None,
40
+ description="Array of widget codes to display on dashboard"
41
+ )
42
+ default_tickets_view: Optional[str] = Field(
43
+ None,
44
+ description="Default view for tickets: 'list', 'kanban', 'calendar'"
45
+ )
46
+ tickets_per_page: Optional[int] = Field(
47
+ None,
48
+ ge=10,
49
+ le=100,
50
+ description="Pagination size (10-100)"
51
+ )
52
+ default_sort_field: Optional[str] = Field(
53
+ None,
54
+ description="Default sort field"
55
+ )
56
+ default_sort_order: Optional[str] = Field(
57
+ None,
58
+ description="Default sort order: 'asc', 'desc'"
59
+ )
60
+ default_map_zoom: Optional[int] = Field(
61
+ None,
62
+ description="Default map zoom level"
63
+ )
64
+ default_map_center_lat: Optional[float] = Field(
65
+ None,
66
+ description="Default map center latitude"
67
+ )
68
+ default_map_center_lng: Optional[float] = Field(
69
+ None,
70
+ description="Default map center longitude"
71
+ )
72
+ additional_settings: Optional[Dict[str, Any]] = Field(
73
+ None,
74
+ description="Additional settings (sidebar_collapsed, compact_mode, etc.)"
75
+ )
76
+
77
+ @validator('theme')
78
+ def validate_theme(cls, v):
79
+ if v and v not in ['light', 'dark', 'auto']:
80
+ raise ValueError('Theme must be one of: light, dark, auto')
81
+ return v
82
+
83
+ @validator('default_tickets_view')
84
+ def validate_tickets_view(cls, v):
85
+ if v and v not in ['list', 'kanban', 'calendar']:
86
+ raise ValueError('Tickets view must be one of: list, kanban, calendar')
87
+ return v
88
+
89
+ @validator('default_sort_order')
90
+ def validate_sort_order(cls, v):
91
+ if v and v not in ['asc', 'desc']:
92
+ raise ValueError('Sort order must be: asc or desc')
93
+ return v
94
+
95
+
96
+ class UserPreferencesResponse(BaseModel):
97
+ """User preferences response"""
98
+ id: UUID
99
+ user_id: UUID
100
+ favorite_apps: List[str] = Field(
101
+ default_factory=lambda: ['dashboard', 'tickets', 'projects', 'maps'],
102
+ description="List of favorite app codes"
103
+ )
104
+ theme: str = Field(default="light", description="UI theme")
105
+ language: str = Field(default="en", description="Preferred language")
106
+ email_notifications: bool = Field(default=True, description="Email notifications enabled")
107
+ push_notifications: bool = Field(default=True, description="Push notifications enabled")
108
+ sms_notifications: bool = Field(default=False, description="SMS notifications enabled")
109
+ dashboard_widgets: List[str] = Field(
110
+ default_factory=lambda: ['recent_tickets', 'team_performance', 'sla_metrics'],
111
+ description="Dashboard widgets"
112
+ )
113
+ default_tickets_view: str = Field(default="list", description="Default tickets view")
114
+ tickets_per_page: int = Field(default=25, description="Tickets per page")
115
+ default_sort_field: str = Field(default="created_at", description="Default sort field")
116
+ default_sort_order: str = Field(default="desc", description="Default sort order")
117
+ default_map_zoom: int = Field(default=12, description="Default map zoom")
118
+ default_map_center_lat: Optional[float] = Field(None, description="Map center latitude")
119
+ default_map_center_lng: Optional[float] = Field(None, description="Map center longitude")
120
+ additional_settings: Dict[str, Any] = Field(default_factory=dict, description="Additional settings")
121
+
122
+ class Config:
123
+ from_attributes = True
124
+ json_schema_extra = {
125
+ "example": {
126
+ "id": "550e8400-e29b-41d4-a716-446655440000",
127
+ "user_id": "550e8400-e29b-41d4-a716-446655440001",
128
+ "favorite_apps": ["dashboard", "organizations", "users", "activity"],
129
+ "theme": "light",
130
+ "language": "en",
131
+ "email_notifications": True,
132
+ "push_notifications": True,
133
+ "sms_notifications": False,
134
+ "dashboard_widgets": ["recent_tickets", "team_performance", "sla_metrics"],
135
+ "default_tickets_view": "list",
136
+ "tickets_per_page": 25,
137
+ "default_sort_field": "created_at",
138
+ "default_sort_order": "desc",
139
+ "default_map_zoom": 12,
140
+ "default_map_center_lat": None,
141
+ "default_map_center_lng": None,
142
+ "additional_settings": {}
143
+ }
144
+ }
145
+
146
+
147
+ # Default favorite apps by role (using database app codes - lowercase with underscores)
148
+ DEFAULT_FAVORITE_APPS = {
149
+ "platform_admin": ["dashboard", "organizations", "users", "activity"],
150
+ "client_admin": ["dashboard", "projects", "tickets", "team"],
151
+ "contractor_admin": ["dashboard", "projects", "tickets", "team"],
152
+ "sales_manager": ["dashboard", "sales_orders", "customers", "reports"],
153
+ "project_manager": ["dashboard", "projects", "tickets", "team"],
154
+ "dispatcher": ["dashboard", "tickets", "maps", "team"],
155
+ "field_agent": ["tickets", "maps", "timesheets", "profile"],
156
+ "sales_agent": ["dashboard", "sales_orders", "customers", "maps"]
157
+ }
158
+
159
+ # Available apps by role (what they can favorite - max 6)
160
+ AVAILABLE_APPS = {
161
+ "platform_admin": [
162
+ # Core apps
163
+ "dashboard", "organizations", "users", "activity",
164
+ # Management
165
+ "settings", "billing", "notifications", "help"
166
+ ],
167
+ "client_admin": [
168
+ "dashboard", "projects", "tickets", "team", "sales_orders",
169
+ "customers", "contractors", "reports", "settings", "help"
170
+ ],
171
+ "contractor_admin": [
172
+ "dashboard", "projects", "tickets", "team", "timesheets",
173
+ "payroll", "reports", "settings", "help"
174
+ ],
175
+ "sales_manager": [
176
+ "dashboard", "sales_orders", "customers", "reports",
177
+ "team", "maps", "settings", "help"
178
+ ],
179
+ "project_manager": [
180
+ "dashboard", "projects", "tickets", "team", "reports",
181
+ "maps", "settings", "help"
182
+ ],
183
+ "dispatcher": [
184
+ "dashboard", "tickets", "maps", "team", "projects",
185
+ "reports", "settings", "help"
186
+ ],
187
+ "field_agent": [
188
+ "tickets", "maps", "timesheets", "profile", "expenses",
189
+ "documents", "help"
190
+ ],
191
+ "sales_agent": [
192
+ "dashboard", "sales_orders", "customers", "maps",
193
+ "profile", "reports", "help"
194
+ ]
195
+ }
196
+
197
+ # Available dashboard widgets by role
198
+ DEFAULT_DASHBOARD_WIDGETS = {
199
+ "platform_admin": ["recent_tickets", "team_performance", "sla_metrics", "organizations_overview"],
200
+ "client_admin": ["recent_tickets", "team_performance", "sla_metrics", "project_status"],
201
+ "contractor_admin": ["recent_tickets", "team_performance", "payroll_summary", "project_status"],
202
+ "sales_manager": ["sales_pipeline", "revenue_metrics", "team_performance", "conversion_rates"],
203
+ "project_manager": ["project_status", "team_performance", "sla_metrics", "map_view"],
204
+ "dispatcher": ["recent_tickets", "map_view", "team_availability", "sla_metrics"],
205
+ "field_agent": ["my_tickets", "earnings_summary", "attendance_summary"],
206
+ "sales_agent": ["my_sales", "customer_pipeline", "earnings_summary"]
207
+ }