Primo commit
This commit is contained in:
@@ -9,6 +9,7 @@ from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import UserRole
|
||||
from services.auth import (
|
||||
create_user, authenticate_user, create_access_token,
|
||||
get_user_by_email
|
||||
@@ -25,7 +26,6 @@ class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: str
|
||||
manager_id: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -42,16 +42,16 @@ class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str | None
|
||||
manager_id: str | None
|
||||
role: str
|
||||
manager_parking_quota: int | None = None
|
||||
office_id: str | None
|
||||
office_name: str | None = None
|
||||
role: UserRole
|
||||
week_start_day: int = 0
|
||||
# Notification preferences
|
||||
notify_weekly_parking: int = 1
|
||||
notify_daily_parking: int = 1
|
||||
notify_weekly_parking: bool = True
|
||||
notify_daily_parking: bool = True
|
||||
notify_daily_parking_hour: int = 8
|
||||
notify_daily_parking_minute: int = 0
|
||||
notify_parking_changes: int = 1
|
||||
notify_parking_changes: bool = True
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
@@ -76,8 +76,7 @@ def register(request: Request, data: RegisterRequest, db: Session = Depends(get_
|
||||
db=db,
|
||||
email=data.email,
|
||||
password=data.password,
|
||||
name=data.name,
|
||||
manager_id=data.manager_id
|
||||
name=data.name
|
||||
)
|
||||
|
||||
config.logger.info(f"New user registered: {data.email}")
|
||||
@@ -126,15 +125,15 @@ def get_me(user=Depends(get_current_user)):
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
manager_id=user.manager_id,
|
||||
office_id=user.office_id,
|
||||
office_name=user.office.name if user.office else None,
|
||||
role=user.role,
|
||||
manager_parking_quota=user.manager_parking_quota,
|
||||
week_start_day=get_notification_default(user.week_start_day, 0),
|
||||
notify_weekly_parking=get_notification_default(user.notify_weekly_parking, 1),
|
||||
notify_daily_parking=get_notification_default(user.notify_daily_parking, 1),
|
||||
notify_weekly_parking=get_notification_default(user.notify_weekly_parking, True),
|
||||
notify_daily_parking=get_notification_default(user.notify_daily_parking, True),
|
||||
notify_daily_parking_hour=get_notification_default(user.notify_daily_parking_hour, 8),
|
||||
notify_daily_parking_minute=get_notification_default(user.notify_daily_parking_minute, 0),
|
||||
notify_parking_changes=get_notification_default(user.notify_parking_changes, 1)
|
||||
notify_parking_changes=get_notification_default(user.notify_parking_changes, True)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Manager settings, closing days, guarantees, and exclusions
|
||||
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 datetime import datetime, date
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -26,7 +26,8 @@ router = APIRouter(prefix="/api/managers", tags=["managers"])
|
||||
|
||||
# Request/Response Models
|
||||
class ClosingDayCreate(BaseModel):
|
||||
date: str # YYYY-MM-DD
|
||||
date: date # Start date
|
||||
end_date: date | None = None # Optional end date (inclusive)
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@@ -36,14 +37,16 @@ class WeeklyClosingDayCreate(BaseModel):
|
||||
|
||||
class GuaranteeCreate(BaseModel):
|
||||
user_id: str
|
||||
start_date: str | None = None
|
||||
end_date: str | None = None
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ExclusionCreate(BaseModel):
|
||||
user_id: str
|
||||
start_date: str | None = None
|
||||
end_date: str | None = None
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ManagerSettingsUpdate(BaseModel):
|
||||
@@ -124,7 +127,7 @@ def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Se
|
||||
raise HTTPException(status_code=400, detail=f"Spot prefix '{data.spot_prefix}' is already used")
|
||||
manager.manager_spot_prefix = data.spot_prefix
|
||||
|
||||
manager.updated_at = datetime.utcnow().isoformat()
|
||||
manager.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
@@ -155,7 +158,7 @@ def get_manager_closing_days(manager_id: str, db: Session = Depends(get_db), use
|
||||
days = db.query(ManagerClosingDay).filter(
|
||||
ManagerClosingDay.manager_id == manager_id
|
||||
).order_by(ManagerClosingDay.date).all()
|
||||
return [{"id": d.id, "date": d.date, "reason": d.reason} for d in days]
|
||||
return [{"id": d.id, "date": d.date, "end_date": d.end_date, "reason": d.reason} for d in days]
|
||||
|
||||
|
||||
@router.post("/{manager_id}/closing-days")
|
||||
@@ -172,10 +175,14 @@ def add_manager_closing_day(manager_id: str, data: ClosingDayCreate, db: Session
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
|
||||
|
||||
if data.end_date and data.end_date < data.date:
|
||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||
|
||||
closing_day = ManagerClosingDay(
|
||||
id=generate_uuid(),
|
||||
manager_id=manager_id,
|
||||
date=data.date,
|
||||
end_date=data.end_date,
|
||||
reason=data.reason
|
||||
)
|
||||
db.add(closing_day)
|
||||
@@ -270,7 +277,8 @@ def get_manager_guarantees(manager_id: str, db: Session = Depends(get_db), user=
|
||||
"user_id": g.user_id,
|
||||
"user_name": user_lookup.get(g.user_id),
|
||||
"start_date": g.start_date,
|
||||
"end_date": g.end_date
|
||||
"end_date": g.end_date,
|
||||
"notes": g.notes
|
||||
}
|
||||
for g in guarantees
|
||||
]
|
||||
@@ -292,13 +300,17 @@ def add_manager_guarantee(manager_id: str, data: GuaranteeCreate, db: Session =
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
|
||||
|
||||
if data.start_date and data.end_date and data.end_date < data.start_date:
|
||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||
|
||||
guarantee = ParkingGuarantee(
|
||||
id=generate_uuid(),
|
||||
manager_id=manager_id,
|
||||
user_id=data.user_id,
|
||||
start_date=data.start_date,
|
||||
end_date=data.end_date,
|
||||
created_at=datetime.utcnow().isoformat()
|
||||
notes=data.notes,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(guarantee)
|
||||
db.commit()
|
||||
@@ -340,7 +352,8 @@ def get_manager_exclusions(manager_id: str, db: Session = Depends(get_db), user=
|
||||
"user_id": e.user_id,
|
||||
"user_name": user_lookup.get(e.user_id),
|
||||
"start_date": e.start_date,
|
||||
"end_date": e.end_date
|
||||
"end_date": e.end_date,
|
||||
"notes": e.notes
|
||||
}
|
||||
for e in exclusions
|
||||
]
|
||||
@@ -362,13 +375,17 @@ def add_manager_exclusion(manager_id: str, data: ExclusionCreate, db: Session =
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
|
||||
|
||||
if data.start_date and data.end_date and data.end_date < data.start_date:
|
||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||
|
||||
exclusion = ParkingExclusion(
|
||||
id=generate_uuid(),
|
||||
manager_id=manager_id,
|
||||
user_id=data.user_id,
|
||||
start_date=data.start_date,
|
||||
end_date=data.end_date,
|
||||
created_at=datetime.utcnow().isoformat()
|
||||
notes=data.notes,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(exclusion)
|
||||
db.commit()
|
||||
|
||||
500
app/routes/offices.py
Normal file
500
app/routes/offices.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
Office Management Routes
|
||||
Office settings, closing days, guarantees, and exclusions
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import (
|
||||
User, Office,
|
||||
OfficeClosingDay, OfficeWeeklyClosingDay,
|
||||
ParkingGuarantee, ParkingExclusion,
|
||||
UserRole
|
||||
)
|
||||
from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user
|
||||
from utils.helpers import generate_uuid
|
||||
from app import config
|
||||
|
||||
router = APIRouter(prefix="/api/offices", tags=["offices"])
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class ValidOfficeCreate(BaseModel):
|
||||
name: str
|
||||
parking_quota: int = 0
|
||||
|
||||
|
||||
class ClosingDayCreate(BaseModel):
|
||||
date: date # Start date
|
||||
end_date: date | None = None # Optional end date (inclusive)
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class WeeklyClosingDayCreate(BaseModel):
|
||||
weekday: int # 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
|
||||
|
||||
class GuaranteeCreate(BaseModel):
|
||||
user_id: str
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ExclusionCreate(BaseModel):
|
||||
user_id: str
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class OfficeSettingsUpdate(BaseModel):
|
||||
parking_quota: int | None = None
|
||||
name: str | None = None
|
||||
booking_window_enabled: bool | None = None
|
||||
booking_window_end_hour: int | None = None
|
||||
booking_window_end_minute: int | None = None
|
||||
|
||||
|
||||
# Helper check
|
||||
def check_office_access(user: User, office_id: str):
|
||||
if user.role == UserRole.ADMIN:
|
||||
return True
|
||||
if user.role == UserRole.MANAGER and user.office_id == office_id:
|
||||
return True
|
||||
raise HTTPException(status_code=403, detail="Access denied to this office")
|
||||
|
||||
|
||||
# Office listing and details
|
||||
@router.get("")
|
||||
def list_offices(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get all offices with their user count and parking quota"""
|
||||
offices = db.query(Office).all()
|
||||
|
||||
# Batch query user counts
|
||||
counts = db.query(User.office_id, func.count(User.id)).filter(
|
||||
User.office_id.isnot(None)
|
||||
).group_by(User.office_id).all()
|
||||
user_counts = {office_id: count for office_id, count in counts}
|
||||
|
||||
return [
|
||||
{
|
||||
"id": office.id,
|
||||
"name": office.name,
|
||||
"parking_quota": office.parking_quota,
|
||||
"spot_prefix": office.spot_prefix,
|
||||
"user_count": user_counts.get(office.id, 0)
|
||||
}
|
||||
for office in offices
|
||||
]
|
||||
|
||||
def get_next_available_prefix(db: Session) -> str:
|
||||
"""Find the next available office prefix (A, B, C... AA, AB...)"""
|
||||
existing = db.query(Office.spot_prefix).filter(Office.spot_prefix.isnot(None)).all()
|
||||
used_prefixes = {row[0] for row in existing}
|
||||
|
||||
# Try single letters A-Z
|
||||
for i in range(26):
|
||||
char = chr(65 + i)
|
||||
if char not in used_prefixes:
|
||||
return char
|
||||
|
||||
# Try double letters AA-ZZ if needed
|
||||
for i in range(26):
|
||||
for j in range(26):
|
||||
char = chr(65 + i) + chr(65 + j)
|
||||
if char not in used_prefixes:
|
||||
return char
|
||||
|
||||
raise HTTPException(status_code=400, detail="No more office prefixes available")
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Create a new office (admin only)"""
|
||||
office = Office(
|
||||
id=generate_uuid(),
|
||||
name=data.name,
|
||||
parking_quota=data.parking_quota,
|
||||
spot_prefix=get_next_available_prefix(db),
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(office)
|
||||
db.commit()
|
||||
return office
|
||||
|
||||
@router.get("/{office_id}")
|
||||
def get_office_details(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get office details"""
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
|
||||
check_office_access(user, office_id)
|
||||
|
||||
user_count = db.query(User).filter(User.office_id == office_id).count()
|
||||
|
||||
return {
|
||||
"id": office.id,
|
||||
"name": office.name,
|
||||
"parking_quota": office.parking_quota,
|
||||
"spot_prefix": office.spot_prefix,
|
||||
"user_count": user_count,
|
||||
"booking_window_enabled": office.booking_window_enabled,
|
||||
"booking_window_end_hour": office.booking_window_end_hour,
|
||||
"booking_window_end_minute": office.booking_window_end_minute
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{office_id}")
|
||||
def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Update office settings (admin only) - Manager can view but usually Admin sets quota"""
|
||||
# Verify access - currently assume admin manages quota. If manager should too, update logic.
|
||||
# User request description: "Admin manage all offices with CRUD... rimodulare posti auto".
|
||||
# So Managers might not edit quota? Or maybe they can?
|
||||
# Keeping it simple: require_admin for structural changes.
|
||||
|
||||
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:
|
||||
office.name = data.name
|
||||
|
||||
if data.parking_quota is not None:
|
||||
if data.parking_quota < 0:
|
||||
raise HTTPException(status_code=400, detail="Parking quota must be non-negative")
|
||||
office.parking_quota = data.parking_quota
|
||||
|
||||
if data.booking_window_enabled is not None:
|
||||
office.booking_window_enabled = data.booking_window_enabled
|
||||
|
||||
if data.booking_window_end_hour is not None:
|
||||
if not (0 <= data.booking_window_end_hour <= 23):
|
||||
raise HTTPException(status_code=400, detail="Hour must be 0-23")
|
||||
office.booking_window_end_hour = data.booking_window_end_hour
|
||||
|
||||
if data.booking_window_end_minute is not None:
|
||||
if not (0 <= data.booking_window_end_minute <= 59):
|
||||
raise HTTPException(status_code=400, detail="Minute must be 0-59")
|
||||
office.booking_window_end_minute = data.booking_window_end_minute
|
||||
|
||||
office.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"id": office.id,
|
||||
"name": office.name,
|
||||
"parking_quota": office.parking_quota,
|
||||
"spot_prefix": office.spot_prefix,
|
||||
"booking_window_enabled": office.booking_window_enabled,
|
||||
"booking_window_end_hour": office.booking_window_end_hour,
|
||||
"booking_window_end_minute": office.booking_window_end_minute
|
||||
}
|
||||
|
||||
@router.delete("/{office_id}")
|
||||
def delete_office(office_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Delete an 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")
|
||||
|
||||
db.delete(office)
|
||||
db.commit()
|
||||
return {"message": "Office deleted"}
|
||||
|
||||
|
||||
@router.get("/{office_id}/users")
|
||||
def get_office_users(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get all users in an office"""
|
||||
check_office_access(user, office_id)
|
||||
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
|
||||
users = db.query(User).filter(User.office_id == office_id).all()
|
||||
return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role} for u in users]
|
||||
|
||||
|
||||
# Closing days
|
||||
@router.get("/{office_id}/closing-days")
|
||||
def get_office_closing_days(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""Get closing days for an office"""
|
||||
# Any user in the office can read closing days? Or just manager?
|
||||
# check_office_access(user, office_id) # Let's allow read for all authenticated (frontend might need it)
|
||||
|
||||
days = db.query(OfficeClosingDay).filter(
|
||||
OfficeClosingDay.office_id == office_id
|
||||
).order_by(OfficeClosingDay.date).all()
|
||||
return [{"id": d.id, "date": d.date, "end_date": d.end_date, "reason": d.reason} for d in days]
|
||||
|
||||
|
||||
@router.post("/{office_id}/closing-days")
|
||||
def add_office_closing_day(office_id: str, data: ClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Add a closing day for an office"""
|
||||
check_office_access(user, office_id)
|
||||
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
|
||||
existing = db.query(OfficeClosingDay).filter(
|
||||
OfficeClosingDay.office_id == office_id,
|
||||
OfficeClosingDay.date == data.date
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
|
||||
|
||||
if data.end_date and data.end_date < data.date:
|
||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||
|
||||
closing_day = OfficeClosingDay(
|
||||
id=generate_uuid(),
|
||||
office_id=office_id,
|
||||
date=data.date,
|
||||
end_date=data.end_date,
|
||||
reason=data.reason
|
||||
)
|
||||
db.add(closing_day)
|
||||
db.commit()
|
||||
return {"id": closing_day.id, "message": "Closing day added"}
|
||||
|
||||
|
||||
@router.delete("/{office_id}/closing-days/{closing_day_id}")
|
||||
def remove_office_closing_day(office_id: str, closing_day_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Remove a closing day for an office"""
|
||||
check_office_access(user, office_id)
|
||||
|
||||
closing_day = db.query(OfficeClosingDay).filter(
|
||||
OfficeClosingDay.id == closing_day_id,
|
||||
OfficeClosingDay.office_id == office_id
|
||||
).first()
|
||||
if not closing_day:
|
||||
raise HTTPException(status_code=404, detail="Closing day not found")
|
||||
|
||||
db.delete(closing_day)
|
||||
db.commit()
|
||||
return {"message": "Closing day removed"}
|
||||
|
||||
|
||||
# Weekly closing days
|
||||
@router.get("/{office_id}/weekly-closing-days")
|
||||
def get_office_weekly_closing_days(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""Get weekly closing days for an office"""
|
||||
days = db.query(OfficeWeeklyClosingDay).filter(
|
||||
OfficeWeeklyClosingDay.office_id == office_id
|
||||
).all()
|
||||
return [{"id": d.id, "weekday": d.weekday} for d in days]
|
||||
|
||||
|
||||
@router.post("/{office_id}/weekly-closing-days")
|
||||
def add_office_weekly_closing_day(office_id: str, data: WeeklyClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Add a weekly closing day for an office"""
|
||||
check_office_access(user, office_id)
|
||||
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
|
||||
if data.weekday < 0 or data.weekday > 6:
|
||||
raise HTTPException(status_code=400, detail="Weekday must be 0-6 (0=Sunday, 6=Saturday)")
|
||||
|
||||
existing = db.query(OfficeWeeklyClosingDay).filter(
|
||||
OfficeWeeklyClosingDay.office_id == office_id,
|
||||
OfficeWeeklyClosingDay.weekday == data.weekday
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Weekly closing day already exists for this weekday")
|
||||
|
||||
weekly_closing = OfficeWeeklyClosingDay(
|
||||
id=generate_uuid(),
|
||||
office_id=office_id,
|
||||
weekday=data.weekday
|
||||
)
|
||||
db.add(weekly_closing)
|
||||
db.commit()
|
||||
return {"id": weekly_closing.id, "message": "Weekly closing day added"}
|
||||
|
||||
|
||||
@router.delete("/{office_id}/weekly-closing-days/{weekly_id}")
|
||||
def remove_office_weekly_closing_day(office_id: str, weekly_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Remove a weekly closing day for an office"""
|
||||
check_office_access(user, office_id)
|
||||
|
||||
weekly_closing = db.query(OfficeWeeklyClosingDay).filter(
|
||||
OfficeWeeklyClosingDay.id == weekly_id,
|
||||
OfficeWeeklyClosingDay.office_id == office_id
|
||||
).first()
|
||||
if not weekly_closing:
|
||||
raise HTTPException(status_code=404, detail="Weekly closing day not found")
|
||||
|
||||
db.delete(weekly_closing)
|
||||
db.commit()
|
||||
return {"message": "Weekly closing day removed"}
|
||||
|
||||
|
||||
# Guarantees
|
||||
@router.get("/{office_id}/guarantees")
|
||||
def get_office_guarantees(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get parking guarantees for an office"""
|
||||
check_office_access(user, office_id)
|
||||
|
||||
guarantees = db.query(ParkingGuarantee).filter(ParkingGuarantee.office_id == office_id).all()
|
||||
|
||||
# Batch query to get all user names at once
|
||||
user_ids = [g.user_id for g in guarantees]
|
||||
if user_ids:
|
||||
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||
user_lookup = {u.id: u.name for u in users}
|
||||
else:
|
||||
user_lookup = {}
|
||||
|
||||
return [
|
||||
{
|
||||
"id": g.id,
|
||||
"user_id": g.user_id,
|
||||
"user_name": user_lookup.get(g.user_id),
|
||||
"start_date": g.start_date,
|
||||
"end_date": g.end_date,
|
||||
"notes": g.notes
|
||||
}
|
||||
for g in guarantees
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{office_id}/guarantees")
|
||||
def add_office_guarantee(office_id: str, data: GuaranteeCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Add parking guarantee for an office"""
|
||||
check_office_access(user, office_id)
|
||||
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
if not db.query(User).filter(User.id == data.user_id).first():
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
existing = db.query(ParkingGuarantee).filter(
|
||||
ParkingGuarantee.office_id == office_id,
|
||||
ParkingGuarantee.user_id == data.user_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
|
||||
|
||||
if data.start_date and data.end_date and data.end_date < data.start_date:
|
||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||
|
||||
guarantee = ParkingGuarantee(
|
||||
id=generate_uuid(),
|
||||
office_id=office_id,
|
||||
user_id=data.user_id,
|
||||
start_date=data.start_date,
|
||||
end_date=data.end_date,
|
||||
notes=data.notes,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(guarantee)
|
||||
db.commit()
|
||||
return {"id": guarantee.id, "message": "Guarantee added"}
|
||||
|
||||
|
||||
@router.delete("/{office_id}/guarantees/{guarantee_id}")
|
||||
def remove_office_guarantee(office_id: str, guarantee_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Remove parking guarantee for an office"""
|
||||
check_office_access(user, office_id)
|
||||
|
||||
guarantee = db.query(ParkingGuarantee).filter(
|
||||
ParkingGuarantee.id == guarantee_id,
|
||||
ParkingGuarantee.office_id == office_id
|
||||
).first()
|
||||
if not guarantee:
|
||||
raise HTTPException(status_code=404, detail="Guarantee not found")
|
||||
|
||||
db.delete(guarantee)
|
||||
db.commit()
|
||||
return {"message": "Guarantee removed"}
|
||||
|
||||
|
||||
# Exclusions
|
||||
@router.get("/{office_id}/exclusions")
|
||||
def get_office_exclusions(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get parking exclusions for an office"""
|
||||
check_office_access(user, office_id)
|
||||
|
||||
exclusions = db.query(ParkingExclusion).filter(ParkingExclusion.office_id == office_id).all()
|
||||
|
||||
# Batch query to get all user names at once
|
||||
user_ids = [e.user_id for e in exclusions]
|
||||
if user_ids:
|
||||
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||
user_lookup = {u.id: u.name for u in users}
|
||||
else:
|
||||
user_lookup = {}
|
||||
|
||||
return [
|
||||
{
|
||||
"id": e.id,
|
||||
"user_id": e.user_id,
|
||||
"user_name": user_lookup.get(e.user_id),
|
||||
"start_date": e.start_date,
|
||||
"end_date": e.end_date,
|
||||
"notes": e.notes
|
||||
}
|
||||
for e in exclusions
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{office_id}/exclusions")
|
||||
def add_office_exclusion(office_id: str, data: ExclusionCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Add parking exclusion for an office"""
|
||||
check_office_access(user, office_id)
|
||||
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
if not db.query(User).filter(User.id == data.user_id).first():
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
existing = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.office_id == office_id,
|
||||
ParkingExclusion.user_id == data.user_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
|
||||
|
||||
if data.start_date and data.end_date and data.end_date < data.start_date:
|
||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||
|
||||
exclusion = ParkingExclusion(
|
||||
id=generate_uuid(),
|
||||
office_id=office_id,
|
||||
user_id=data.user_id,
|
||||
start_date=data.start_date,
|
||||
end_date=data.end_date,
|
||||
notes=data.notes,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(exclusion)
|
||||
db.commit()
|
||||
return {"id": exclusion.id, "message": "Exclusion added"}
|
||||
|
||||
|
||||
@router.delete("/{office_id}/exclusions/{exclusion_id}")
|
||||
def remove_office_exclusion(office_id: str, exclusion_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Remove parking exclusion for an office"""
|
||||
check_office_access(user, office_id)
|
||||
|
||||
exclusion = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.id == exclusion_id,
|
||||
ParkingExclusion.office_id == office_id
|
||||
).first()
|
||||
if not exclusion:
|
||||
raise HTTPException(status_code=404, detail="Exclusion not found")
|
||||
|
||||
db.delete(exclusion)
|
||||
db.commit()
|
||||
return {"message": "Exclusion removed"}
|
||||
@@ -2,21 +2,33 @@
|
||||
Parking Management Routes
|
||||
Parking assignments, spot management, and pool initialization
|
||||
|
||||
Manager-centric model:
|
||||
- Managers own parking spots (defined by manager_parking_quota)
|
||||
- Spots are named with manager's letter prefix (A1, A2, B1, B2...)
|
||||
- Assignments reference manager_id directly
|
||||
"""
|
||||
"""
|
||||
Parking Management Routes
|
||||
Parking assignments, spot management, and pool initialization
|
||||
|
||||
Manager-centric model:
|
||||
- Managers own parking spots (defined by manager_parking_quota)
|
||||
- Spots are named with manager's letter prefix (A1, A2, B1, B2...)
|
||||
- Assignments reference manager_id directly
|
||||
"""
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import DailyParkingAssignment, User
|
||||
from database.models import DailyParkingAssignment, User, UserRole, Office
|
||||
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.parking import (
|
||||
initialize_parking_pool, get_spot_display_name, release_user_spot,
|
||||
run_batch_allocation, clear_assignments_for_office_date
|
||||
)
|
||||
from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned
|
||||
from app import config
|
||||
|
||||
@@ -25,14 +37,14 @@ router = APIRouter(prefix="/api/parking", tags=["parking"])
|
||||
|
||||
# Request/Response Models
|
||||
class InitPoolRequest(BaseModel):
|
||||
date: str # YYYY-MM-DD
|
||||
date: date
|
||||
|
||||
|
||||
class ManualAssignRequest(BaseModel):
|
||||
manager_id: str
|
||||
office_id: str
|
||||
user_id: str
|
||||
spot_id: str
|
||||
date: str
|
||||
date: date
|
||||
|
||||
|
||||
class ReassignSpotRequest(BaseModel):
|
||||
@@ -42,50 +54,57 @@ class ReassignSpotRequest(BaseModel):
|
||||
|
||||
class AssignmentResponse(BaseModel):
|
||||
id: str
|
||||
date: str
|
||||
date: date
|
||||
spot_id: str
|
||||
spot_display_name: str | None = None
|
||||
user_id: str | None
|
||||
manager_id: str
|
||||
office_id: str
|
||||
user_name: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
|
||||
class RunAllocationRequest(BaseModel):
|
||||
date: date
|
||||
office_id: str
|
||||
|
||||
|
||||
class ClearAssignmentsRequest(BaseModel):
|
||||
date: date
|
||||
office_id: str
|
||||
|
||||
|
||||
# Routes
|
||||
@router.post("/init-manager-pool")
|
||||
def init_manager_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Initialize parking pool for a manager on a given date"""
|
||||
try:
|
||||
datetime.strptime(request.date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format")
|
||||
@router.post("/init-office-pool")
|
||||
def init_office_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Initialize parking pool for an office on a given date"""
|
||||
pool_date = request.date
|
||||
|
||||
quota = current_user.manager_parking_quota or 0
|
||||
if quota == 0:
|
||||
return {"success": True, "message": "No parking quota configured", "spots": 0}
|
||||
if not current_user.office_id:
|
||||
raise HTTPException(status_code=400, detail="User does not belong to an office")
|
||||
|
||||
office = db.query(Office).filter(Office.id == current_user.office_id).first()
|
||||
if not office or not office.parking_quota:
|
||||
return {"success": True, "message": "No parking quota configured", "spots": 0}
|
||||
|
||||
spots = initialize_parking_pool(current_user.id, quota, request.date, db)
|
||||
spots = initialize_parking_pool(office.id, office.parking_quota, pool_date, db)
|
||||
return {"success": True, "spots": spots}
|
||||
|
||||
|
||||
@router.get("/assignments/{date}", response_model=List[AssignmentResponse])
|
||||
def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get parking assignments for a date, optionally filtered by manager"""
|
||||
try:
|
||||
datetime.strptime(date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format")
|
||||
@router.get("/assignments/{date_val}", response_model=List[AssignmentResponse])
|
||||
def get_assignments(date_val: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get parking assignments for a date, optionally filtered by office"""
|
||||
query_date = date_val
|
||||
|
||||
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == date)
|
||||
if manager_id:
|
||||
query = query.filter(DailyParkingAssignment.manager_id == manager_id)
|
||||
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date)
|
||||
if office_id:
|
||||
query = query.filter(DailyParkingAssignment.office_id == office_id)
|
||||
|
||||
assignments = query.all()
|
||||
results = []
|
||||
|
||||
for assignment in assignments:
|
||||
# Get display name using manager's spot prefix
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
||||
# Get display name using office's spot prefix
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||
|
||||
result = AssignmentResponse(
|
||||
id=assignment.id,
|
||||
@@ -93,7 +112,7 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
|
||||
spot_id=assignment.spot_id,
|
||||
spot_display_name=spot_display_name,
|
||||
user_id=assignment.user_id,
|
||||
manager_id=assignment.manager_id
|
||||
office_id=assignment.office_id
|
||||
)
|
||||
|
||||
if assignment.user_id:
|
||||
@@ -108,7 +127,7 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
|
||||
|
||||
|
||||
@router.get("/my-assignments", response_model=List[AssignmentResponse])
|
||||
def get_my_assignments(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
def get_my_assignments(start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get current user's parking assignments"""
|
||||
query = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.user_id == current_user.id
|
||||
@@ -123,7 +142,7 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
|
||||
results = []
|
||||
|
||||
for assignment in assignments:
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||
|
||||
results.append(AssignmentResponse(
|
||||
id=assignment.id,
|
||||
@@ -131,7 +150,7 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
|
||||
spot_id=assignment.spot_id,
|
||||
spot_display_name=spot_display_name,
|
||||
user_id=assignment.user_id,
|
||||
manager_id=assignment.manager_id,
|
||||
office_id=assignment.office_id,
|
||||
user_name=current_user.name,
|
||||
user_email=current_user.email
|
||||
))
|
||||
@@ -139,27 +158,55 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
|
||||
return results
|
||||
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/run-allocation")
|
||||
def run_fair_allocation(data: RunAllocationRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Manually trigger fair allocation for a date (Test Tool)"""
|
||||
# Verify office access
|
||||
if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized for this office")
|
||||
|
||||
result = run_batch_allocation(data.office_id, data.date, db)
|
||||
return {"message": "Allocation completed", "result": result}
|
||||
|
||||
|
||||
@router.post("/clear-assignments")
|
||||
def clear_assignments(data: ClearAssignmentsRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Clear all assignments for a date (Test Tool)"""
|
||||
# Verify office access
|
||||
if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized for this office")
|
||||
|
||||
count = clear_assignments_for_office_date(data.office_id, data.date, db)
|
||||
return {"message": "Assignments cleared", "count": count}
|
||||
|
||||
|
||||
@router.post("/manual-assign")
|
||||
def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Manually assign a spot to a user"""
|
||||
assign_date = data.date
|
||||
|
||||
# Verify user exists
|
||||
user = db.query(User).filter(User.id == data.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Verify manager exists and check permission
|
||||
manager = db.query(User).filter(User.id == data.manager_id, User.role == "manager").first()
|
||||
if not manager:
|
||||
raise HTTPException(status_code=404, detail="Manager not found")
|
||||
# Verify office exists
|
||||
office = db.query(Office).filter(Office.id == data.office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=404, detail="Office not found")
|
||||
|
||||
# Only admin or the manager themselves can assign spots
|
||||
if current_user.role != "admin" and current_user.id != data.manager_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this manager")
|
||||
# Only admin or the manager of that office can assign spots
|
||||
is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == data.office_id)
|
||||
if current_user.role != UserRole.ADMIN and not is_manager:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this office")
|
||||
|
||||
# Check if spot exists and is free
|
||||
spot = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.manager_id == data.manager_id,
|
||||
DailyParkingAssignment.date == data.date,
|
||||
DailyParkingAssignment.office_id == data.office_id,
|
||||
DailyParkingAssignment.date == assign_date,
|
||||
DailyParkingAssignment.spot_id == data.spot_id
|
||||
).first()
|
||||
|
||||
@@ -170,7 +217,7 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
|
||||
|
||||
# Check if user already has a spot for this date (from any manager)
|
||||
existing = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.date == data.date,
|
||||
DailyParkingAssignment.date == assign_date,
|
||||
DailyParkingAssignment.user_id == data.user_id
|
||||
).first()
|
||||
|
||||
@@ -180,7 +227,7 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
|
||||
spot.user_id = data.user_id
|
||||
db.commit()
|
||||
|
||||
spot_display_name = get_spot_display_name(data.spot_id, data.manager_id, db)
|
||||
spot_display_name = get_spot_display_name(data.spot_id, data.office_id, db)
|
||||
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name}
|
||||
|
||||
|
||||
@@ -198,7 +245,7 @@ def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_u
|
||||
raise HTTPException(status_code=403, detail="You can only release your own parking spot")
|
||||
|
||||
# Get spot display name for notification
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||
|
||||
assignment.user_id = None
|
||||
db.commit()
|
||||
@@ -223,9 +270,9 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Check permission: admin, manager who owns the spot, or current spot holder
|
||||
is_admin = current_user.role == 'admin'
|
||||
is_admin = current_user.role == UserRole.ADMIN
|
||||
is_spot_owner = assignment.user_id == current_user.id
|
||||
is_manager = current_user.id == assignment.manager_id
|
||||
is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == assignment.office_id)
|
||||
|
||||
if not (is_admin or is_manager or is_spot_owner):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to reassign this spot")
|
||||
@@ -235,9 +282,17 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
||||
old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None
|
||||
|
||||
# Get spot display name for notifications
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||
|
||||
if data.new_user_id:
|
||||
if data.new_user_id == "auto":
|
||||
# "Auto assign" means releasing the spot so the system picks the next person
|
||||
# release_user_spot returns True if it released it (and potentially reassigned it)
|
||||
success = release_user_spot(assignment.office_id, assignment.user_id, assignment.date, db)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Could not auto-reassign spot")
|
||||
return {"message": "Spot released for auto-assignment"}
|
||||
|
||||
elif data.new_user_id:
|
||||
# Check new user exists
|
||||
new_user = db.query(User).filter(User.id == data.new_user_id).first()
|
||||
if not new_user:
|
||||
@@ -275,7 +330,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
||||
db.refresh(assignment)
|
||||
|
||||
# Build response
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||
|
||||
result = AssignmentResponse(
|
||||
id=assignment.id,
|
||||
@@ -283,7 +338,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
||||
spot_id=assignment.spot_id,
|
||||
spot_display_name=spot_display_name,
|
||||
user_id=assignment.user_id,
|
||||
manager_id=assignment.manager_id
|
||||
office_id=assignment.office_id
|
||||
)
|
||||
|
||||
if assignment.user_id:
|
||||
@@ -308,16 +363,16 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Check permission: admin, manager who owns the spot, or current spot holder
|
||||
is_admin = current_user.role == 'admin'
|
||||
is_admin = current_user.role == UserRole.ADMIN
|
||||
is_spot_owner = assignment.user_id == current_user.id
|
||||
is_manager = current_user.id == assignment.manager_id
|
||||
is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == assignment.office_id)
|
||||
|
||||
if not (is_admin or is_manager or is_spot_owner):
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
# Get users in this manager's team (including the manager themselves)
|
||||
# Get users in this office (including the manager themselves)
|
||||
users = db.query(User).filter(
|
||||
(User.manager_id == assignment.manager_id) | (User.id == assignment.manager_id),
|
||||
User.office_id == assignment.office_id,
|
||||
User.id != assignment.user_id # Exclude current holder
|
||||
).all()
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ Presence Management Routes
|
||||
User presence marking and admin management
|
||||
"""
|
||||
from typing import List
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from datetime import datetime, timedelta, date
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import UserPresence, User, DailyParkingAssignment
|
||||
from database.models import UserPresence, User, DailyParkingAssignment, UserRole, PresenceStatus, Office
|
||||
from utils.auth_middleware import get_current_user, require_manager_or_admin
|
||||
from utils.helpers import generate_uuid
|
||||
from services.parking import handle_presence_change, get_spot_display_name
|
||||
@@ -20,38 +20,26 @@ router = APIRouter(prefix="/api/presence", tags=["presence"])
|
||||
|
||||
# Request/Response Models
|
||||
class PresenceMarkRequest(BaseModel):
|
||||
date: str # YYYY-MM-DD
|
||||
status: str # present, remote, absent
|
||||
date: date
|
||||
status: PresenceStatus
|
||||
|
||||
|
||||
class AdminPresenceMarkRequest(BaseModel):
|
||||
user_id: str
|
||||
date: str
|
||||
status: str
|
||||
date: date
|
||||
status: PresenceStatus
|
||||
|
||||
|
||||
class BulkPresenceRequest(BaseModel):
|
||||
start_date: str
|
||||
end_date: str
|
||||
status: str
|
||||
days: List[int] | None = None # Optional: [0,1,2,3,4] for Mon-Fri
|
||||
|
||||
|
||||
class AdminBulkPresenceRequest(BaseModel):
|
||||
user_id: str
|
||||
start_date: str
|
||||
end_date: str
|
||||
status: str
|
||||
days: List[int] | None = None
|
||||
|
||||
|
||||
class PresenceResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
date: str
|
||||
status: str
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
date: date
|
||||
status: PresenceStatus
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
parking_spot_number: str | None = None
|
||||
|
||||
class Config:
|
||||
@@ -59,51 +47,38 @@ class PresenceResponse(BaseModel):
|
||||
|
||||
|
||||
# Helper functions
|
||||
def validate_status(status: str):
|
||||
if status not in ["present", "remote", "absent"]:
|
||||
raise HTTPException(status_code=400, detail="Status must be: present, remote, or absent")
|
||||
|
||||
|
||||
def parse_date(date_str: str) -> datetime:
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
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":
|
||||
if current_user.role == UserRole.ADMIN:
|
||||
return True
|
||||
|
||||
if current_user.role == "manager":
|
||||
# Manager can access users they manage
|
||||
if target_user.manager_id == current_user.id:
|
||||
if current_user.role == UserRole.MANAGER:
|
||||
# Manager can access users in their Office
|
||||
if target_user.office_id == current_user.office_id:
|
||||
return True
|
||||
raise HTTPException(status_code=403, detail="User is not managed by you")
|
||||
raise HTTPException(status_code=403, detail="User is not in your office")
|
||||
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
def _mark_presence_for_user(
|
||||
user_id: str,
|
||||
date: str,
|
||||
status: str,
|
||||
presence_date: date,
|
||||
status: PresenceStatus,
|
||||
db: Session,
|
||||
target_user: User
|
||||
) -> UserPresence:
|
||||
"""
|
||||
Core presence marking logic - shared by user and admin routes.
|
||||
"""
|
||||
validate_status(status)
|
||||
parse_date(date)
|
||||
|
||||
existing = db.query(UserPresence).filter(
|
||||
UserPresence.user_id == user_id,
|
||||
UserPresence.date == date
|
||||
UserPresence.date == presence_date
|
||||
).first()
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
now = datetime.utcnow()
|
||||
old_status = existing.status if existing else None
|
||||
|
||||
if existing:
|
||||
@@ -116,7 +91,7 @@ def _mark_presence_for_user(
|
||||
presence = UserPresence(
|
||||
id=generate_uuid(),
|
||||
user_id=user_id,
|
||||
date=date,
|
||||
date=presence_date,
|
||||
status=status,
|
||||
created_at=now,
|
||||
updated_at=now
|
||||
@@ -125,114 +100,36 @@ def _mark_presence_for_user(
|
||||
db.commit()
|
||||
db.refresh(presence)
|
||||
|
||||
# Handle parking assignment
|
||||
# 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:
|
||||
# Handle parking assignment (if user is in an office)
|
||||
if target_user.office_id and old_status != status:
|
||||
try:
|
||||
handle_presence_change(
|
||||
user_id, date,
|
||||
old_status or "absent", status,
|
||||
parking_manager_id, db
|
||||
user_id, presence_date,
|
||||
old_status or PresenceStatus.ABSENT, status,
|
||||
target_user.office_id, db
|
||||
)
|
||||
except Exception as e:
|
||||
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}")
|
||||
config.logger.warning(f"Parking handler failed for user {user_id} on {presence_date}: {e}")
|
||||
|
||||
return presence
|
||||
|
||||
|
||||
def _bulk_mark_presence(
|
||||
user_id: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
status: str,
|
||||
days: List[int] | None,
|
||||
db: Session,
|
||||
target_user: User
|
||||
) -> List[UserPresence]:
|
||||
"""
|
||||
Core bulk presence marking logic - shared by user and admin routes.
|
||||
"""
|
||||
validate_status(status)
|
||||
start = parse_date(start_date)
|
||||
end = parse_date(end_date)
|
||||
|
||||
if end < start:
|
||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||
if (end - start).days > 90:
|
||||
raise HTTPException(status_code=400, detail="Range cannot exceed 90 days")
|
||||
|
||||
results = []
|
||||
current_date = start
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
while current_date <= end:
|
||||
if days is None or current_date.weekday() in days:
|
||||
date_str = current_date.strftime("%Y-%m-%d")
|
||||
|
||||
existing = db.query(UserPresence).filter(
|
||||
UserPresence.user_id == user_id,
|
||||
UserPresence.date == date_str
|
||||
).first()
|
||||
|
||||
old_status = existing.status if existing else None
|
||||
|
||||
if existing:
|
||||
existing.status = status
|
||||
existing.updated_at = now
|
||||
results.append(existing)
|
||||
else:
|
||||
presence = UserPresence(
|
||||
id=generate_uuid(),
|
||||
user_id=user_id,
|
||||
date=date_str,
|
||||
status=status,
|
||||
created_at=now,
|
||||
updated_at=now
|
||||
)
|
||||
db.add(presence)
|
||||
results.append(presence)
|
||||
|
||||
# Handle parking for each date
|
||||
# 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,
|
||||
parking_manager_id, db
|
||||
)
|
||||
except Exception as e:
|
||||
config.logger.warning(f"Parking handler failed for user {user_id} on {date_str}: {e}")
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
db.commit()
|
||||
return results
|
||||
|
||||
|
||||
def _delete_presence(
|
||||
user_id: str,
|
||||
date: str,
|
||||
presence_date: date,
|
||||
db: Session,
|
||||
target_user: User
|
||||
) -> dict:
|
||||
"""
|
||||
Core presence deletion logic - shared by user and admin routes.
|
||||
"""
|
||||
parse_date(date)
|
||||
|
||||
presence = db.query(UserPresence).filter(
|
||||
UserPresence.user_id == user_id,
|
||||
UserPresence.date == date
|
||||
UserPresence.date == presence_date
|
||||
).first()
|
||||
|
||||
if not presence:
|
||||
@@ -242,20 +139,15 @@ def _delete_presence(
|
||||
db.delete(presence)
|
||||
db.commit()
|
||||
|
||||
# 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:
|
||||
if target_user.office_id:
|
||||
try:
|
||||
handle_presence_change(
|
||||
user_id, date,
|
||||
old_status, "absent",
|
||||
parking_manager_id, db
|
||||
user_id, presence_date,
|
||||
old_status, PresenceStatus.ABSENT,
|
||||
target_user.office_id, db
|
||||
)
|
||||
except Exception as e:
|
||||
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}")
|
||||
config.logger.warning(f"Parking handler failed for user {user_id} on {presence_date}: {e}")
|
||||
|
||||
return {"message": "Presence deleted"}
|
||||
|
||||
@@ -267,34 +159,26 @@ def mark_presence(data: PresenceMarkRequest, db: Session = Depends(get_db), curr
|
||||
return _mark_presence_for_user(current_user.id, data.date, data.status, db, current_user)
|
||||
|
||||
|
||||
@router.post("/mark-bulk", response_model=List[PresenceResponse])
|
||||
def mark_bulk_presence(data: BulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Mark presence for a date range"""
|
||||
return _bulk_mark_presence(
|
||||
current_user.id, data.start_date, data.end_date,
|
||||
data.status, data.days, db, current_user
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/my-presences", response_model=List[PresenceResponse])
|
||||
def get_my_presences(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
def get_my_presences(start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get current user's presences"""
|
||||
query = db.query(UserPresence).filter(UserPresence.user_id == current_user.id)
|
||||
|
||||
if start_date:
|
||||
parse_date(start_date)
|
||||
query = query.filter(UserPresence.date >= start_date)
|
||||
if end_date:
|
||||
parse_date(end_date)
|
||||
query = query.filter(UserPresence.date <= end_date)
|
||||
|
||||
return query.order_by(UserPresence.date.desc()).all()
|
||||
|
||||
|
||||
@router.delete("/{date}")
|
||||
def delete_presence(date: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
@router.delete("/{date_val}")
|
||||
def delete_presence(date_val: date, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Delete presence for a date"""
|
||||
return _delete_presence(current_user.id, date, db, current_user)
|
||||
return _delete_presence(current_user.id, date_val, db, current_user)
|
||||
|
||||
|
||||
# Admin/Manager Routes
|
||||
@@ -309,66 +193,47 @@ def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(ge
|
||||
return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user)
|
||||
|
||||
|
||||
@router.post("/admin/mark-bulk", response_model=List[PresenceResponse])
|
||||
def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Bulk mark presence for any user (manager/admin)"""
|
||||
target_user = db.query(User).filter(User.id == data.user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/admin/{user_id}/{date}")
|
||||
def admin_delete_presence(user_id: str, date: str, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
|
||||
@router.delete("/admin/{user_id}/{date_val}")
|
||||
def admin_delete_presence(user_id: str, date_val: date, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Delete presence for any user (manager/admin)"""
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
check_manager_access(current_user, target_user, db)
|
||||
return _delete_presence(user_id, date, db, target_user)
|
||||
return _delete_presence(user_id, date_val, 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(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)
|
||||
def get_team_presences(start_date: date, end_date: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get office presences with parking info.
|
||||
- Admins can see all users (or filter by office_id)
|
||||
- Managers see their own office's users
|
||||
- Employees can see their own office's users (read-only view)
|
||||
"""
|
||||
parse_date(start_date)
|
||||
parse_date(end_date)
|
||||
|
||||
# Get users based on permissions and manager filter
|
||||
# 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()
|
||||
if current_user.role == UserRole.ADMIN:
|
||||
if office_id:
|
||||
users = db.query(User).filter(User.office_id == office_id).all()
|
||||
else:
|
||||
users = db.query(User).all()
|
||||
|
||||
elif current_user.office_id:
|
||||
# Non-admin users see their office members
|
||||
users = db.query(User).filter(User.office_id == current_user.office_id).all()
|
||||
|
||||
else:
|
||||
# Manager sees their team + themselves
|
||||
users = db.query(User).filter(
|
||||
(User.manager_id == current_user.id) | (User.id == current_user.id)
|
||||
).all()
|
||||
# No office assigned
|
||||
return []
|
||||
|
||||
# Batch query presences and parking for all users
|
||||
# Batch query presences and parking for all selected users
|
||||
user_ids = [u.id for u in users]
|
||||
if not user_ids:
|
||||
return []
|
||||
|
||||
presences = db.query(UserPresence).filter(
|
||||
UserPresence.user_id.in_(user_ids),
|
||||
UserPresence.date >= start_date,
|
||||
@@ -389,7 +254,7 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
|
||||
parking_lookup[p.user_id] = []
|
||||
parking_info_lookup[p.user_id] = []
|
||||
parking_lookup[p.user_id].append(p.date)
|
||||
spot_display_name = get_spot_display_name(p.spot_id, p.manager_id, db)
|
||||
spot_display_name = get_spot_display_name(p.spot_id, p.office_id, db)
|
||||
parking_info_lookup[p.user_id].append({
|
||||
"id": p.id,
|
||||
"date": p.date,
|
||||
@@ -397,10 +262,10 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
|
||||
"spot_display_name": spot_display_name
|
||||
})
|
||||
|
||||
# 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 office lookup for display (replacing old manager_lookup)
|
||||
office_ids = list(set(u.office_id for u in users if u.office_id))
|
||||
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
|
||||
office_lookup = {o.id: o.name for o in offices}
|
||||
|
||||
# Build response
|
||||
result = []
|
||||
@@ -410,8 +275,8 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"manager_id": user.manager_id,
|
||||
"manager_name": manager_lookup.get(user.manager_id),
|
||||
"office_id": user.office_id,
|
||||
"office_name": office_lookup.get(user.office_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, [])
|
||||
@@ -421,7 +286,7 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
|
||||
|
||||
|
||||
@router.get("/admin/{user_id}")
|
||||
def get_user_presences(user_id: str, start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
def get_user_presences(user_id: str, start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Get any user's presences with parking info (manager/admin)"""
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
@@ -432,24 +297,23 @@ def get_user_presences(user_id: str, start_date: str = None, end_date: str = Non
|
||||
query = db.query(UserPresence).filter(UserPresence.user_id == user_id)
|
||||
|
||||
if start_date:
|
||||
parse_date(start_date)
|
||||
query = query.filter(UserPresence.date >= start_date)
|
||||
if end_date:
|
||||
parse_date(end_date)
|
||||
query = query.filter(UserPresence.date <= end_date)
|
||||
|
||||
presences = query.order_by(UserPresence.date.desc()).all()
|
||||
|
||||
# Batch query parking assignments
|
||||
date_strs = [p.date for p in presences]
|
||||
dates = [p.date for p in presences]
|
||||
parking_map = {}
|
||||
if date_strs:
|
||||
if dates:
|
||||
# Note: Assignments link to user. We can find spot display name by looking up assignment -> office
|
||||
assignments = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.user_id == user_id,
|
||||
DailyParkingAssignment.date.in_(date_strs)
|
||||
DailyParkingAssignment.date.in_(dates)
|
||||
).all()
|
||||
for a in assignments:
|
||||
parking_map[a.date] = get_spot_display_name(a.spot_id, a.manager_id, db)
|
||||
parking_map[a.date] = get_spot_display_name(a.spot_id, a.office_id, db)
|
||||
|
||||
# Build response
|
||||
result = []
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import User
|
||||
from database.models import User, UserRole, Office
|
||||
from utils.auth_middleware import get_current_user, require_admin
|
||||
from utils.helpers import (
|
||||
generate_uuid, is_ldap_user, is_ldap_admin,
|
||||
@@ -25,16 +25,14 @@ class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: str | None = None
|
||||
role: str = "employee"
|
||||
manager_id: str | None = None
|
||||
role: UserRole = UserRole.EMPLOYEE
|
||||
office_id: str | None = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
role: str | None = None
|
||||
manager_id: str | None = None
|
||||
manager_parking_quota: int | None = None
|
||||
manager_spot_prefix: str | None = None
|
||||
role: UserRole | None = None
|
||||
office_id: str | None = None
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
@@ -44,11 +42,11 @@ class ProfileUpdate(BaseModel):
|
||||
class SettingsUpdate(BaseModel):
|
||||
week_start_day: int | None = None
|
||||
# Notification preferences
|
||||
notify_weekly_parking: int | None = None
|
||||
notify_daily_parking: int | None = None
|
||||
notify_weekly_parking: bool | None = None
|
||||
notify_daily_parking: bool | None = None
|
||||
notify_daily_parking_hour: int | None = None
|
||||
notify_daily_parking_minute: int | None = None
|
||||
notify_parking_changes: int | None = None
|
||||
notify_parking_changes: bool | None = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
@@ -60,61 +58,54 @@ class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str | None
|
||||
role: str
|
||||
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
|
||||
role: UserRole
|
||||
office_id: str | None = None
|
||||
office_name: str | None = None
|
||||
is_ldap_user: bool = False
|
||||
is_ldap_admin: bool = False
|
||||
created_at: str | None
|
||||
parking_ratio: float | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
def user_to_response(user: User, db: Session, manager_lookup: dict = None, managed_counts: dict = None) -> dict:
|
||||
def user_to_response(user: User, db: Session, office_lookup: dict = None) -> dict:
|
||||
"""
|
||||
Convert user to response dict with computed fields.
|
||||
|
||||
Args:
|
||||
user: The user to convert
|
||||
db: Database session
|
||||
manager_lookup: Optional pre-fetched dict of manager_id -> name (for batch operations)
|
||||
managed_counts: Optional pre-fetched dict of user_id -> managed_user_count (for batch operations)
|
||||
"""
|
||||
# Get manager name - use lookup if available, otherwise query
|
||||
manager_name = None
|
||||
if user.manager_id:
|
||||
if manager_lookup is not None:
|
||||
manager_name = manager_lookup.get(user.manager_id)
|
||||
# Get office name - use lookup if available, otherwise query
|
||||
office_name = None
|
||||
if user.office_id:
|
||||
if office_lookup is not None:
|
||||
office_name = office_lookup.get(user.office_id)
|
||||
else:
|
||||
manager = db.query(User).filter(User.id == user.manager_id).first()
|
||||
if manager:
|
||||
manager_name = manager.name
|
||||
office = db.query(Office).filter(Office.id == user.office_id).first()
|
||||
if office:
|
||||
office_name = office.name
|
||||
|
||||
# Count managed users if this user is a manager
|
||||
managed_user_count = None
|
||||
if user.role == "manager":
|
||||
if managed_counts is not None:
|
||||
managed_user_count = managed_counts.get(user.id, 0)
|
||||
else:
|
||||
managed_user_count = db.query(User).filter(User.manager_id == user.id).count()
|
||||
# Calculate parking ratio (score)
|
||||
parking_ratio = None
|
||||
if user.office_id:
|
||||
try:
|
||||
# Avoid circular import by importing inside function if needed,
|
||||
# or ensure services.parking doesn't import this file.
|
||||
from services.parking import get_user_parking_ratio
|
||||
parking_ratio = get_user_parking_ratio(user.id, user.office_id, db)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
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,
|
||||
"office_id": user.office_id,
|
||||
"office_name": office_name,
|
||||
"is_ldap_user": is_ldap_user(user),
|
||||
"is_ldap_admin": is_ldap_admin(user),
|
||||
"created_at": user.created_at
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"parking_ratio": parking_ratio
|
||||
}
|
||||
|
||||
|
||||
@@ -125,23 +116,12 @@ def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
users = db.query(User).all()
|
||||
|
||||
# Build lookups to avoid N+1 queries
|
||||
# Manager lookup: id -> name
|
||||
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}
|
||||
# Office lookup: id -> name
|
||||
office_ids = list(set(u.office_id for u in users if u.office_id))
|
||||
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
|
||||
office_lookup = {o.id: o.name for o in offices}
|
||||
|
||||
# Managed user counts for managers
|
||||
from sqlalchemy import func
|
||||
manager_user_ids = [u.id for u in users if u.role == "manager"]
|
||||
if manager_user_ids:
|
||||
counts = db.query(User.manager_id, func.count(User.id)).filter(
|
||||
User.manager_id.in_(manager_user_ids)
|
||||
).group_by(User.manager_id).all()
|
||||
managed_counts = {manager_id: count for manager_id, count in counts}
|
||||
else:
|
||||
managed_counts = {}
|
||||
|
||||
return [user_to_response(u, db, manager_lookup, managed_counts) for u in users]
|
||||
return [user_to_response(u, db, office_lookup) for u in users]
|
||||
|
||||
|
||||
@router.get("/{user_id}")
|
||||
@@ -162,18 +142,17 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
|
||||
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")
|
||||
# Role validation handled by Pydantic Enum
|
||||
|
||||
# Validate password strength
|
||||
password_errors = validate_password(data.password)
|
||||
if password_errors:
|
||||
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
|
||||
|
||||
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.office_id:
|
||||
office = db.query(Office).filter(Office.id == data.office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=400, detail="Invalid office")
|
||||
|
||||
new_user = User(
|
||||
id=generate_uuid(),
|
||||
@@ -181,8 +160,8 @@ 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,
|
||||
manager_id=data.manager_id,
|
||||
created_at=datetime.utcnow().isoformat()
|
||||
office_id=data.office_id,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
@@ -211,54 +190,20 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
|
||||
|
||||
# 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 target_is_ldap_admin and data.role != "admin":
|
||||
if target_is_ldap_admin and data.role != UserRole.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
|
||||
|
||||
# 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
|
||||
# Office assignment
|
||||
if "office_id" in data.__fields_set__:
|
||||
if data.office_id:
|
||||
office = db.query(Office).filter(Office.id == data.office_id).first()
|
||||
if not office:
|
||||
raise HTTPException(status_code=400, detail="Invalid office")
|
||||
target.office_id = data.office_id if data.office_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")
|
||||
target.manager_parking_quota = data.manager_parking_quota
|
||||
|
||||
if data.manager_spot_prefix is not None:
|
||||
if target.role != "manager":
|
||||
raise HTTPException(status_code=400, detail="Spot prefix only for managers")
|
||||
prefix = data.manager_spot_prefix.upper() if data.manager_spot_prefix else None
|
||||
if prefix and not prefix.isalpha():
|
||||
raise HTTPException(status_code=400, detail="Spot prefix must be a letter")
|
||||
# Check for duplicate prefix
|
||||
if prefix:
|
||||
existing = db.query(User).filter(
|
||||
User.manager_spot_prefix == prefix,
|
||||
User.id != user_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"Spot prefix '{prefix}' is already used by another manager")
|
||||
target.manager_spot_prefix = prefix
|
||||
|
||||
target.updated_at = datetime.utcnow().isoformat()
|
||||
target.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
return user_to_response(target, db)
|
||||
@@ -274,12 +219,6 @@ 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"}
|
||||
@@ -289,20 +228,20 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend
|
||||
@router.get("/me/profile")
|
||||
def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get current user's profile"""
|
||||
# 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
|
||||
# Get office name
|
||||
office_name = None
|
||||
if current_user.office_id:
|
||||
office = db.query(Office).filter(Office.id == current_user.office_id).first()
|
||||
if office:
|
||||
office_name = office.name
|
||||
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"email": current_user.email,
|
||||
"name": current_user.name,
|
||||
"role": current_user.role,
|
||||
"manager_id": current_user.manager_id,
|
||||
"manager_name": manager_name,
|
||||
"office_id": current_user.office_id,
|
||||
"office_name": office_name,
|
||||
"is_ldap_user": is_ldap_user(current_user)
|
||||
}
|
||||
|
||||
@@ -314,7 +253,7 @@ def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_u
|
||||
if is_ldap_user(current_user):
|
||||
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
|
||||
current_user.name = data.name
|
||||
current_user.updated_at = datetime.utcnow().isoformat()
|
||||
current_user.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return {"message": "Profile updated"}
|
||||
@@ -325,11 +264,11 @@ def get_settings(current_user=Depends(get_current_user)):
|
||||
"""Get current user's settings"""
|
||||
return {
|
||||
"week_start_day": get_notification_default(current_user.week_start_day, 0),
|
||||
"notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, 1),
|
||||
"notify_daily_parking": get_notification_default(current_user.notify_daily_parking, 1),
|
||||
"notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, True),
|
||||
"notify_daily_parking": get_notification_default(current_user.notify_daily_parking, True),
|
||||
"notify_daily_parking_hour": get_notification_default(current_user.notify_daily_parking_hour, 8),
|
||||
"notify_daily_parking_minute": get_notification_default(current_user.notify_daily_parking_minute, 0),
|
||||
"notify_parking_changes": get_notification_default(current_user.notify_parking_changes, 1)
|
||||
"notify_parking_changes": get_notification_default(current_user.notify_parking_changes, True)
|
||||
}
|
||||
|
||||
|
||||
@@ -337,8 +276,8 @@ def get_settings(current_user=Depends(get_current_user)):
|
||||
def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Update current user's settings"""
|
||||
if data.week_start_day is not None:
|
||||
if data.week_start_day not in [0, 1]:
|
||||
raise HTTPException(status_code=400, detail="Week start must be 0 (Sunday) or 1 (Monday)")
|
||||
if data.week_start_day not in [0, 6]:
|
||||
raise HTTPException(status_code=400, detail="Week start must be 0 (Monday) or 6 (Sunday)")
|
||||
current_user.week_start_day = data.week_start_day
|
||||
|
||||
# Notification preferences
|
||||
@@ -361,7 +300,7 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current
|
||||
if data.notify_parking_changes is not None:
|
||||
current_user.notify_parking_changes = data.notify_parking_changes
|
||||
|
||||
current_user.updated_at = datetime.utcnow().isoformat()
|
||||
current_user.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return {
|
||||
"message": "Settings updated",
|
||||
@@ -389,7 +328,7 @@ def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db),
|
||||
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
|
||||
|
||||
current_user.password_hash = hash_password(data.new_password)
|
||||
current_user.updated_at = datetime.utcnow().isoformat()
|
||||
current_user.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
config.logger.info(f"User {current_user.email} changed password")
|
||||
return {"message": "Password changed"}
|
||||
|
||||
Reference in New Issue
Block a user