Refactor to manager-centric model, add team calendar for all users
Key changes: - Removed office-centric model (deleted offices.py, office-rules) - Renamed to team-rules, managers are part of their own team - Team calendar visible to all (read-only for employees) - Admins can have a manager assigned
This commit is contained in:
@@ -28,9 +28,9 @@ AUTHELIA_HEADER_USER = os.getenv("AUTHELIA_HEADER_USER", "Remote-User")
|
||||
AUTHELIA_HEADER_NAME = os.getenv("AUTHELIA_HEADER_NAME", "Remote-Name")
|
||||
AUTHELIA_HEADER_EMAIL = os.getenv("AUTHELIA_HEADER_EMAIL", "Remote-Email")
|
||||
AUTHELIA_HEADER_GROUPS = os.getenv("AUTHELIA_HEADER_GROUPS", "Remote-Groups")
|
||||
# Group to role mapping (follows lldap naming convention)
|
||||
# Only parking_admins group is synced from LLDAP -> admin role
|
||||
# Manager role and user assignments are managed by admin in the app UI
|
||||
AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins")
|
||||
AUTHELIA_MANAGER_GROUP = os.getenv("AUTHELIA_MANAGER_GROUP", "managers")
|
||||
|
||||
# Email (optional)
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
||||
|
||||
@@ -22,7 +22,7 @@ class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: str
|
||||
office_id: str | None = None
|
||||
manager_id: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -39,7 +39,7 @@ class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str | None
|
||||
office_id: str | None
|
||||
manager_id: str | None
|
||||
role: str
|
||||
manager_parking_quota: int | None = None
|
||||
week_start_day: int = 0
|
||||
@@ -71,7 +71,7 @@ def register(data: RegisterRequest, db: Session = Depends(get_db)):
|
||||
email=data.email,
|
||||
password=data.password,
|
||||
name=data.name,
|
||||
office_id=data.office_id
|
||||
manager_id=data.manager_id
|
||||
)
|
||||
|
||||
token = create_access_token(user.id, user.email)
|
||||
@@ -116,7 +116,7 @@ def get_me(user=Depends(get_current_user)):
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
office_id=user.office_id,
|
||||
manager_id=user.manager_id,
|
||||
role=user.role,
|
||||
manager_parking_quota=user.manager_parking_quota,
|
||||
week_start_day=user.week_start_day or 0,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
Manager Rules Routes
|
||||
Manager settings, closing days, guarantees, and exclusions
|
||||
|
||||
Key concept: Managers own parking spots and set rules for all their managed offices.
|
||||
Rules are set at manager level, not office level.
|
||||
Key concept: Managers own parking spots and set rules for their managed users.
|
||||
Rules are set at manager level (users have manager_id pointing to their manager).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -13,7 +13,7 @@ import uuid
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import (
|
||||
Office, User, OfficeMembership,
|
||||
User,
|
||||
ManagerClosingDay, ManagerWeeklyClosingDay,
|
||||
ParkingGuarantee, ParkingExclusion
|
||||
)
|
||||
@@ -52,14 +52,12 @@ class ManagerSettingsUpdate(BaseModel):
|
||||
# Manager listing and details
|
||||
@router.get("")
|
||||
def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get all managers with their managed offices and parking quota"""
|
||||
"""Get all managers with their managed user count and parking quota"""
|
||||
managers = db.query(User).filter(User.role == "manager").all()
|
||||
result = []
|
||||
|
||||
for manager in managers:
|
||||
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all()
|
||||
office_ids = [m.office_id for m in memberships]
|
||||
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
|
||||
managed_user_count = db.query(User).filter(User.manager_id == manager.id).count()
|
||||
|
||||
result.append({
|
||||
"id": manager.id,
|
||||
@@ -67,7 +65,7 @@ def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or
|
||||
"email": manager.email,
|
||||
"parking_quota": manager.manager_parking_quota or 0,
|
||||
"spot_prefix": manager.manager_spot_prefix,
|
||||
"offices": [{"id": o.id, "name": o.name} for o in offices]
|
||||
"managed_user_count": managed_user_count
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -75,14 +73,12 @@ def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or
|
||||
|
||||
@router.get("/{manager_id}")
|
||||
def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get manager details including offices and parking settings"""
|
||||
"""Get manager details including managed users and parking settings"""
|
||||
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
|
||||
if not manager:
|
||||
raise HTTPException(status_code=404, detail="Manager not found")
|
||||
|
||||
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all()
|
||||
office_ids = [m.office_id for m in memberships]
|
||||
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
|
||||
managed_user_count = db.query(User).filter(User.manager_id == manager_id).count()
|
||||
|
||||
return {
|
||||
"id": manager.id,
|
||||
@@ -90,7 +86,7 @@ def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Dep
|
||||
"email": manager.email,
|
||||
"parking_quota": manager.manager_parking_quota or 0,
|
||||
"spot_prefix": manager.manager_spot_prefix,
|
||||
"offices": [{"id": o.id, "name": o.name} for o in offices]
|
||||
"managed_user_count": managed_user_count
|
||||
}
|
||||
|
||||
|
||||
@@ -131,19 +127,16 @@ def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Se
|
||||
|
||||
@router.get("/{manager_id}/users")
|
||||
def get_manager_users(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get all users from offices managed by this manager"""
|
||||
"""Get all users in a manager's team (including the manager themselves)"""
|
||||
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
|
||||
if not manager:
|
||||
raise HTTPException(status_code=404, detail="Manager not found")
|
||||
|
||||
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all()
|
||||
managed_office_ids = [m.office_id for m in memberships]
|
||||
|
||||
if not managed_office_ids:
|
||||
return []
|
||||
|
||||
users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all()
|
||||
return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role, "office_id": u.office_id} for u in users]
|
||||
# Include users managed by this manager + the manager themselves
|
||||
users = db.query(User).filter(
|
||||
(User.manager_id == manager_id) | (User.id == manager_id)
|
||||
).all()
|
||||
return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role} for u in users]
|
||||
|
||||
|
||||
# Closing days
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
"""
|
||||
Office Management Routes
|
||||
Admin CRUD for offices and manager-office memberships
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import Office, User, OfficeMembership
|
||||
from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/offices", tags=["offices"])
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class OfficeCreate(BaseModel):
|
||||
name: str
|
||||
location: str | None = None
|
||||
|
||||
|
||||
class OfficeUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
location: str | None = None
|
||||
|
||||
|
||||
class OfficeResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
location: str | None = None
|
||||
created_at: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AddManagerRequest(BaseModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
# Office CRUD Routes
|
||||
@router.get("")
|
||||
def list_offices(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""List all offices with counts"""
|
||||
offices = db.query(Office).all()
|
||||
result = []
|
||||
for office in offices:
|
||||
manager_count = db.query(OfficeMembership).filter(OfficeMembership.office_id == office.id).count()
|
||||
employee_count = db.query(User).filter(User.office_id == office.id).count()
|
||||
result.append({
|
||||
"id": office.id,
|
||||
"name": office.name,
|
||||
"location": office.location,
|
||||
"created_at": office.created_at,
|
||||
"manager_count": manager_count,
|
||||
"employee_count": employee_count
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{office_id}", response_model=OfficeResponse)
|
||||
def get_office(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""Get office by ID"""
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
return office
|
||||
|
||||
|
||||
@router.post("", response_model=OfficeResponse)
|
||||
def create_office(data: OfficeCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Create new office (admin only)"""
|
||||
office = Office(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data.name,
|
||||
location=data.location,
|
||||
created_at=datetime.utcnow().isoformat()
|
||||
)
|
||||
|
||||
db.add(office)
|
||||
db.commit()
|
||||
db.refresh(office)
|
||||
return office
|
||||
|
||||
|
||||
@router.put("/{office_id}", response_model=OfficeResponse)
|
||||
def update_office(office_id: str, data: OfficeUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Update office (admin only)"""
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
|
||||
if data.name is not None:
|
||||
office.name = data.name
|
||||
if data.location is not None:
|
||||
office.location = data.location
|
||||
|
||||
office.updated_at = datetime.utcnow().isoformat()
|
||||
db.commit()
|
||||
db.refresh(office)
|
||||
return office
|
||||
|
||||
|
||||
@router.delete("/{office_id}")
|
||||
def delete_office(office_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Delete office (admin only)"""
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
|
||||
if db.query(User).filter(User.office_id == office_id).count() > 0:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete office with assigned users")
|
||||
|
||||
db.delete(office)
|
||||
db.commit()
|
||||
return {"message": "Office deleted"}
|
||||
|
||||
|
||||
# Office membership routes (linking managers to offices)
|
||||
@router.get("/{office_id}/managers")
|
||||
def get_office_managers(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get managers for an office"""
|
||||
memberships = db.query(OfficeMembership).filter(OfficeMembership.office_id == office_id).all()
|
||||
manager_ids = [m.user_id for m in memberships]
|
||||
managers = db.query(User).filter(User.id.in_(manager_ids)).all()
|
||||
return [{"id": m.id, "name": m.name, "email": m.email} for m in managers]
|
||||
|
||||
|
||||
@router.post("/{office_id}/managers")
|
||||
def add_office_manager(office_id: str, data: AddManagerRequest, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Add manager to office (admin only)"""
|
||||
if not db.query(Office).filter(Office.id == office_id).first():
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
|
||||
manager = db.query(User).filter(User.id == data.user_id).first()
|
||||
if not manager:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if manager.role != "manager":
|
||||
raise HTTPException(status_code=400, detail="User must have manager role")
|
||||
|
||||
existing = db.query(OfficeMembership).filter(
|
||||
OfficeMembership.office_id == office_id,
|
||||
OfficeMembership.user_id == data.user_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Manager already assigned to office")
|
||||
|
||||
membership = OfficeMembership(
|
||||
id=str(uuid.uuid4()),
|
||||
office_id=office_id,
|
||||
user_id=data.user_id,
|
||||
created_at=datetime.utcnow().isoformat()
|
||||
)
|
||||
db.add(membership)
|
||||
db.commit()
|
||||
return {"message": "Manager added to office"}
|
||||
|
||||
|
||||
@router.delete("/{office_id}/managers/{manager_id}")
|
||||
def remove_office_manager(office_id: str, manager_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Remove manager from office (admin only)"""
|
||||
membership = db.query(OfficeMembership).filter(
|
||||
OfficeMembership.office_id == office_id,
|
||||
OfficeMembership.user_id == manager_id
|
||||
).first()
|
||||
if not membership:
|
||||
raise HTTPException(status_code=404, detail="Manager not assigned to office")
|
||||
|
||||
db.delete(membership)
|
||||
db.commit()
|
||||
return {"message": "Manager removed from office"}
|
||||
|
||||
|
||||
# Legacy redirect for /api/offices/managers/list -> /api/managers
|
||||
@router.get("/managers/list")
|
||||
def list_managers_legacy(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get all managers with their managed offices and parking quota (legacy endpoint)"""
|
||||
managers = db.query(User).filter(User.role == "manager").all()
|
||||
result = []
|
||||
|
||||
for manager in managers:
|
||||
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all()
|
||||
office_ids = [m.office_id for m in memberships]
|
||||
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
|
||||
|
||||
result.append({
|
||||
"id": manager.id,
|
||||
"name": manager.name,
|
||||
"email": manager.email,
|
||||
"parking_quota": manager.manager_parking_quota or 0,
|
||||
"spot_prefix": manager.manager_spot_prefix,
|
||||
"offices": [{"id": o.id, "name": o.name} for o in offices]
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import DailyParkingAssignment, User, OfficeMembership
|
||||
from database.models import DailyParkingAssignment, User
|
||||
from utils.auth_middleware import get_current_user, require_manager_or_admin
|
||||
from services.parking import initialize_parking_pool, get_spot_display_name
|
||||
from services.notifications import queue_parking_change_notification
|
||||
@@ -49,7 +49,6 @@ class AssignmentResponse(BaseModel):
|
||||
manager_id: str
|
||||
user_name: str | None = None
|
||||
user_email: str | None = None
|
||||
user_office_id: str | None = None
|
||||
|
||||
|
||||
# Routes
|
||||
@@ -102,7 +101,6 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
|
||||
if user:
|
||||
result.user_name = user.name
|
||||
result.user_email = user.email
|
||||
result.user_office_id = user.office_id
|
||||
|
||||
results.append(result)
|
||||
|
||||
@@ -307,7 +305,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
||||
@router.get("/eligible-users/{assignment_id}")
|
||||
def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get users eligible for reassignment of a parking spot.
|
||||
Returns users in the same manager's offices.
|
||||
Returns users managed by the same manager.
|
||||
"""
|
||||
assignment = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.id == assignment_id
|
||||
@@ -324,16 +322,9 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
|
||||
if not (is_admin or is_manager or is_spot_owner):
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
# Get all users belonging to offices managed by this manager
|
||||
# Get offices managed by this manager
|
||||
managed_office_ids = db.query(OfficeMembership.office_id).filter(
|
||||
OfficeMembership.user_id == assignment.manager_id
|
||||
).all()
|
||||
managed_office_ids = [o[0] for o in managed_office_ids]
|
||||
|
||||
# Get users in those offices
|
||||
# Get users in this manager's team (including the manager themselves)
|
||||
users = db.query(User).filter(
|
||||
User.office_id.in_(managed_office_ids),
|
||||
(User.manager_id == assignment.manager_id) | (User.id == assignment.manager_id),
|
||||
User.id != assignment.user_id # Exclude current holder
|
||||
).all()
|
||||
|
||||
@@ -351,8 +342,7 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
"office_id": user.office_id
|
||||
"email": user.email
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@@ -10,8 +10,8 @@ from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import UserPresence, User, DailyParkingAssignment, OfficeMembership, Office
|
||||
from utils.auth_middleware import get_current_user, require_manager_or_admin, check_manager_access_to_user
|
||||
from database.models import UserPresence, User, DailyParkingAssignment
|
||||
from utils.auth_middleware import get_current_user, require_manager_or_admin
|
||||
from services.parking import handle_presence_change, get_spot_display_name
|
||||
|
||||
router = APIRouter(prefix="/api/presence", tags=["presence"])
|
||||
@@ -70,6 +70,20 @@ def parse_date(date_str: str) -> datetime:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
|
||||
def check_manager_access(current_user: User, target_user: User, db: Session):
|
||||
"""Check if current_user has access to target_user"""
|
||||
if current_user.role == "admin":
|
||||
return True
|
||||
|
||||
if current_user.role == "manager":
|
||||
# Manager can access users they manage
|
||||
if target_user.manager_id == current_user.id:
|
||||
return True
|
||||
raise HTTPException(status_code=403, detail="User is not managed by you")
|
||||
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
def _mark_presence_for_user(
|
||||
user_id: str,
|
||||
date: str,
|
||||
@@ -111,12 +125,18 @@ def _mark_presence_for_user(
|
||||
db.refresh(presence)
|
||||
|
||||
# Handle parking assignment
|
||||
if old_status != status and target_user.office_id:
|
||||
# Use manager_id if user has one, or user's own id if they are a manager
|
||||
parking_manager_id = target_user.manager_id
|
||||
if not parking_manager_id and target_user.role == "manager":
|
||||
# Manager is part of their own team for parking purposes
|
||||
parking_manager_id = target_user.id
|
||||
|
||||
if old_status != status and parking_manager_id:
|
||||
try:
|
||||
handle_presence_change(
|
||||
user_id, date,
|
||||
old_status or "absent", status,
|
||||
target_user.office_id, db
|
||||
parking_manager_id, db
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Warning: Parking handler failed: {e}")
|
||||
@@ -177,12 +197,17 @@ def _bulk_mark_presence(
|
||||
results.append(presence)
|
||||
|
||||
# Handle parking for each date
|
||||
if old_status != status and target_user.office_id:
|
||||
# Use manager_id if user has one, or user's own id if they are a manager
|
||||
parking_manager_id = target_user.manager_id
|
||||
if not parking_manager_id and target_user.role == "manager":
|
||||
parking_manager_id = target_user.id
|
||||
|
||||
if old_status != status and parking_manager_id:
|
||||
try:
|
||||
handle_presence_change(
|
||||
user_id, date_str,
|
||||
old_status or "absent", status,
|
||||
target_user.office_id, db
|
||||
parking_manager_id, db
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -216,12 +241,17 @@ def _delete_presence(
|
||||
db.delete(presence)
|
||||
db.commit()
|
||||
|
||||
if target_user.office_id:
|
||||
# Use manager_id if user has one, or user's own id if they are a manager
|
||||
parking_manager_id = target_user.manager_id
|
||||
if not parking_manager_id and target_user.role == "manager":
|
||||
parking_manager_id = target_user.id
|
||||
|
||||
if parking_manager_id:
|
||||
try:
|
||||
handle_presence_change(
|
||||
user_id, date,
|
||||
old_status, "absent",
|
||||
target_user.office_id, db
|
||||
parking_manager_id, db
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -274,7 +304,7 @@ def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(ge
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
check_manager_access_to_user(current_user, target_user, db)
|
||||
check_manager_access(current_user, target_user, db)
|
||||
return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user)
|
||||
|
||||
|
||||
@@ -285,7 +315,7 @@ def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depen
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
check_manager_access_to_user(current_user, target_user, db)
|
||||
check_manager_access(current_user, target_user, db)
|
||||
return _bulk_mark_presence(
|
||||
data.user_id, data.start_date, data.end_date,
|
||||
data.status, data.days, db, target_user
|
||||
@@ -299,34 +329,42 @@ def admin_delete_presence(user_id: str, date: str, db: Session = Depends(get_db)
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
check_manager_access_to_user(current_user, target_user, db)
|
||||
check_manager_access(current_user, target_user, db)
|
||||
return _delete_presence(user_id, date, db, target_user)
|
||||
|
||||
|
||||
@router.get("/team")
|
||||
def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Get team presences with parking info for managers/admins, filtered by manager"""
|
||||
def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get team presences with parking info, filtered by manager.
|
||||
- Admins can see all teams
|
||||
- Managers see their own team
|
||||
- Employees can only see their own team (read-only view)
|
||||
"""
|
||||
parse_date(start_date)
|
||||
parse_date(end_date)
|
||||
|
||||
# Get users based on permissions and manager filter
|
||||
if manager_id:
|
||||
# Filter by specific manager's offices
|
||||
managed_office_ids = [m.office_id for m in db.query(OfficeMembership).filter(
|
||||
OfficeMembership.user_id == manager_id
|
||||
).all()]
|
||||
if not managed_office_ids:
|
||||
return []
|
||||
users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all()
|
||||
# Note: Manager is part of their own team (for parking assignment purposes)
|
||||
if current_user.role == "employee":
|
||||
# Employees can only see their own team (users with same manager_id + the manager)
|
||||
if not current_user.manager_id:
|
||||
return [] # No manager assigned, no team to show
|
||||
users = db.query(User).filter(
|
||||
(User.manager_id == current_user.manager_id) | (User.id == current_user.manager_id)
|
||||
).all()
|
||||
elif manager_id:
|
||||
# Filter by specific manager (for admins/managers) - include the manager themselves
|
||||
users = db.query(User).filter(
|
||||
(User.manager_id == manager_id) | (User.id == manager_id)
|
||||
).all()
|
||||
elif current_user.role == "admin":
|
||||
# Admin sees all users
|
||||
users = db.query(User).all()
|
||||
else:
|
||||
# Manager sees only users in their managed offices
|
||||
managed_ids = [m.office_id for m in current_user.managed_offices]
|
||||
if not managed_ids:
|
||||
return []
|
||||
users = db.query(User).filter(User.office_id.in_(managed_ids)).all()
|
||||
# Manager sees their team + themselves
|
||||
users = db.query(User).filter(
|
||||
(User.manager_id == current_user.id) | (User.id == current_user.id)
|
||||
).all()
|
||||
|
||||
# Batch query presences and parking for all users
|
||||
user_ids = [u.id for u in users]
|
||||
@@ -358,30 +396,21 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
|
||||
"spot_display_name": spot_display_name
|
||||
})
|
||||
|
||||
# Build office and managed offices lookups
|
||||
offices_lookup = {o.id: o.name for o in db.query(Office).all()}
|
||||
managed_offices_lookup = {}
|
||||
for m in db.query(OfficeMembership).all():
|
||||
if m.user_id not in managed_offices_lookup:
|
||||
managed_offices_lookup[m.user_id] = []
|
||||
managed_offices_lookup[m.user_id].append(offices_lookup.get(m.office_id, m.office_id))
|
||||
# Build manager lookup for display
|
||||
manager_ids = list(set(u.manager_id for u in users if u.manager_id))
|
||||
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
|
||||
manager_lookup = {m.id: m.name for m in managers}
|
||||
|
||||
# Build response
|
||||
result = []
|
||||
for user in users:
|
||||
user_presences = [p for p in presences if p.user_id == user.id]
|
||||
|
||||
# For managers, show managed offices; for others, show their office
|
||||
if user.role == "manager" and user.id in managed_offices_lookup:
|
||||
office_display = ", ".join(managed_offices_lookup[user.id])
|
||||
else:
|
||||
office_display = offices_lookup.get(user.office_id)
|
||||
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"office_id": user.office_id,
|
||||
"office_name": office_display,
|
||||
"manager_id": user.manager_id,
|
||||
"manager_name": manager_lookup.get(user.manager_id),
|
||||
"presences": [{"date": p.date, "status": p.status} for p in user_presences],
|
||||
"parking_dates": parking_lookup.get(user.id, []),
|
||||
"parking_info": parking_info_lookup.get(user.id, [])
|
||||
@@ -397,7 +426,7 @@ def get_user_presences(user_id: str, start_date: str = None, end_date: str = Non
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
check_manager_access_to_user(current_user, target_user, db)
|
||||
check_manager_access(current_user, target_user, db)
|
||||
|
||||
query = db.query(UserPresence).filter(UserPresence.user_id == user_id)
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ import uuid
|
||||
import re
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import User, Office, OfficeMembership
|
||||
from database.models import User
|
||||
from utils.auth_middleware import get_current_user, require_admin
|
||||
from services.auth import hash_password, verify_password
|
||||
from app import config
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
@@ -24,21 +25,19 @@ class UserCreate(BaseModel):
|
||||
password: str
|
||||
name: str | None = None
|
||||
role: str = "employee"
|
||||
office_id: str | None = None
|
||||
manager_id: str | None = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: EmailStr | None = None
|
||||
name: str | None = None
|
||||
role: str | None = None
|
||||
office_id: str | None = None
|
||||
manager_id: str | None = None
|
||||
manager_parking_quota: int | None = None
|
||||
manager_spot_prefix: str | None = None
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
office_id: str | None = None
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
@@ -61,44 +60,86 @@ class UserResponse(BaseModel):
|
||||
email: str
|
||||
name: str | None
|
||||
role: str
|
||||
office_id: str | None
|
||||
manager_id: str | None = None
|
||||
manager_name: str | None = None
|
||||
manager_parking_quota: int | None = None
|
||||
manager_spot_prefix: str | None = None
|
||||
managed_user_count: int | None = None
|
||||
is_ldap_user: bool = False
|
||||
is_ldap_admin: bool = False
|
||||
created_at: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
def user_to_response(user: User, db: Session) -> dict:
|
||||
"""Convert user to response dict with computed fields"""
|
||||
# Get manager name if user has a manager
|
||||
manager_name = None
|
||||
if user.manager_id:
|
||||
manager = db.query(User).filter(User.id == user.manager_id).first()
|
||||
if manager:
|
||||
manager_name = manager.name
|
||||
|
||||
# Count managed users if this user is a manager
|
||||
managed_user_count = None
|
||||
if user.role == "manager":
|
||||
managed_user_count = db.query(User).filter(User.manager_id == user.id).count()
|
||||
|
||||
# Determine if user is LDAP-managed
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and user.password_hash is None
|
||||
is_ldap_admin = is_ldap_user and user.role == "admin"
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"manager_id": user.manager_id,
|
||||
"manager_name": manager_name,
|
||||
"manager_parking_quota": user.manager_parking_quota,
|
||||
"manager_spot_prefix": user.manager_spot_prefix,
|
||||
"managed_user_count": managed_user_count,
|
||||
"is_ldap_user": is_ldap_user,
|
||||
"is_ldap_admin": is_ldap_admin,
|
||||
"created_at": user.created_at
|
||||
}
|
||||
|
||||
|
||||
# Admin Routes
|
||||
@router.get("", response_model=List[UserResponse])
|
||||
@router.get("")
|
||||
def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""List all users (admin only)"""
|
||||
users = db.query(User).all()
|
||||
return users
|
||||
return [user_to_response(u, db) for u in users]
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
@router.get("/{user_id}")
|
||||
def get_user(user_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Get user by ID (admin only)"""
|
||||
target = db.query(User).filter(User.id == user_id).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return target
|
||||
return user_to_response(target, db)
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse)
|
||||
@router.post("")
|
||||
def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Create new user (admin only)"""
|
||||
"""Create new user (admin only) - only for non-LDAP mode"""
|
||||
if config.AUTHELIA_ENABLED:
|
||||
raise HTTPException(status_code=400, detail="User creation disabled in LDAP mode. Users are created on first login.")
|
||||
|
||||
if db.query(User).filter(User.email == data.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
if data.role not in ["admin", "manager", "employee"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
|
||||
if data.office_id:
|
||||
if not db.query(Office).filter(Office.id == data.office_id).first():
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
if data.manager_id:
|
||||
manager = db.query(User).filter(User.id == data.manager_id).first()
|
||||
if not manager or manager.role != "manager":
|
||||
raise HTTPException(status_code=400, detail="Invalid manager")
|
||||
|
||||
new_user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -106,42 +147,61 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
|
||||
password_hash=hash_password(data.password),
|
||||
name=data.name,
|
||||
role=data.role,
|
||||
office_id=data.office_id,
|
||||
manager_id=data.manager_id,
|
||||
created_at=datetime.utcnow().isoformat()
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
return user_to_response(new_user, db)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
@router.put("/{user_id}")
|
||||
def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Update user (admin only)"""
|
||||
target = db.query(User).filter(User.id == user_id).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if data.email is not None:
|
||||
existing = db.query(User).filter(User.email == data.email, User.id != user_id).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already in use")
|
||||
target.email = data.email
|
||||
# Check if user is LDAP-managed
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and target.password_hash is None
|
||||
is_ldap_admin = is_ldap_user and target.role == "admin"
|
||||
|
||||
# Name update - blocked for LDAP users
|
||||
if data.name is not None:
|
||||
if is_ldap_user:
|
||||
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
|
||||
target.name = data.name
|
||||
|
||||
# Role update
|
||||
if data.role is not None:
|
||||
if data.role not in ["admin", "manager", "employee"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
# Can't change admin role for LDAP admins (they get admin from parking_admins group)
|
||||
if is_ldap_admin and data.role != "admin":
|
||||
raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)")
|
||||
# If changing from manager to another role, check for managed users
|
||||
if target.role == "manager" and data.role != "manager":
|
||||
managed_count = db.query(User).filter(User.manager_id == user_id).count()
|
||||
if managed_count > 0:
|
||||
raise HTTPException(status_code=400, detail=f"Cannot change role: {managed_count} users are assigned to this manager")
|
||||
# Clear manager-specific fields
|
||||
target.manager_parking_quota = 0
|
||||
target.manager_spot_prefix = None
|
||||
target.role = data.role
|
||||
|
||||
if data.office_id is not None:
|
||||
if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first():
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
target.office_id = data.office_id
|
||||
# Manager assignment (any user including admins can be assigned to a manager)
|
||||
if data.manager_id is not None:
|
||||
if data.manager_id:
|
||||
manager = db.query(User).filter(User.id == data.manager_id).first()
|
||||
if not manager or manager.role != "manager":
|
||||
raise HTTPException(status_code=400, detail="Invalid manager")
|
||||
if data.manager_id == user_id:
|
||||
raise HTTPException(status_code=400, detail="User cannot be their own manager")
|
||||
target.manager_id = data.manager_id if data.manager_id else None
|
||||
|
||||
# Manager-specific fields
|
||||
if data.manager_parking_quota is not None:
|
||||
if target.role != "manager":
|
||||
raise HTTPException(status_code=400, detail="Parking quota only for managers")
|
||||
@@ -166,7 +226,7 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
|
||||
target.updated_at = datetime.utcnow().isoformat()
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
return target
|
||||
return user_to_response(target, db)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
@@ -179,60 +239,53 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Check if user is a manager with managed users
|
||||
if target.role == "manager":
|
||||
managed_count = db.query(User).filter(User.manager_id == user_id).count()
|
||||
if managed_count > 0:
|
||||
raise HTTPException(status_code=400, detail=f"Cannot delete: {managed_count} users are assigned to this manager")
|
||||
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
return {"message": "User deleted"}
|
||||
|
||||
|
||||
# Self-service Routes
|
||||
@router.get("/me/managed-offices")
|
||||
def get_managed_offices(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get offices the current user manages"""
|
||||
if current_user.role == "admin":
|
||||
offices = db.query(Office).all()
|
||||
return {"role": "admin", "offices": [{"id": o.id, "name": o.name} for o in offices]}
|
||||
|
||||
if current_user.role == "manager":
|
||||
memberships = db.query(OfficeMembership).filter(
|
||||
OfficeMembership.user_id == current_user.id
|
||||
).all()
|
||||
office_ids = [m.office_id for m in memberships]
|
||||
offices = db.query(Office).filter(Office.id.in_(office_ids)).all()
|
||||
return {"role": "manager", "offices": [{"id": o.id, "name": o.name} for o in offices]}
|
||||
|
||||
if current_user.office_id:
|
||||
office = db.query(Office).filter(Office.id == current_user.office_id).first()
|
||||
if office:
|
||||
return {"role": "employee", "offices": [{"id": office.id, "name": office.name}]}
|
||||
|
||||
return {"role": current_user.role, "offices": []}
|
||||
|
||||
|
||||
@router.get("/me/profile")
|
||||
def get_profile(current_user=Depends(get_current_user)):
|
||||
def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get current user's profile"""
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None
|
||||
|
||||
# Get manager name
|
||||
manager_name = None
|
||||
if current_user.manager_id:
|
||||
manager = db.query(User).filter(User.id == current_user.manager_id).first()
|
||||
if manager:
|
||||
manager_name = manager.name
|
||||
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"email": current_user.email,
|
||||
"name": current_user.name,
|
||||
"role": current_user.role,
|
||||
"office_id": current_user.office_id
|
||||
"manager_id": current_user.manager_id,
|
||||
"manager_name": manager_name,
|
||||
"is_ldap_user": is_ldap_user
|
||||
}
|
||||
|
||||
|
||||
@router.put("/me/profile")
|
||||
def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Update current user's profile"""
|
||||
"""Update current user's profile (limited fields)"""
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None
|
||||
|
||||
if data.name is not None:
|
||||
if is_ldap_user:
|
||||
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
|
||||
current_user.name = data.name
|
||||
current_user.updated_at = datetime.utcnow().isoformat()
|
||||
db.commit()
|
||||
|
||||
if data.office_id is not None:
|
||||
if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first():
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
current_user.office_id = data.office_id if data.office_id else None
|
||||
|
||||
current_user.updated_at = datetime.utcnow().isoformat()
|
||||
db.commit()
|
||||
return {"message": "Profile updated"}
|
||||
|
||||
|
||||
@@ -292,7 +345,10 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current
|
||||
|
||||
@router.post("/me/change-password")
|
||||
def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Change current user's password"""
|
||||
"""Change current user's password (not available in LDAP mode)"""
|
||||
if config.AUTHELIA_ENABLED and current_user.password_hash is None:
|
||||
raise HTTPException(status_code=400, detail="Password is managed by LDAP")
|
||||
|
||||
if not verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user