Primo commit

This commit is contained in:
2026-01-13 11:20:12 +01:00
parent ce9e2fdf2a
commit 17453f5d13
51 changed files with 3883 additions and 2508 deletions

View File

@@ -7,6 +7,10 @@ import sys
import logging
from pathlib import Path
# Paths
BASE_DIR = Path(__file__).resolve().parent.parent
FRONTEND_DIR = BASE_DIR / "frontend"
# Configure logging
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
@@ -17,6 +21,19 @@ logger = logging.getLogger("org-parking")
# Database
DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db")
# Fix for local execution: if path is absolute (docker) but dir doesn't exist, fallback to local data/
if os.path.isabs(DATABASE_PATH) and not os.path.exists(os.path.dirname(DATABASE_PATH)):
# Check if we are aiming for /app/data but running locally
if str(DATABASE_PATH).startswith("/app/") or not os.access(os.path.dirname(DATABASE_PATH), os.W_OK):
logger.warning(f"Configured DATABASE_PATH '{DATABASE_PATH}' folder not found/writable. Switching to local 'data' directory.")
local_data_dir = BASE_DIR / "data"
local_data_dir.mkdir(exist_ok=True)
DATABASE_PATH = str(local_data_dir / os.path.basename(DATABASE_PATH))
logger.info(f"Using local database path: {DATABASE_PATH}")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}")
# JWT Authentication
@@ -35,7 +52,7 @@ HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000"))
# CORS
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",")
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",")
# Authelia Integration
AUTHELIA_ENABLED = os.getenv("AUTHELIA_ENABLED", "false").lower() == "true"
@@ -67,6 +84,4 @@ EMAIL_LOG_FILE = os.getenv("EMAIL_LOG_FILE", "/tmp/parking-emails.log")
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "5")) # requests per window
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds
# Paths
BASE_DIR = Path(__file__).resolve().parent.parent
FRONTEND_DIR = BASE_DIR / "frontend"

View File

@@ -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)
)

View File

@@ -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
View 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"}

View File

@@ -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()

View File

@@ -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 = []

View File

@@ -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"}