aggiunti trasferte, export excel, miglioramenti generali
This commit is contained in:
@@ -68,6 +68,7 @@ AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins")
|
||||
# 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
|
||||
AUTHELIA_LOGOUT_URL = os.getenv("AUTHELIA_LOGOUT_URL", "") # e.g., https://auth.rocketscale.it/logout
|
||||
REGISTRATION_URL = os.getenv("REGISTRATION_URL", "") # e.g., https://register.rocketscale.it
|
||||
|
||||
# Email configuration (following org-stack pattern)
|
||||
|
||||
@@ -145,6 +145,7 @@ def get_auth_config():
|
||||
return {
|
||||
"authelia_enabled": config.AUTHELIA_ENABLED,
|
||||
"login_url": config.AUTHELIA_LOGIN_URL if config.AUTHELIA_ENABLED else None,
|
||||
"logout_url": config.AUTHELIA_LOGOUT_URL if config.AUTHELIA_ENABLED else None,
|
||||
"registration_url": config.REGISTRATION_URL if config.AUTHELIA_ENABLED else None
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,11 @@ def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=D
|
||||
)
|
||||
db.add(office)
|
||||
db.commit()
|
||||
|
||||
# Sync spots
|
||||
from services.offices import sync_office_spots
|
||||
sync_office_spots(office.id, office.parking_quota, office.spot_prefix, db)
|
||||
|
||||
return office
|
||||
|
||||
@router.get("/{office_id}")
|
||||
@@ -186,6 +191,10 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi
|
||||
office.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
# Sync spots
|
||||
from services.offices import sync_office_spots
|
||||
sync_office_spots(office.id, office.parking_quota, office.spot_prefix, db)
|
||||
|
||||
return {
|
||||
"id": office.id,
|
||||
"name": office.name,
|
||||
@@ -459,16 +468,20 @@ def add_office_exclusion(office_id: str, data: ExclusionCreate, db: Session = De
|
||||
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")
|
||||
# Relaxed unique check - user can have multiple exclusions (different periods)
|
||||
# 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")
|
||||
|
||||
if data.end_date and not data.start_date:
|
||||
raise HTTPException(status_code=400, detail="Start date is required if an end date is specified")
|
||||
|
||||
exclusion = ParkingExclusion(
|
||||
id=generate_uuid(),
|
||||
office_id=office_id,
|
||||
|
||||
@@ -23,7 +23,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import DailyParkingAssignment, User, UserRole, Office
|
||||
from database.models import DailyParkingAssignment, User, UserRole, Office, OfficeSpot
|
||||
from utils.auth_middleware import get_current_user, require_manager_or_admin
|
||||
from services.parking import (
|
||||
initialize_parking_pool, get_spot_display_name, release_user_spot,
|
||||
@@ -91,35 +91,70 @@ def init_office_pool(request: InitPoolRequest, db: Session = Depends(get_db), cu
|
||||
|
||||
|
||||
@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"""
|
||||
def get_assignments(date_val: date, office_id: str | None = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get parking assignments for a date, merging active assignments with empty spots"""
|
||||
query_date = date_val
|
||||
|
||||
# Defaults to user's office if not specified
|
||||
target_office_id = office_id or current_user.office_id
|
||||
|
||||
if not target_office_id:
|
||||
# Admin looking at all? Or error?
|
||||
# If no office_id, we might fetch all spots from all offices?
|
||||
# Let's support specific office filtering primarily as per UI use case
|
||||
# If office_id is None, we proceed with caution (maybe all offices)
|
||||
pass
|
||||
|
||||
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date)
|
||||
if office_id:
|
||||
query = query.filter(DailyParkingAssignment.office_id == office_id)
|
||||
# 1. Get ALL spots for the target office(s)
|
||||
# Note: Sorting by spot_number for consistent display order
|
||||
spot_query = db.query(OfficeSpot).filter(OfficeSpot.is_unavailable == False)
|
||||
if target_office_id:
|
||||
spot_query = spot_query.filter(OfficeSpot.office_id == target_office_id)
|
||||
spots = spot_query.order_by(OfficeSpot.spot_number).all()
|
||||
|
||||
assignments = query.all()
|
||||
# 2. Get EXISTING assignments
|
||||
assign_query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date)
|
||||
if target_office_id:
|
||||
assign_query = assign_query.filter(DailyParkingAssignment.office_id == target_office_id)
|
||||
active_assignments = assign_query.all()
|
||||
|
||||
# Map assignment by spot_id for O(1) lookup
|
||||
assignment_map = {a.spot_id: a for a in active_assignments}
|
||||
|
||||
results = []
|
||||
|
||||
for assignment in assignments:
|
||||
# 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,
|
||||
date=assignment.date,
|
||||
spot_id=assignment.spot_id,
|
||||
spot_display_name=spot_display_name,
|
||||
user_id=assignment.user_id,
|
||||
office_id=assignment.office_id
|
||||
)
|
||||
|
||||
if assignment.user_id:
|
||||
user = db.query(User).filter(User.id == assignment.user_id).first()
|
||||
if user:
|
||||
result.user_name = user.name
|
||||
result.user_email = user.email
|
||||
|
||||
# 3. Merge
|
||||
for spot in spots:
|
||||
assignment = assignment_map.get(spot.id)
|
||||
|
||||
if assignment:
|
||||
# Active assignment
|
||||
result = AssignmentResponse(
|
||||
id=assignment.id,
|
||||
date=assignment.date,
|
||||
spot_id=spot.id, # The FK
|
||||
spot_display_name=spot.name,
|
||||
user_id=assignment.user_id,
|
||||
office_id=spot.office_id
|
||||
)
|
||||
|
||||
if assignment.user_id:
|
||||
user = db.query(User).filter(User.id == assignment.user_id).first()
|
||||
if user:
|
||||
result.user_name = user.name
|
||||
result.user_email = user.email
|
||||
else:
|
||||
# Empty spot (Virtual assignment response)
|
||||
# We use "virtual" ID or just None? Schema says ID is str.
|
||||
# Frontend might need an ID for keys. Let's use "virtual-{spot.id}"
|
||||
result = AssignmentResponse(
|
||||
id=f"virtual-{spot.id}",
|
||||
date=query_date,
|
||||
spot_id=spot.id,
|
||||
spot_display_name=spot.name,
|
||||
user_id=None,
|
||||
office_id=spot.office_id
|
||||
)
|
||||
|
||||
results.append(result)
|
||||
|
||||
@@ -158,9 +193,6 @@ def get_my_assignments(start_date: date = None, end_date: date = None, db: Sessi
|
||||
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)"""
|
||||
@@ -203,32 +235,43 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
|
||||
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(
|
||||
# Check if spot exists (OfficeSpot)
|
||||
spot_def = db.query(OfficeSpot).filter(OfficeSpot.id == data.spot_id).first()
|
||||
if not spot_def:
|
||||
raise HTTPException(status_code=404, detail="Spot definition not found")
|
||||
|
||||
# Check if spot is already assigned
|
||||
existing_assignment = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.office_id == data.office_id,
|
||||
DailyParkingAssignment.date == assign_date,
|
||||
DailyParkingAssignment.spot_id == data.spot_id
|
||||
).first()
|
||||
|
||||
if not spot:
|
||||
raise HTTPException(status_code=404, detail="Spot not found")
|
||||
if spot.user_id:
|
||||
if existing_assignment:
|
||||
raise HTTPException(status_code=400, detail="Spot already assigned")
|
||||
|
||||
# Check if user already has a spot for this date (from any manager)
|
||||
existing = db.query(DailyParkingAssignment).filter(
|
||||
user_has_spot = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.date == assign_date,
|
||||
DailyParkingAssignment.user_id == data.user_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
if user_has_spot:
|
||||
raise HTTPException(status_code=400, detail="User already has a spot for this date")
|
||||
|
||||
spot.user_id = data.user_id
|
||||
# Create Assignment
|
||||
new_assignment = DailyParkingAssignment(
|
||||
id=generate_uuid(),
|
||||
date=assign_date,
|
||||
spot_id=data.spot_id,
|
||||
user_id=data.user_id,
|
||||
office_id=data.office_id,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(new_assignment)
|
||||
db.commit()
|
||||
|
||||
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}
|
||||
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_def.name}
|
||||
|
||||
|
||||
@router.post("/release-my-spot/{assignment_id}")
|
||||
@@ -245,15 +288,16 @@ 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.office_id, db)
|
||||
spot_name = assignment.spot.name if assignment.spot else "Unknown"
|
||||
|
||||
assignment.user_id = None
|
||||
# Delete assignment (Release)
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
|
||||
# Send notification (self-release, so just confirmation)
|
||||
notify_parking_released(current_user, assignment.date, spot_display_name)
|
||||
notify_parking_released(current_user, assignment.date, spot_name)
|
||||
|
||||
config.logger.info(f"User {current_user.email} released parking spot {spot_display_name} on {assignment.date}")
|
||||
config.logger.info(f"User {current_user.email} released parking spot {spot_name} on {assignment.date}")
|
||||
return {"message": "Parking spot released"}
|
||||
|
||||
|
||||
@@ -282,7 +326,7 @@ 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.office_id, db)
|
||||
spot_name = assignment.spot.name if assignment.spot else "Unknown"
|
||||
|
||||
if data.new_user_id == "auto":
|
||||
# "Auto assign" means releasing the spot so the system picks the next person
|
||||
@@ -308,46 +352,49 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already has a spot for this date")
|
||||
|
||||
# Update assignment to new user
|
||||
assignment.user_id = data.new_user_id
|
||||
|
||||
# Send notifications
|
||||
# Notify old user that spot was reassigned
|
||||
if old_user and old_user.id != new_user.id:
|
||||
notify_parking_reassigned(old_user, assignment.date, spot_display_name, new_user.name)
|
||||
notify_parking_reassigned(old_user, assignment.date, spot_name, new_user.name)
|
||||
# Notify new user that spot was assigned
|
||||
notify_parking_assigned(new_user, assignment.date, spot_display_name)
|
||||
notify_parking_assigned(new_user, assignment.date, spot_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}")
|
||||
config.logger.info(f"Parking spot {spot_name} on {assignment.date} reassigned from {old_user.email if old_user else 'unassigned'} to {new_user.email}")
|
||||
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
|
||||
result = AssignmentResponse(
|
||||
id=assignment.id,
|
||||
date=assignment.date,
|
||||
spot_id=assignment.spot_id,
|
||||
spot_display_name=spot_name,
|
||||
user_id=assignment.user_id,
|
||||
office_id=assignment.office_id
|
||||
)
|
||||
|
||||
if assignment.user_id:
|
||||
result.user_name = new_user.name
|
||||
result.user_email = new_user.email
|
||||
|
||||
return result
|
||||
|
||||
else:
|
||||
assignment.user_id = None
|
||||
# Release (Delete assignment)
|
||||
db.delete(assignment)
|
||||
|
||||
# Notify old user that spot was released
|
||||
if old_user:
|
||||
notify_parking_released(old_user, assignment.date, spot_display_name)
|
||||
notify_parking_released(old_user, assignment.date, spot_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)
|
||||
|
||||
# Build response
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||
|
||||
result = AssignmentResponse(
|
||||
id=assignment.id,
|
||||
date=assignment.date,
|
||||
spot_id=assignment.spot_id,
|
||||
spot_display_name=spot_display_name,
|
||||
user_id=assignment.user_id,
|
||||
office_id=assignment.office_id
|
||||
)
|
||||
|
||||
if assignment.user_id:
|
||||
user = db.query(User).filter(User.id == assignment.user_id).first()
|
||||
if user:
|
||||
result.user_name = user.name
|
||||
result.user_email = user.email
|
||||
|
||||
return result
|
||||
config.logger.info(f"Parking spot {spot_name} on {assignment.date} released by {old_user.email if old_user else 'unknown'}")
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": "Spot released"}
|
||||
|
||||
|
||||
@router.get("/eligible-users/{assignment_id}")
|
||||
@@ -394,3 +441,91 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class TestEmailRequest(BaseModel):
|
||||
date: date = None
|
||||
office_id: str
|
||||
|
||||
|
||||
@router.post("/test-email")
|
||||
def send_test_email_tool(data: TestEmailRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Send a test email to the current user (Test Tool)"""
|
||||
from services.notifications import send_email
|
||||
from database.models import OfficeClosingDay, OfficeWeeklyClosingDay
|
||||
from datetime import timedelta
|
||||
|
||||
# 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")
|
||||
|
||||
target_date = data.date
|
||||
if not target_date:
|
||||
# Find next open day
|
||||
# Start from tomorrow (or today? Prompt says "dopo il giorno corrente" -> after today)
|
||||
check_date = date.today() + timedelta(days=1)
|
||||
|
||||
# Load closing rules
|
||||
weekly_closed = db.query(OfficeWeeklyClosingDay.weekday).filter(
|
||||
OfficeWeeklyClosingDay.office_id == data.office_id
|
||||
).all()
|
||||
weekly_closed_set = {w[0] for w in weekly_closed}
|
||||
|
||||
specific_closed = db.query(OfficeClosingDay).filter(
|
||||
OfficeClosingDay.office_id == data.office_id,
|
||||
OfficeClosingDay.date >= check_date
|
||||
).all()
|
||||
|
||||
# Max lookahead 30 days to avoid infinite loop
|
||||
found = False
|
||||
for _ in range(30):
|
||||
# Check weekly
|
||||
if check_date.weekday() in weekly_closed_set:
|
||||
check_date += timedelta(days=1)
|
||||
continue
|
||||
|
||||
# Check specific
|
||||
is_specific = False
|
||||
for d in specific_closed:
|
||||
s = d.date
|
||||
e = d.end_date or d.date
|
||||
if s <= check_date <= e:
|
||||
is_specific = True
|
||||
break
|
||||
|
||||
if is_specific:
|
||||
check_date += timedelta(days=1)
|
||||
continue
|
||||
|
||||
found = True
|
||||
break
|
||||
|
||||
if found:
|
||||
target_date = check_date
|
||||
else:
|
||||
# Fallback
|
||||
target_date = date.today() + timedelta(days=1)
|
||||
|
||||
# Send Email
|
||||
subject = f"Test Email - Parking System ({target_date})"
|
||||
body_html = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Parking System Test Email</h2>
|
||||
<p>Hi {current_user.name},</p>
|
||||
<p>This is a test email triggered from the Parking Manager Test Tools.</p>
|
||||
<p><strong>Selected Date:</strong> {target_date}</p>
|
||||
<p><strong>SMTP Status:</strong> {'Enabled' if config.SMTP_ENABLED else 'Disabled (File Logging)'}</p>
|
||||
<p>If you received this, the notification system is working.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
success = send_email(current_user.email, subject, body_html)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"mode": "SMTP" if config.SMTP_ENABLED else "FILE",
|
||||
"date": target_date,
|
||||
"message": "Email sent successfully" if success else "Failed to send email"
|
||||
}
|
||||
|
||||
@@ -329,3 +329,49 @@ def get_user_presences(user_id: str, start_date: date = None, end_date: date = N
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ClearOfficePresenceRequest(BaseModel):
|
||||
start_date: date
|
||||
end_date: date
|
||||
office_id: str
|
||||
|
||||
|
||||
@router.post("/admin/clear-office-presence")
|
||||
def clear_office_presence(data: ClearOfficePresenceRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Clear all presence and parking for an office in a date range (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")
|
||||
|
||||
# Get all users in the office
|
||||
users = db.query(User).filter(User.office_id == data.office_id).all()
|
||||
user_ids = [u.id for u in users]
|
||||
|
||||
if not user_ids:
|
||||
return {"message": "No users in office", "count_presence": 0, "count_parking": 0}
|
||||
|
||||
# 1. Delete Parking Assignments
|
||||
parking_delete = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.user_id.in_(user_ids),
|
||||
DailyParkingAssignment.date >= data.start_date,
|
||||
DailyParkingAssignment.date <= data.end_date
|
||||
)
|
||||
parking_count = parking_delete.delete(synchronize_session=False)
|
||||
|
||||
# 2. Delete Presence
|
||||
presence_delete = db.query(UserPresence).filter(
|
||||
UserPresence.user_id.in_(user_ids),
|
||||
UserPresence.date >= data.start_date,
|
||||
UserPresence.date <= data.end_date
|
||||
)
|
||||
presence_count = presence_delete.delete(synchronize_session=False)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Cleared office presence and parking",
|
||||
"count_presence": presence_count,
|
||||
"count_parking": parking_count
|
||||
}
|
||||
|
||||
195
app/routes/reports.py
Normal file
195
app/routes/reports.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from io import BytesIO
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import User, UserPresence, DailyParkingAssignment, UserRole, Office
|
||||
from utils.auth_middleware import require_manager_or_admin
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@router.get("/team-export")
|
||||
def export_team_data(
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
office_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_manager_or_admin)
|
||||
):
|
||||
"""
|
||||
Export team presence and parking data to Excel.
|
||||
"""
|
||||
# 1. Determine Scope (Admin vs Manager)
|
||||
target_office_id = office_id
|
||||
if current_user.role == UserRole.MANAGER:
|
||||
# Manager is restricted to their own office
|
||||
if office_id and office_id != current_user.office_id:
|
||||
raise HTTPException(status_code=403, detail="Cannot export data for other offices")
|
||||
target_office_id = current_user.office_id
|
||||
|
||||
# 2. Fetch Users
|
||||
query = db.query(User)
|
||||
if target_office_id:
|
||||
query = query.filter(User.office_id == target_office_id)
|
||||
users = query.all()
|
||||
user_ids = [u.id for u in users]
|
||||
|
||||
# Map users for quick lookup
|
||||
user_map = {u.id: u for u in users}
|
||||
|
||||
# 3. Fetch Presences
|
||||
presences = db.query(UserPresence).filter(
|
||||
UserPresence.user_id.in_(user_ids),
|
||||
UserPresence.date >= start_date,
|
||||
UserPresence.date <= end_date
|
||||
).all()
|
||||
|
||||
# 4. Fetch Parking Assignments
|
||||
assignments = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.user_id.in_(user_ids),
|
||||
DailyParkingAssignment.date >= start_date,
|
||||
DailyParkingAssignment.date <= end_date
|
||||
).all()
|
||||
|
||||
# Organize data by Date -> User -> Info
|
||||
# Structure: data_map[date_str][user_id] = { presence: ..., parking: ... }
|
||||
data_map = {}
|
||||
|
||||
for p in presences:
|
||||
d_str = p.date.isoformat()
|
||||
if d_str not in data_map: data_map[d_str] = {}
|
||||
if p.user_id not in data_map[d_str]: data_map[d_str][p.user_id] = {}
|
||||
data_map[d_str][p.user_id]['presence'] = p.status.value # 'present', 'remote', etc.
|
||||
|
||||
for a in assignments:
|
||||
d_str = a.date.isoformat()
|
||||
if d_str not in data_map: data_map[d_str] = {}
|
||||
if a.user_id not in data_map[d_str]: data_map[d_str][a.user_id] = {}
|
||||
if a.spot:
|
||||
data_map[d_str][a.user_id]['parking'] = a.spot.name
|
||||
else:
|
||||
data_map[d_str][a.user_id]['parking'] = "Unknown"
|
||||
|
||||
# 5. Generate Excel
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Report Presenze Matrix"
|
||||
|
||||
# --- Header Row (Dates) ---
|
||||
# Column A: "Utente"
|
||||
ws.cell(row=1, column=1, value="Utente")
|
||||
|
||||
# Generate date range
|
||||
from datetime import timedelta
|
||||
date_cols = {} # date_str -> col_index
|
||||
col_idx = 2
|
||||
|
||||
curr = start_date
|
||||
while curr <= end_date:
|
||||
d_str = curr.isoformat()
|
||||
# Header: DD/MM
|
||||
header_val = f"{curr.day}/{curr.month}"
|
||||
|
||||
cell = ws.cell(row=1, column=col_idx, value=header_val)
|
||||
date_cols[d_str] = col_idx
|
||||
|
||||
# Style Header
|
||||
cell.font = Font(bold=True)
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
|
||||
col_idx += 1
|
||||
curr += timedelta(days=1)
|
||||
|
||||
# Style First Header (Utente)
|
||||
first_header = ws.cell(row=1, column=1)
|
||||
first_header.font = Font(bold=True)
|
||||
first_header.alignment = Alignment(horizontal="left")
|
||||
|
||||
# Define Fills
|
||||
fill_present = PatternFill(start_color="dcfce7", end_color="dcfce7", fill_type="solid") # Light Green
|
||||
fill_remote = PatternFill(start_color="dbeafe", end_color="dbeafe", fill_type="solid") # Light Blue
|
||||
fill_absent = PatternFill(start_color="fee2e2", end_color="fee2e2", fill_type="solid") # Light Red
|
||||
fill_trip = PatternFill(start_color="fef3c7", end_color="fef3c7", fill_type="solid") # Light Orange (matching frontend warning-bg)
|
||||
|
||||
# --- User Rows ---
|
||||
row_idx = 2
|
||||
for user in users:
|
||||
# User Name in Col 1
|
||||
name_cell = ws.cell(row=row_idx, column=1, value=user.name)
|
||||
name_cell.font = Font(bold=True)
|
||||
|
||||
# Determine Office label (optional append?)
|
||||
# name_cell.value = f"{user.name} ({user.office.name})" if user.office else user.name
|
||||
|
||||
# Fill Dates
|
||||
curr = start_date
|
||||
while curr <= end_date:
|
||||
d_str = curr.isoformat()
|
||||
if d_str in date_cols:
|
||||
c_idx = date_cols[d_str]
|
||||
|
||||
# Get Data
|
||||
u_data = data_map.get(d_str, {}).get(user.id, {})
|
||||
presence = u_data.get('presence', '')
|
||||
parking = u_data.get('parking', '')
|
||||
|
||||
cell_val = ""
|
||||
fill = None
|
||||
|
||||
if presence == 'present':
|
||||
cell_val = "In Sede"
|
||||
fill = fill_present
|
||||
elif presence == 'remote':
|
||||
cell_val = "Remoto"
|
||||
fill = fill_remote
|
||||
elif presence == 'absent':
|
||||
cell_val = "Ferie"
|
||||
fill = fill_absent
|
||||
elif presence == 'business_trip':
|
||||
cell_val = "Trasferta"
|
||||
fill = fill_trip
|
||||
|
||||
# Append Parking info if present
|
||||
if parking:
|
||||
if cell_val:
|
||||
cell_val += f" ({parking})"
|
||||
else:
|
||||
cell_val = f"({parking})" # Parking without presence? Unusual but possible
|
||||
|
||||
cell = ws.cell(row=row_idx, column=c_idx, value=cell_val)
|
||||
if fill:
|
||||
cell.fill = fill
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
|
||||
curr += timedelta(days=1)
|
||||
|
||||
row_idx += 1
|
||||
|
||||
# Adjust column widths
|
||||
ws.column_dimensions['A'].width = 25 # User name column
|
||||
|
||||
# Auto-width for date columns (approx)
|
||||
for i in range(2, col_idx):
|
||||
col_letter = ws.cell(row=1, column=i).column_letter
|
||||
ws.column_dimensions[col_letter].width = 12
|
||||
|
||||
# Save to buffer
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
filename = f"report_parking_matrix_{start_date}_{end_date}.xlsx"
|
||||
|
||||
headers = {
|
||||
'Content-Disposition': f'attachment; filename="{filename}"'
|
||||
}
|
||||
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers=headers
|
||||
)
|
||||
@@ -2,13 +2,13 @@
|
||||
User Management Routes
|
||||
Admin user CRUD and user self-service (profile, settings, password)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import User, UserRole, Office
|
||||
from database.models import User, UserRole, Office, ParkingExclusion
|
||||
from utils.auth_middleware import get_current_user, require_admin
|
||||
from utils.helpers import (
|
||||
generate_uuid, is_ldap_user, is_ldap_admin,
|
||||
@@ -54,6 +54,20 @@ class ChangePasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class UserExclusionCreate(BaseModel):
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class UserExclusionResponse(BaseModel):
|
||||
id: str
|
||||
start_date: date | None
|
||||
end_date: date | None
|
||||
notes: str | None
|
||||
is_excluded: bool = True
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
@@ -332,3 +346,105 @@ def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db),
|
||||
db.commit()
|
||||
config.logger.info(f"User {current_user.email} changed password")
|
||||
return {"message": "Password changed"}
|
||||
|
||||
|
||||
# Exclusion Management (Self-Service)
|
||||
# Exclusion Management (Self-Service)
|
||||
@router.get("/me/exclusion", response_model=list[UserExclusionResponse])
|
||||
def get_my_exclusions(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get current user's parking exclusions"""
|
||||
if not current_user.office_id:
|
||||
return []
|
||||
|
||||
exclusions = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.user_id == current_user.id,
|
||||
ParkingExclusion.office_id == current_user.office_id
|
||||
).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": e.id,
|
||||
"start_date": e.start_date,
|
||||
"end_date": e.end_date,
|
||||
"notes": e.notes,
|
||||
"is_excluded": True
|
||||
}
|
||||
for e in exclusions
|
||||
]
|
||||
|
||||
|
||||
@router.post("/me/exclusion")
|
||||
def create_my_exclusion(data: UserExclusionCreate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Create new parking exclusion for current user"""
|
||||
if not current_user.office_id:
|
||||
raise HTTPException(status_code=400, detail="User is not assigned to an office")
|
||||
|
||||
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")
|
||||
|
||||
if data.end_date and not data.start_date:
|
||||
raise HTTPException(status_code=400, detail="Start date is required if an end date is specified")
|
||||
|
||||
exclusion = ParkingExclusion(
|
||||
id=generate_uuid(),
|
||||
office_id=current_user.office_id,
|
||||
user_id=current_user.id,
|
||||
start_date=data.start_date,
|
||||
end_date=data.end_date,
|
||||
notes=data.notes or "Auto-esclusione utente",
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(exclusion)
|
||||
db.commit()
|
||||
return {"message": "Exclusion created", "id": exclusion.id}
|
||||
|
||||
|
||||
@router.put("/me/exclusion/{exclusion_id}")
|
||||
def update_my_exclusion(exclusion_id: str, data: UserExclusionCreate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Update specific parking exclusion for current user"""
|
||||
if not current_user.office_id:
|
||||
raise HTTPException(status_code=400, detail="User is not assigned to an office")
|
||||
|
||||
exclusion = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.id == exclusion_id,
|
||||
ParkingExclusion.user_id == current_user.id,
|
||||
ParkingExclusion.office_id == current_user.office_id
|
||||
).first()
|
||||
|
||||
if not exclusion:
|
||||
raise HTTPException(status_code=404, detail="Exclusion not found")
|
||||
|
||||
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")
|
||||
|
||||
if data.end_date and not data.start_date:
|
||||
raise HTTPException(status_code=400, detail="Start date is required if an end date is specified")
|
||||
|
||||
exclusion.start_date = data.start_date
|
||||
exclusion.end_date = data.end_date
|
||||
if data.notes is not None:
|
||||
exclusion.notes = data.notes
|
||||
|
||||
exclusion.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return {"message": "Exclusion updated", "id": exclusion.id}
|
||||
|
||||
|
||||
@router.delete("/me/exclusion/{exclusion_id}")
|
||||
def delete_my_exclusion(exclusion_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Remove specific parking exclusion for current user"""
|
||||
if not current_user.office_id:
|
||||
raise HTTPException(status_code=400, detail="User is not assigned to an office")
|
||||
|
||||
exclusion = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.id == exclusion_id,
|
||||
ParkingExclusion.user_id == current_user.id,
|
||||
ParkingExclusion.office_id == current_user.office_id
|
||||
).first()
|
||||
|
||||
if not exclusion:
|
||||
raise HTTPException(status_code=404, detail="Exclusion not found")
|
||||
|
||||
db.delete(exclusion)
|
||||
db.commit()
|
||||
return {"message": "Exclusion removed"}
|
||||
|
||||
Reference in New Issue
Block a user