fix landing page
This commit is contained in:
@@ -3,16 +3,32 @@ Application Configuration
|
||||
Environment-based settings with sensible defaults
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Configure logging
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger("org-parking")
|
||||
|
||||
# Database
|
||||
DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db")
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}")
|
||||
|
||||
# JWT Authentication
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "")
|
||||
if not SECRET_KEY:
|
||||
logger.error("FATAL: SECRET_KEY environment variable is required")
|
||||
sys.exit(1)
|
||||
if SECRET_KEY == "change-me-in-production":
|
||||
logger.warning("WARNING: Using default SECRET_KEY - change this in production!")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 hours
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours
|
||||
|
||||
# Server
|
||||
HOST = os.getenv("HOST", "0.0.0.0")
|
||||
@@ -32,12 +48,24 @@ AUTHELIA_HEADER_GROUPS = os.getenv("AUTHELIA_HEADER_GROUPS", "Remote-Groups")
|
||||
# Manager role and user assignments are managed by admin in the app UI
|
||||
AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins")
|
||||
|
||||
# Email (optional)
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
||||
# External URLs for Authelia mode
|
||||
# When AUTHELIA_ENABLED, login redirects to Authelia and register to external portal
|
||||
AUTHELIA_LOGIN_URL = os.getenv("AUTHELIA_LOGIN_URL", "") # e.g., https://auth.rocketscale.it
|
||||
REGISTRATION_URL = os.getenv("REGISTRATION_URL", "") # e.g., https://register.rocketscale.it
|
||||
|
||||
# Email configuration (following org-stack pattern)
|
||||
SMTP_ENABLED = os.getenv("SMTP_ENABLED", "false").lower() == "true"
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USER)
|
||||
SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER or "noreply@parking.local")
|
||||
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
|
||||
EMAIL_LOG_FILE = os.getenv("EMAIL_LOG_FILE", "/tmp/parking-emails.log")
|
||||
|
||||
# Rate limiting
|
||||
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
|
||||
|
||||
@@ -2,20 +2,23 @@
|
||||
Authentication Routes
|
||||
Login, register, logout, and user info
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Response, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from database.connection import get_db
|
||||
from services.auth import (
|
||||
create_user, authenticate_user, create_access_token,
|
||||
get_user_by_email, hash_password, verify_password
|
||||
get_user_by_email
|
||||
)
|
||||
from utils.auth_middleware import get_current_user
|
||||
from utils.helpers import validate_password, format_password_errors, get_notification_default
|
||||
from app import config
|
||||
import re
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
@@ -52,7 +55,8 @@ class UserResponse(BaseModel):
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
def register(data: RegisterRequest, db: Session = Depends(get_db)):
|
||||
@limiter.limit(f"{config.RATE_LIMIT_REQUESTS}/minute")
|
||||
def register(request: Request, data: RegisterRequest, db: Session = Depends(get_db)):
|
||||
"""Register a new user"""
|
||||
if get_user_by_email(db, data.email):
|
||||
raise HTTPException(
|
||||
@@ -60,10 +64,12 @@ def register(data: RegisterRequest, db: Session = Depends(get_db)):
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
if len(data.password) < 8:
|
||||
# Validate password strength
|
||||
password_errors = validate_password(data.password)
|
||||
if password_errors:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password must be at least 8 characters"
|
||||
detail=format_password_errors(password_errors)
|
||||
)
|
||||
|
||||
user = create_user(
|
||||
@@ -74,16 +80,19 @@ def register(data: RegisterRequest, db: Session = Depends(get_db)):
|
||||
manager_id=data.manager_id
|
||||
)
|
||||
|
||||
config.logger.info(f"New user registered: {data.email}")
|
||||
token = create_access_token(user.id, user.email)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(data: LoginRequest, response: Response, db: Session = Depends(get_db)):
|
||||
@limiter.limit(f"{config.RATE_LIMIT_REQUESTS}/minute")
|
||||
def login(request: Request, data: LoginRequest, response: Response, db: Session = Depends(get_db)):
|
||||
"""Login with email and password"""
|
||||
user = authenticate_user(db, data.email, data.password)
|
||||
|
||||
if not user:
|
||||
config.logger.warning(f"Failed login attempt for: {data.email}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials"
|
||||
@@ -99,6 +108,7 @@ def login(data: LoginRequest, response: Response, db: Session = Depends(get_db))
|
||||
samesite="lax"
|
||||
)
|
||||
|
||||
config.logger.info(f"User logged in: {data.email}")
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@@ -119,15 +129,27 @@ def get_me(user=Depends(get_current_user)):
|
||||
manager_id=user.manager_id,
|
||||
role=user.role,
|
||||
manager_parking_quota=user.manager_parking_quota,
|
||||
week_start_day=user.week_start_day or 0,
|
||||
notify_weekly_parking=user.notify_weekly_parking if user.notify_weekly_parking is not None else 1,
|
||||
notify_daily_parking=user.notify_daily_parking if user.notify_daily_parking is not None else 1,
|
||||
notify_daily_parking_hour=user.notify_daily_parking_hour if user.notify_daily_parking_hour is not None else 8,
|
||||
notify_daily_parking_minute=user.notify_daily_parking_minute if user.notify_daily_parking_minute is not None else 0,
|
||||
notify_parking_changes=user.notify_parking_changes if user.notify_parking_changes is not None else 1
|
||||
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_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)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def get_auth_config():
|
||||
"""Get authentication configuration for frontend.
|
||||
Returns info about auth mode and external URLs.
|
||||
"""
|
||||
return {
|
||||
"authelia_enabled": config.AUTHELIA_ENABLED,
|
||||
"login_url": config.AUTHELIA_LOGIN_URL if config.AUTHELIA_ENABLED else None,
|
||||
"registration_url": config.REGISTRATION_URL if config.AUTHELIA_ENABLED else None
|
||||
}
|
||||
|
||||
|
||||
@router.get("/holidays/{year}")
|
||||
def get_holidays(year: int):
|
||||
"""Get public holidays for a given year"""
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
from sqlalchemy import func
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import (
|
||||
@@ -18,6 +18,8 @@ from database.models import (
|
||||
ParkingGuarantee, ParkingExclusion
|
||||
)
|
||||
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/managers", tags=["managers"])
|
||||
|
||||
@@ -54,21 +56,28 @@ class ManagerSettingsUpdate(BaseModel):
|
||||
def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get all managers with their managed user count and parking quota"""
|
||||
managers = db.query(User).filter(User.role == "manager").all()
|
||||
result = []
|
||||
|
||||
for manager in managers:
|
||||
managed_user_count = db.query(User).filter(User.manager_id == manager.id).count()
|
||||
# Batch query to get managed user counts for all managers at once
|
||||
manager_ids = [m.id for m in managers]
|
||||
if manager_ids:
|
||||
counts = db.query(User.manager_id, func.count(User.id)).filter(
|
||||
User.manager_id.in_(manager_ids)
|
||||
).group_by(User.manager_id).all()
|
||||
managed_counts = {manager_id: count for manager_id, count in counts}
|
||||
else:
|
||||
managed_counts = {}
|
||||
|
||||
result.append({
|
||||
return [
|
||||
{
|
||||
"id": manager.id,
|
||||
"name": manager.name,
|
||||
"email": manager.email,
|
||||
"parking_quota": manager.manager_parking_quota or 0,
|
||||
"spot_prefix": manager.manager_spot_prefix,
|
||||
"managed_user_count": managed_user_count
|
||||
})
|
||||
|
||||
return result
|
||||
"managed_user_count": managed_counts.get(manager.id, 0)
|
||||
}
|
||||
for manager in managers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{manager_id}")
|
||||
@@ -164,7 +173,7 @@ def add_manager_closing_day(manager_id: str, data: ClosingDayCreate, db: Session
|
||||
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
|
||||
|
||||
closing_day = ManagerClosingDay(
|
||||
id=str(uuid.uuid4()),
|
||||
id=generate_uuid(),
|
||||
manager_id=manager_id,
|
||||
date=data.date,
|
||||
reason=data.reason
|
||||
@@ -217,7 +226,7 @@ def add_manager_weekly_closing_day(manager_id: str, data: WeeklyClosingDayCreate
|
||||
raise HTTPException(status_code=400, detail="Weekly closing day already exists for this weekday")
|
||||
|
||||
weekly_closing = ManagerWeeklyClosingDay(
|
||||
id=str(uuid.uuid4()),
|
||||
id=generate_uuid(),
|
||||
manager_id=manager_id,
|
||||
weekday=data.weekday
|
||||
)
|
||||
@@ -246,17 +255,25 @@ def remove_manager_weekly_closing_day(manager_id: str, weekly_id: str, db: Sessi
|
||||
def get_manager_guarantees(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get parking guarantees for a manager"""
|
||||
guarantees = db.query(ParkingGuarantee).filter(ParkingGuarantee.manager_id == manager_id).all()
|
||||
result = []
|
||||
for g in guarantees:
|
||||
target_user = db.query(User).filter(User.id == g.user_id).first()
|
||||
result.append({
|
||||
|
||||
# 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": target_user.name if target_user else None,
|
||||
"user_name": user_lookup.get(g.user_id),
|
||||
"start_date": g.start_date,
|
||||
"end_date": g.end_date
|
||||
})
|
||||
return result
|
||||
}
|
||||
for g in guarantees
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{manager_id}/guarantees")
|
||||
@@ -276,7 +293,7 @@ def add_manager_guarantee(manager_id: str, data: GuaranteeCreate, db: Session =
|
||||
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
|
||||
|
||||
guarantee = ParkingGuarantee(
|
||||
id=str(uuid.uuid4()),
|
||||
id=generate_uuid(),
|
||||
manager_id=manager_id,
|
||||
user_id=data.user_id,
|
||||
start_date=data.start_date,
|
||||
@@ -308,17 +325,25 @@ def remove_manager_guarantee(manager_id: str, guarantee_id: str, db: Session = D
|
||||
def get_manager_exclusions(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
|
||||
"""Get parking exclusions for a manager"""
|
||||
exclusions = db.query(ParkingExclusion).filter(ParkingExclusion.manager_id == manager_id).all()
|
||||
result = []
|
||||
for e in exclusions:
|
||||
target_user = db.query(User).filter(User.id == e.user_id).first()
|
||||
result.append({
|
||||
|
||||
# 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": target_user.name if target_user else None,
|
||||
"user_name": user_lookup.get(e.user_id),
|
||||
"start_date": e.start_date,
|
||||
"end_date": e.end_date
|
||||
})
|
||||
return result
|
||||
}
|
||||
for e in exclusions
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{manager_id}/exclusions")
|
||||
@@ -338,7 +363,7 @@ def add_manager_exclusion(manager_id: str, data: ExclusionCreate, db: Session =
|
||||
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
|
||||
|
||||
exclusion = ParkingExclusion(
|
||||
id=str(uuid.uuid4()),
|
||||
id=generate_uuid(),
|
||||
manager_id=manager_id,
|
||||
user_id=data.user_id,
|
||||
start_date=data.start_date,
|
||||
|
||||
@@ -12,13 +12,13 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import DailyParkingAssignment, User
|
||||
from utils.auth_middleware import get_current_user, require_manager_or_admin
|
||||
from services.parking import initialize_parking_pool, get_spot_display_name
|
||||
from services.notifications import queue_parking_change_notification
|
||||
from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned
|
||||
from app import config
|
||||
|
||||
router = APIRouter(prefix="/api/parking", tags=["parking"])
|
||||
|
||||
@@ -203,12 +203,10 @@ def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_u
|
||||
assignment.user_id = None
|
||||
db.commit()
|
||||
|
||||
# Queue notification (self-release, so just confirmation)
|
||||
queue_parking_change_notification(
|
||||
current_user, assignment.date, "released",
|
||||
spot_display_name, db=db
|
||||
)
|
||||
# Send notification (self-release, so just confirmation)
|
||||
notify_parking_released(current_user, assignment.date, spot_display_name)
|
||||
|
||||
config.logger.info(f"User {current_user.email} released parking spot {spot_display_name} on {assignment.date}")
|
||||
return {"message": "Parking spot released"}
|
||||
|
||||
|
||||
@@ -257,26 +255,21 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
||||
|
||||
assignment.user_id = data.new_user_id
|
||||
|
||||
# Queue notifications
|
||||
# Send notifications
|
||||
# Notify old user that spot was reassigned
|
||||
if old_user and old_user.id != new_user.id:
|
||||
queue_parking_change_notification(
|
||||
old_user, assignment.date, "reassigned",
|
||||
spot_display_name, new_user.name, db
|
||||
)
|
||||
notify_parking_reassigned(old_user, assignment.date, spot_display_name, new_user.name)
|
||||
# Notify new user that spot was assigned
|
||||
queue_parking_change_notification(
|
||||
new_user, assignment.date, "assigned",
|
||||
spot_display_name, db=db
|
||||
)
|
||||
notify_parking_assigned(new_user, assignment.date, spot_display_name)
|
||||
|
||||
config.logger.info(f"Parking spot {spot_display_name} on {assignment.date} reassigned from {old_user.email if old_user else 'unassigned'} to {new_user.email}")
|
||||
else:
|
||||
assignment.user_id = None
|
||||
# Notify old user that spot was released
|
||||
if old_user:
|
||||
queue_parking_change_notification(
|
||||
old_user, assignment.date, "released",
|
||||
spot_display_name, db=db
|
||||
)
|
||||
notify_parking_released(old_user, assignment.date, spot_display_name)
|
||||
|
||||
config.logger.info(f"Parking spot {spot_display_name} on {assignment.date} released by {old_user.email if old_user else 'unknown'}")
|
||||
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
|
||||
@@ -7,12 +7,13 @@ from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import UserPresence, User, DailyParkingAssignment
|
||||
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
|
||||
from app import config
|
||||
|
||||
router = APIRouter(prefix="/api/presence", tags=["presence"])
|
||||
|
||||
@@ -113,7 +114,7 @@ def _mark_presence_for_user(
|
||||
presence = existing
|
||||
else:
|
||||
presence = UserPresence(
|
||||
id=str(uuid.uuid4()),
|
||||
id=generate_uuid(),
|
||||
user_id=user_id,
|
||||
date=date,
|
||||
status=status,
|
||||
@@ -139,7 +140,7 @@ def _mark_presence_for_user(
|
||||
parking_manager_id, db
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Warning: Parking handler failed: {e}")
|
||||
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}")
|
||||
|
||||
return presence
|
||||
|
||||
@@ -186,7 +187,7 @@ def _bulk_mark_presence(
|
||||
results.append(existing)
|
||||
else:
|
||||
presence = UserPresence(
|
||||
id=str(uuid.uuid4()),
|
||||
id=generate_uuid(),
|
||||
user_id=user_id,
|
||||
date=date_str,
|
||||
status=status,
|
||||
@@ -209,8 +210,8 @@ def _bulk_mark_presence(
|
||||
old_status or "absent", status,
|
||||
parking_manager_id, db
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
config.logger.warning(f"Parking handler failed for user {user_id} on {date_str}: {e}")
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
@@ -253,8 +254,8 @@ def _delete_presence(
|
||||
old_status, "absent",
|
||||
parking_manager_id, db
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}")
|
||||
|
||||
return {"message": "Presence deleted"}
|
||||
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
User Management Routes
|
||||
Admin user CRUD and user self-service (profile, settings, password)
|
||||
"""
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
import re
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import User
|
||||
from utils.auth_middleware import get_current_user, require_admin
|
||||
from utils.helpers import (
|
||||
generate_uuid, is_ldap_user, is_ldap_admin,
|
||||
validate_password, format_password_errors, get_notification_default
|
||||
)
|
||||
from services.auth import hash_password, verify_password
|
||||
from app import config
|
||||
|
||||
@@ -73,23 +74,33 @@ class UserResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
def user_to_response(user: User, db: Session) -> dict:
|
||||
"""Convert user to response dict with computed fields"""
|
||||
# Get manager name if user has a manager
|
||||
def user_to_response(user: User, db: Session, manager_lookup: dict = None, managed_counts: 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:
|
||||
manager = db.query(User).filter(User.id == user.manager_id).first()
|
||||
if manager:
|
||||
manager_name = manager.name
|
||||
if manager_lookup is not None:
|
||||
manager_name = manager_lookup.get(user.manager_id)
|
||||
else:
|
||||
manager = db.query(User).filter(User.id == user.manager_id).first()
|
||||
if manager:
|
||||
manager_name = manager.name
|
||||
|
||||
# Count managed users if this user is a manager
|
||||
managed_user_count = None
|
||||
if user.role == "manager":
|
||||
managed_user_count = db.query(User).filter(User.manager_id == user.id).count()
|
||||
|
||||
# Determine if user is LDAP-managed
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and user.password_hash is None
|
||||
is_ldap_admin = is_ldap_user and user.role == "admin"
|
||||
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()
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
@@ -101,8 +112,8 @@ def user_to_response(user: User, db: Session) -> dict:
|
||||
"manager_parking_quota": user.manager_parking_quota,
|
||||
"manager_spot_prefix": user.manager_spot_prefix,
|
||||
"managed_user_count": managed_user_count,
|
||||
"is_ldap_user": is_ldap_user,
|
||||
"is_ldap_admin": is_ldap_admin,
|
||||
"is_ldap_user": is_ldap_user(user),
|
||||
"is_ldap_admin": is_ldap_admin(user),
|
||||
"created_at": user.created_at
|
||||
}
|
||||
|
||||
@@ -112,7 +123,25 @@ def user_to_response(user: User, db: Session) -> dict:
|
||||
def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""List all users (admin only)"""
|
||||
users = db.query(User).all()
|
||||
return [user_to_response(u, db) for u in users]
|
||||
|
||||
# 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}
|
||||
|
||||
# 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]
|
||||
|
||||
|
||||
@router.get("/{user_id}")
|
||||
@@ -136,13 +165,18 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
|
||||
if data.role not in ["admin", "manager", "employee"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
|
||||
# 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")
|
||||
|
||||
new_user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
id=generate_uuid(),
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
name=data.name,
|
||||
@@ -154,6 +188,7 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
config.logger.info(f"Admin created new user: {data.email}")
|
||||
return user_to_response(new_user, db)
|
||||
|
||||
|
||||
@@ -165,12 +200,12 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Check if user is LDAP-managed
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and target.password_hash is None
|
||||
is_ldap_admin = is_ldap_user and target.role == "admin"
|
||||
target_is_ldap = is_ldap_user(target)
|
||||
target_is_ldap_admin = is_ldap_admin(target)
|
||||
|
||||
# Name update - blocked for LDAP users
|
||||
if data.name is not None:
|
||||
if is_ldap_user:
|
||||
if target_is_ldap:
|
||||
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
|
||||
target.name = data.name
|
||||
|
||||
@@ -179,7 +214,7 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
|
||||
if data.role not in ["admin", "manager", "employee"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
# Can't change admin role for LDAP admins (they get admin from parking_admins group)
|
||||
if is_ldap_admin and data.role != "admin":
|
||||
if target_is_ldap_admin and data.role != "admin":
|
||||
raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)")
|
||||
# If changing from manager to another role, check for managed users
|
||||
if target.role == "manager" and data.role != "manager":
|
||||
@@ -254,8 +289,6 @@ 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"""
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None
|
||||
|
||||
# Get manager name
|
||||
manager_name = None
|
||||
if current_user.manager_id:
|
||||
@@ -270,17 +303,15 @@ def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_
|
||||
"role": current_user.role,
|
||||
"manager_id": current_user.manager_id,
|
||||
"manager_name": manager_name,
|
||||
"is_ldap_user": is_ldap_user
|
||||
"is_ldap_user": is_ldap_user(current_user)
|
||||
}
|
||||
|
||||
|
||||
@router.put("/me/profile")
|
||||
def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Update current user's profile (limited fields)"""
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None
|
||||
|
||||
if data.name is not None:
|
||||
if is_ldap_user:
|
||||
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()
|
||||
@@ -293,12 +324,12 @@ def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_u
|
||||
def get_settings(current_user=Depends(get_current_user)):
|
||||
"""Get current user's settings"""
|
||||
return {
|
||||
"week_start_day": current_user.week_start_day or 0,
|
||||
"notify_weekly_parking": current_user.notify_weekly_parking if current_user.notify_weekly_parking is not None else 1,
|
||||
"notify_daily_parking": current_user.notify_daily_parking if current_user.notify_daily_parking is not None else 1,
|
||||
"notify_daily_parking_hour": current_user.notify_daily_parking_hour if current_user.notify_daily_parking_hour is not None else 8,
|
||||
"notify_daily_parking_minute": current_user.notify_daily_parking_minute if current_user.notify_daily_parking_minute is not None else 0,
|
||||
"notify_parking_changes": current_user.notify_parking_changes if current_user.notify_parking_changes is not None else 1
|
||||
"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_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)
|
||||
}
|
||||
|
||||
|
||||
@@ -346,28 +377,19 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current
|
||||
@router.post("/me/change-password")
|
||||
def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Change current user's password (not available in LDAP mode)"""
|
||||
if config.AUTHELIA_ENABLED and current_user.password_hash is None:
|
||||
if is_ldap_user(current_user):
|
||||
raise HTTPException(status_code=400, detail="Password is managed by LDAP")
|
||||
|
||||
if not verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
|
||||
# Validate new password
|
||||
password = data.new_password
|
||||
errors = []
|
||||
if len(password) < 8:
|
||||
errors.append("at least 8 characters")
|
||||
if not re.search(r'[A-Z]', password):
|
||||
errors.append("one uppercase letter")
|
||||
if not re.search(r'[a-z]', password):
|
||||
errors.append("one lowercase letter")
|
||||
if not re.search(r'[0-9]', password):
|
||||
errors.append("one number")
|
||||
password_errors = validate_password(data.new_password)
|
||||
if password_errors:
|
||||
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
|
||||
|
||||
if errors:
|
||||
raise HTTPException(status_code=400, detail=f"Password must contain: {', '.join(errors)}")
|
||||
|
||||
current_user.password_hash = hash_password(password)
|
||||
current_user.password_hash = hash_password(data.new_password)
|
||||
current_user.updated_at = datetime.utcnow().isoformat()
|
||||
db.commit()
|
||||
config.logger.info(f"User {current_user.email} changed password")
|
||||
return {"message": "Password changed"}
|
||||
|
||||
Reference in New Issue
Block a user