Compare commits

1 Commits

Author SHA1 Message Date
5f4ef6faee aggiunti trasferte, export excel, miglioramenti generali 2026-02-04 12:55:04 +01:00
30 changed files with 1558 additions and 325 deletions

View File

@@ -1,13 +0,0 @@
parking.lvh.me {
# Integrazione Authelia per autenticazione
forward_auth authelia:9091 {
uri /api/verify?rd=https://parking.lvh.me/
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
# Proxy inverso verso il container parking sulla porta 8000
reverse_proxy parking:8000
# Usa certificati gestiti internamente per lvh.me (locale)
tls internal
}

View File

@@ -119,6 +119,10 @@ Gestione utenti e profili.
- `GET /me/settings`: Ottieni le proprie impostazioni.
- `PUT /me/settings`: Aggiorna le proprie impostazioni.
- `POST /me/change-password`: Modifica la propria password.
- `GET /me/exclusion`: Lista delle proprie auto-esclusioni.
- `POST /me/exclusion`: Crea una nuova auto-esclusione.
- `PUT /me/exclusion/{exclusion_id}`: Modifica una auto-esclusione.
- `DELETE /me/exclusion/{exclusion_id}`: Elimina una auto-esclusione.
### Offices (`/api/offices`)
Gestione uffici, regole di chiusura e quote.
@@ -151,7 +155,8 @@ Gestione presenze giornaliere.
- `POST /admin/mark`: Segna presenza per un altro utente (Manager/Admin).
- `DELETE /admin/{user_id}/{date}`: Rimuovi presenza di un altro utente (Manager/Admin).
- `GET /team`: Visualizza presenze e stato parcheggio del team.
- `GET /admin/{user_id}`: Storico presenze di un utente.
- `GET /admin/{user_id}`: Storico presenze di un utente (Manager/Admin).
- `POST /admin/clear-office-presence`: Pulisce presenze e parcheggi di un ufficio per un range di date (Test/Admin).
### Parking (`/api/parking`)
Gestione assegnazioni posti auto.
@@ -165,6 +170,12 @@ Gestione assegnazioni posti auto.
- `POST /reassign-spot`: Riassegna o libera un posto già assegnato.
- `POST /release-my-spot/{id}`: Rilascia il proprio posto assegnato.
- `GET /eligible-users/{id}`: Lista utenti idonei a ricevere un posto riassegnato.
- `POST /test-email`: Invia email di test (Test Tool).
### Reports (`/api/reports`)
Esportazione dati.
- `GET /team-export`: Esporta dati presenze e parcheggi del team in Excel.
## Utilizzo con AUTHELIA

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,12 @@ services:
restart: unless-stopped
volumes:
- ./data:/app/data
- ./app:/app/app
- ./database:/app/database
- ./services:/app/services
- ./utils:/app/utils
- ./frontend:/app/frontend
- ./main.py:/app/main.py
env_file:
- .env
environment:
@@ -29,5 +35,4 @@ services:
networks:
org-network:
external: true
name: org-stack_org-network
external: true

View File

@@ -20,6 +20,8 @@ class PresenceStatus(str, enum.Enum):
PRESENT = "present"
REMOTE = "remote"
ABSENT = "absent"
BUSINESS_TRIP = "business_trip"
class NotificationType(str, enum.Enum):
@@ -67,6 +69,7 @@ class Office(Base):
users = relationship("User", back_populates="office")
closing_days = relationship("OfficeClosingDay", back_populates="office", cascade="all, delete-orphan")
weekly_closing_days = relationship("OfficeWeeklyClosingDay", back_populates="office", cascade="all, delete-orphan")
spots = relationship("OfficeSpot", back_populates="office", cascade="all, delete-orphan")
class User(Base):
@@ -130,7 +133,7 @@ class DailyParkingAssignment(Base):
id = Column(Text, primary_key=True)
date = Column(Date, nullable=False)
spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from office)
spot_id = Column(Text, ForeignKey("office_spots.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Text, ForeignKey("users.id", ondelete="SET NULL"))
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) # Office that owns the spot
created_at = Column(DateTime, default=datetime.utcnow)
@@ -138,6 +141,7 @@ class DailyParkingAssignment(Base):
# Relationships
user = relationship("User", back_populates="assignments", foreign_keys=[user_id])
office = relationship("Office")
spot = relationship("OfficeSpot", back_populates="assignments")
__table_args__ = (
Index('idx_assignment_office_date', 'office_id', 'date'),
@@ -218,7 +222,7 @@ class ParkingExclusion(Base):
user = relationship("User", foreign_keys=[user_id])
__table_args__ = (
Index('idx_exclusion_office_user', 'office_id', 'user_id', unique=True),
Index('idx_exclusion_office_user', 'office_id', 'user_id'),
)
@@ -237,12 +241,6 @@ class NotificationLog(Base):
)
class NotificationQueue(Base):
"""Queue for pending notifications (for immediate parking change notifications)"""
__tablename__ = "notification_queue"
id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # parking_change
subject = Column(Text, nullable=False)
body = Column(Text, nullable=False)
@@ -252,3 +250,23 @@ class NotificationQueue(Base):
__table_args__ = (
Index('idx_queue_pending', 'sent_at'),
)
class OfficeSpot(Base):
"""Specific parking spot definitions (e.g., A1, A2) linked to an office"""
__tablename__ = "office_spots"
id = Column(Text, primary_key=True)
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
name = Column(Text, nullable=False) # Display name: A1, A2, etc.
spot_number = Column(Integer, nullable=False) # Numeric part for sorting/filtering (1, 2, 3...)
is_unavailable = Column(Boolean, default=False) # If spot is temporarily out of service
# Relationships
office = relationship("Office", back_populates="spots")
assignments = relationship("DailyParkingAssignment", back_populates="spot", cascade="all, delete-orphan")
__table_args__ = (
Index('idx_office_spot_number', 'office_id', 'spot_number', unique=True),
Index('idx_office_spot_name', 'office_id', 'name', unique=True),
)

61
fix_db_index.py Normal file
View File

@@ -0,0 +1,61 @@
import sqlite3
import os
import time
# Function to find the db file
def find_db():
candidates = [
'data/parking.db',
'/home/ssalemi/org-parking/data/parking.db',
'./data/parking.db'
]
for path in candidates:
if os.path.exists(path):
return path
return None
db_path = find_db()
if not db_path:
print("Error: Could not find data/parking.db. Make sure you are in the project root.")
exit(1)
print(f"Target Database: {db_path}")
print("Attempting to fix 'parking_exclusions' index...")
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Turn off foreign keys temporarily to avoid issues during schema modification if needed
cursor.execute("PRAGMA foreign_keys=OFF")
# 1. Drop the existing unique index
print("Dropping index idx_exclusion_office_user...")
try:
cursor.execute("DROP INDEX IF EXISTS idx_exclusion_office_user")
print("Index dropped successfully.")
except Exception as e:
print(f"Warning during drop: {e}")
# 2. Recreate it as non-unique
print("Creating non-unique index idx_exclusion_office_user...")
try:
cursor.execute("CREATE INDEX idx_exclusion_office_user ON parking_exclusions (office_id, user_id)")
print("Index created successfully.")
except Exception as e:
print(f"Error creating index: {e}")
exit(1)
conn.commit()
conn.close()
print("\nSUCCESS: Database updated. You can now define multiple exclusions per user.")
except sqlite3.OperationalError as e:
if "locked" in str(e):
print("\nERROR: Database is LOCKED.")
print("Please STOP the running application (Docker) and try again.")
else:
print(f"\nError: {e}")
except Exception as e:
print(f"\nUnexpected Error: {e}")

View File

@@ -652,6 +652,11 @@ textarea {
border-color: var(--danger) !important;
}
.status-business_trip {
background: var(--warning-bg) !important;
border-color: var(--warning) !important;
}
.status-nodata {
background: white;
}
@@ -1788,6 +1793,7 @@ textarea {
transform: translate(-50%, 100%);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
@@ -1798,6 +1804,7 @@ textarea {
from {
opacity: 1;
}
to {
opacity: 0;
}

View File

@@ -185,9 +185,23 @@ const api = {
* Logout
*/
async logout() {
// Fetch config to check for external logout URL
let logoutUrl = '/login';
try {
const configRes = await this.get('/api/auth/config');
if (configRes && configRes.ok) {
const config = await configRes.json();
if (config.authelia_enabled && config.logout_url) {
logoutUrl = config.logout_url;
}
}
} catch (e) {
console.error('Error fetching logout config', e);
}
await this.post('/api/auth/logout', {});
this.clearToken();
window.location.href = '/login';
window.location.href = logoutUrl;
}
};

View File

@@ -87,9 +87,6 @@ async function initNav() {
// Get user info (works with both JWT and Authelia)
const currentUser = await api.checkAuth();
// Render navigation
navContainer.innerHTML = renderNav(currentPath, currentUser?.role);
// Update user info in sidebar
if (currentUser) {
const userNameEl = document.getElementById('userName');
@@ -98,11 +95,55 @@ async function initNav() {
if (userRoleEl) userRoleEl.textContent = currentUser.role || '-';
}
// Setup user menu
// Setup user menu (logout) & mobile menu
setupUserMenu();
// Setup mobile menu
setupMobileMenu();
// CHECK: Block access if user has no office (and is not admin)
// Admins are allowed to access "Gestione Uffici" even without an office
if (currentUser && !currentUser.office_id && currentUser.role !== 'admin') {
navContainer.innerHTML = ''; // Clear nav
const mainContent = document.querySelector('.main-content');
if (mainContent) {
mainContent.innerHTML = `
<div style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80vh;
padding: 2rem;
text-align: center;
">
<div class="card" style="max-width: 500px; padding: 2.5rem; border-top: 4px solid #ef4444;">
<div style="color: #ef4444; margin-bottom: 1.5rem;">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<h2 style="margin-bottom: 1rem;">Ufficio non assegnato</h2>
<p class="text-secondary" style="margin-bottom: 1.5rem; line-height: 1.6;">
Il tuo account <strong>${currentUser.email}</strong> è attivo, ma non sei ancora stato assegnato a nessuno ufficio.
</p>
<div style="padding: 1.5rem; background: #f8fafc; border-radius: 8px; text-align: left;">
<div style="font-weight: 600; margin-bottom: 0.5rem; color: var(--text);">Cosa fare?</div>
<div style="font-size: 0.95rem; color: var(--text-secondary);">
Contatta l'amministratore di sistema per richiedere l'assegnazione al tuo ufficio di competenza.<br>
<a href="mailto:s.salemi@sielte.it" style="color: var(--primary); text-decoration: none; font-weight: 500; margin-top: 0.5rem; display: inline-block;">s.salemi@sielte.it</a>
</div>
</div>
</div>
</div>
`;
}
return; // STOP rendering nav
}
// Render navigation (Normal Flow)
navContainer.innerHTML = renderNav(currentPath, currentUser?.role);
}
function setupMobileMenu() {

View File

@@ -143,6 +143,7 @@ function setupEventListeners() {
}
});
// Test Tools
// Test Tools
document.getElementById('runAllocationBtn').addEventListener('click', async () => {
if (!confirm('Sei sicuro di voler avviare l\'assegnazione ORA? Questo potrebbe sovrascrivere le assegnazioni esistenti per la data selezionata.')) return;
@@ -166,7 +167,7 @@ function setupEventListeners() {
utils.showMessage('Avvio assegnazione...', 'success');
while (current <= end) {
const dateStr = current.toISOString().split('T')[0];
const dateStr = utils.formatDate(current);
try {
await api.post('/api/parking/run-allocation', {
date: dateStr,
@@ -207,20 +208,110 @@ function setupEventListeners() {
utils.showMessage('Rimozione in corso...', 'warning');
// Loop is fine, but maybe redundant if we could batch clean?
// Backend clear-assignments is per day.
while (current <= end) {
const dateStr = current.toISOString().split('T')[0];
const dateStr = utils.formatDate(current);
try {
const res = await api.post('/api/parking/clear-assignments', {
date: dateStr,
office_id: currentOffice.id
});
totalRemoved += (res.count || 0);
if (res && res.ok) {
const data = await res.json();
totalRemoved += (data.count || 0);
}
} catch (e) {
console.error(`Error clearing ${dateStr}`, e);
}
current.setDate(current.getDate() + 1);
}
utils.showMessage(`Operazione eseguita. ${totalRemoved} assegnazioni rimosse in totale.`, 'warning');
utils.showMessage(`Operazione eseguita.`, 'warning');
});
const clearPresenceBtn = document.getElementById('clearPresenceBtn');
if (clearPresenceBtn) {
clearPresenceBtn.addEventListener('click', async () => {
if (!confirm('ATTENZIONE: Stai per eliminare TUTTI GLI STATI (Presente/Assente/ecc) e relative assegnazioni per tutti gli utenti dell\'ufficio nel periodo selezionato. \n\nQuesta azione è irreversibile. Procedere?')) return;
const dateStart = document.getElementById('testDateStart').value;
const dateEnd = document.getElementById('testDateEnd').value;
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
// Validate office
if (!currentOffice || !currentOffice.id) {
return utils.showMessage('Errore: Nessun ufficio selezionato', 'error');
}
const endDateVal = dateEnd || dateStart;
utils.showMessage('Rimozione stati in corso...', 'warning');
try {
const res = await api.post('/api/presence/admin/clear-office-presence', {
start_date: dateStart,
end_date: endDateVal,
office_id: currentOffice.id
});
if (res && res.ok) {
const data = await res.json();
utils.showMessage(`Operazione completata. Rimossi ${data.count_presence} stati e ${data.count_parking} assegnazioni.`, 'success');
} else {
const err = await res.json();
utils.showMessage('Errore: ' + (err.detail || 'Operazione fallita'), 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server', 'error');
}
});
}
const testEmailBtn = document.getElementById('testEmailBtn');
if (testEmailBtn) {
testEmailBtn.addEventListener('click', async () => {
const dateVal = document.getElementById('testEmailDate').value;
// Validate office
if (!currentOffice || !currentOffice.id) {
return utils.showMessage('Errore: Nessun ufficio selezionato', 'error');
}
utils.showMessage('Invio mail di test in corso...', 'warning');
try {
const res = await api.post('/api/parking/test-email', {
date: dateVal || null,
office_id: currentOffice.id
});
if (res && res.status !== 403 && res.status !== 500 && res.ok !== false) {
// API wrapper usually returns response object or parses JSON?
// api.post returns response object if 200-299, but wrapper handles some.
// Let's assume standard fetch response or check wrapper.
// api.js Wrapper returns fetch Response.
const data = await res.json();
if (data.success) {
let msg = `Email inviata con successo per la data: ${data.date}.`;
if (data.mode === 'FILE') {
msg += ' (SMTP disabilitato: Loggato su file)';
}
utils.showMessage(msg, 'success');
} else {
utils.showMessage('Invio fallito. Controlla i log del server.', 'error');
}
} else {
const err = res ? await res.json() : {};
utils.showMessage('Errore: ' + (err.detail || 'Invio fallito'), 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server', 'error');
}
});
}
}

View File

@@ -33,6 +33,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize Parking Status
initParkingStatus();
setupStatusListeners();
// Initialize Exclusion Logic
initExclusionLogic();
});
async function loadPresences() {
@@ -337,19 +340,57 @@ function setupEventListeners() {
const promises = [];
let current = new Date(startDate);
// Validate filtering
let skippedCount = 0;
while (current <= endDate) {
const dStr = current.toISOString().split('T')[0];
if (status === 'clear') {
promises.push(api.delete(`/api/presence/${dStr}`));
// Create local date for rules check (matches renderCalendar logic)
const localCurrent = new Date(dStr + 'T00:00:00');
const dayOfWeek = localCurrent.getDay(); // 0-6
// Check closing days
// Only enforce rules if we are not clearing (or should we enforce for clearing too?
// Usually clearing is allowed always, but "Inserimento" implies adding.
// Ensuring we don't ADD presence on closed days is the main goal.)
let isClosed = false;
if (status !== 'clear') {
const isWeeklyClosed = weeklyClosingDays.includes(dayOfWeek);
const isSpecificClosed = specificClosingDays.some(d => {
const start = new Date(d.date);
const end = d.end_date ? new Date(d.end_date) : start;
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
// localCurrent is already set to 00:00:00 local
return localCurrent >= start && localCurrent <= end;
});
if (isWeeklyClosed || isSpecificClosed) isClosed = true;
}
if (isClosed) {
skippedCount++;
} else {
promises.push(api.post('/api/presence/mark', { date: dStr, status: status }));
if (status === 'clear') {
promises.push(api.delete(`/api/presence/${dStr}`));
} else {
promises.push(api.post('/api/presence/mark', { date: dStr, status: status }));
}
}
current.setDate(current.getDate() + 1);
}
try {
await Promise.all(promises);
utils.showMessage('Inserimento completato!', 'success');
if (skippedCount > 0) {
utils.showMessage(`Inserimento completato! (${skippedCount} giorni chiusi ignorati)`, 'warning');
} else {
utils.showMessage('Inserimento completato!', 'success');
}
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
} catch (err) {
@@ -531,3 +572,211 @@ function setupStatusListeners() {
}
// ----------------------------------------------------------------------------
// Exclusion Logic
// ----------------------------------------------------------------------------
async function initExclusionLogic() {
await loadExclusionStatus();
setupExclusionListeners();
}
async function loadExclusionStatus() {
try {
const response = await api.get('/api/users/me/exclusion');
if (response && response.ok) {
const data = await response.json();
updateExclusionUI(data); // data is now a list
}
} catch (e) {
console.error("Error loading exclusion status", e);
}
}
function updateExclusionUI(exclusions) {
const statusDiv = document.getElementById('exclusionStatusDisplay');
const manageBtn = document.getElementById('manageExclusionBtn');
// Always show manage button as "Aggiungi Esclusione"
manageBtn.textContent = 'Aggiungi Esclusione';
// Clear previous binding to avoid duplicates or simply use a new function
// But specific listeners are set in setupExclusionListeners.
// Actually, manageBtn logic was resetting UI.
if (exclusions && exclusions.length > 0) {
statusDiv.style.display = 'block';
let html = '<div style="display:flex; flex-direction:column; gap:0.5rem;">';
exclusions.forEach(ex => {
let period = 'Tempo Indeterminato';
if (ex.start_date && ex.end_date) {
period = `${utils.formatDate(new Date(ex.start_date))} - ${utils.formatDate(new Date(ex.end_date))}`;
} else if (ex.start_date) {
period = `Dal ${utils.formatDate(new Date(ex.start_date))}`;
}
html += `
<div style="background: white; border: 1px solid #e5e7eb; padding: 0.75rem; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
<div>
<div style="font-weight: 500; font-size: 0.9rem;">${period}</div>
${ex.notes ? `<div style="font-size: 0.8rem; color: #6b7280;">${ex.notes}</div>` : ''}
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn-icon" onclick='openEditMyExclusion("${ex.id}", ${JSON.stringify(ex).replace(/'/g, "&#39;")})' title="Modifica">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</button>
<button class="btn-icon btn-danger" onclick="deleteMyExclusion('${ex.id}')" title="Rimuovi">
&times;
</button>
</div>
</div>`;
});
html += '</div>';
statusDiv.innerHTML = html;
// Update container style for list
statusDiv.style.backgroundColor = '#f9fafb';
statusDiv.style.color = 'inherit';
statusDiv.style.border = 'none'; // remove border from container, items have border
statusDiv.style.padding = '0'; // reset padding
} else {
statusDiv.style.display = 'none';
statusDiv.innerHTML = '';
}
}
// Global for edit
let myEditingExclusionId = null;
function openEditMyExclusion(id, data) {
myEditingExclusionId = id;
const modal = document.getElementById('userExclusionModal');
const radioForever = document.querySelector('input[name="exclusionType"][value="forever"]');
const radioRange = document.querySelector('input[name="exclusionType"][value="range"]');
const rangeDiv = document.getElementById('exclusionDateRange');
const deleteBtn = document.getElementById('deleteExclusionBtn'); // Hide in edit mode (we have icon) or keep?
// User requested "matita a destra per la modifica ed eliminazione".
// I added trash icon to the list. So modal "Rimuovi" is redundant but harmless.
// I'll hide it for clarity.
if (deleteBtn) deleteBtn.style.display = 'none';
if (data.start_date || data.end_date) {
radioRange.checked = true;
rangeDiv.style.display = 'block';
if (data.start_date) document.getElementById('ueStartDate').value = data.start_date;
if (data.end_date) document.getElementById('ueEndDate').value = data.end_date;
} else {
radioForever.checked = true;
rangeDiv.style.display = 'none';
document.getElementById('ueStartDate').value = '';
document.getElementById('ueEndDate').value = '';
}
document.getElementById('ueNotes').value = data.notes || '';
document.querySelector('#userExclusionModal h3').textContent = 'Modifica Esclusione';
modal.style.display = 'flex';
}
async function deleteMyExclusion(id) {
if (!confirm('Rimuovere questa esclusione?')) return;
const response = await api.delete(`/api/users/me/exclusion/${id}`);
if (response && response.ok) {
utils.showMessage('Esclusione rimossa con successo', 'success');
loadExclusionStatus();
} else {
const err = await response.json();
utils.showMessage(err.detail || 'Errore rimozione', 'error');
}
}
function resetMyExclusionForm() {
document.getElementById('userExclusionForm').reset();
myEditingExclusionId = null;
document.querySelector('#userExclusionModal h3').textContent = 'Nuova Esclusione';
const rangeDiv = document.getElementById('exclusionDateRange');
rangeDiv.style.display = 'none';
document.querySelector('input[name="exclusionType"][value="forever"]').checked = true;
// Hide delete btn in modal (using list icon instead)
const deleteBtn = document.getElementById('deleteExclusionBtn');
if (deleteBtn) deleteBtn.style.display = 'none';
}
function setupExclusionListeners() {
const modal = document.getElementById('userExclusionModal');
const manageBtn = document.getElementById('manageExclusionBtn');
const closeBtn = document.getElementById('closeUserExclusionModal');
const cancelBtn = document.getElementById('cancelUserExclusion');
const form = document.getElementById('userExclusionForm');
const radioForever = document.querySelector('input[name="exclusionType"][value="forever"]');
const radioRange = document.querySelector('input[name="exclusionType"][value="range"]');
const rangeDiv = document.getElementById('exclusionDateRange');
if (manageBtn) {
manageBtn.addEventListener('click', () => {
resetMyExclusionForm();
modal.style.display = 'flex';
});
}
if (closeBtn) closeBtn.addEventListener('click', () => modal.style.display = 'none');
if (cancelBtn) cancelBtn.addEventListener('click', () => modal.style.display = 'none');
// Radio logic
radioForever.addEventListener('change', () => rangeDiv.style.display = 'none');
radioRange.addEventListener('change', () => rangeDiv.style.display = 'block');
// Save
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const type = document.querySelector('input[name="exclusionType"]:checked').value;
const payload = {
notes: document.getElementById('ueNotes').value
};
if (type === 'range') {
const start = document.getElementById('ueStartDate').value;
const end = document.getElementById('ueEndDate').value;
if (start) payload.start_date = start;
if (end) payload.end_date = end;
if (start && end && new Date(end) < new Date(start)) {
return utils.showMessage('La data di fine deve essere dopo la data di inizio', 'error');
}
} else {
payload.start_date = null;
payload.end_date = null;
}
let response;
if (myEditingExclusionId) {
response = await api.put(`/api/users/me/exclusion/${myEditingExclusionId}`, payload);
} else {
response = await api.post('/api/users/me/exclusion', payload);
}
if (response && response.ok) {
utils.showMessage('Esclusione salvata', 'success');
modal.style.display = 'none';
loadExclusionStatus();
} else {
const err = await response.json();
utils.showMessage(err.detail || 'Errore salvataggio', 'error');
}
});
}
}
// Globals
window.openEditMyExclusion = openEditMyExclusion;
window.deleteMyExclusion = deleteMyExclusion;

View File

@@ -113,6 +113,77 @@ async function loadOffices() {
// Initial update of office display
updateOfficeDisplay();
// Show export card for Admin/Manager
if (['admin', 'manager'].includes(currentUser.role)) {
const exportCard = document.getElementById('exportCard');
if (exportCard) {
exportCard.style.display = 'block';
// Set defaults (current month)
const today = new Date();
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
document.getElementById('exportStartDate').valueAsDate = firstDay;
document.getElementById('exportEndDate').valueAsDate = lastDay;
document.getElementById('exportBtn').addEventListener('click', handleExport);
}
}
}
async function handleExport() {
const startStr = document.getElementById('exportStartDate').value;
const endStr = document.getElementById('exportEndDate').value;
const officeId = document.getElementById('officeFilter').value;
if (!startStr || !endStr) {
alert('Seleziona le date di inizio e fine');
return;
}
// Construct URL
let url = `/api/reports/team-export?start_date=${startStr}&end_date=${endStr}`;
if (officeId) {
url += `&office_id=${officeId}`;
}
try {
const btn = document.getElementById('exportBtn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.textContent = 'Generazione...';
// Use fetch directly to handle blob
const token = api.getToken();
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const response = await fetch(url, { headers });
if (response.ok) {
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `report_presenze_${startStr}_${endStr}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(downloadUrl);
a.remove();
} else {
const err = await response.json();
alert('Errore export: ' + (err.detail || 'Sconosciuto'));
}
btn.disabled = false;
btn.innerHTML = originalText;
} catch (e) {
console.error(e);
alert('Errore durante l\'export');
document.getElementById('exportBtn').disabled = false;
document.getElementById('exportBtn').textContent = 'Esporta Excel';
}
}
function getDateRange() {

View File

@@ -267,26 +267,70 @@ async function loadExclusions(officeId) {
</span>
${e.notes ? `<span class="rule-note">${e.notes}</span>` : ''}
</div>
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')">
&times;
</button>
<div class="rule-actions" style="display: flex; gap: 0.5rem; align-items: center;">
<button class="btn-icon" onclick='openEditExclusion("${e.id}", ${JSON.stringify(e).replace(/'/g, "&#39;")})' title="Modifica">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</button>
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')" title="Elimina">
&times;
</button>
</div>
</div>
`).join('');
}
}
async function addExclusion(data) {
const response = await api.post(`/api/offices/${currentOfficeId}/exclusions`, data);
// Global variable to track edit mode
let editingExclusionId = null;
async function openEditExclusion(id, data) {
editingExclusionId = id;
// Populate form
populateUserSelects();
document.getElementById('exclusionUser').value = data.user_id;
// Disable user select in edit mode usually? Or allow change? API allows it.
document.getElementById('exclusionStartDate').value = data.start_date || '';
document.getElementById('exclusionEndDate').value = data.end_date || '';
document.getElementById('exclusionNotes').value = data.notes || '';
// Change modal title/button
document.querySelector('#exclusionModal h3').textContent = 'Modifica Esclusione';
document.querySelector('#exclusionForm button[type="submit"]').textContent = 'Salva Modifiche';
document.getElementById('exclusionModal').style.display = 'flex';
}
async function saveExclusion(data) {
let response;
if (editingExclusionId) {
response = await api.put(`/api/offices/${currentOfficeId}/exclusions/${editingExclusionId}`, data);
} else {
response = await api.post(`/api/offices/${currentOfficeId}/exclusions`, data);
}
if (response && response.ok) {
await loadExclusions(currentOfficeId);
document.getElementById('exclusionModal').style.display = 'none';
document.getElementById('exclusionForm').reset();
resetExclusionForm();
} else {
const error = await response.json();
alert(error.detail || 'Impossibile aggiungere l\'esclusione');
alert(error.detail || 'Impossibile salvare l\'esclusione');
}
}
function resetExclusionForm() {
document.getElementById('exclusionForm').reset();
editingExclusionId = null;
document.querySelector('#exclusionModal h3').textContent = 'Aggiungi Esclusione Parcheggio';
document.querySelector('#exclusionForm button[type="submit"]').textContent = 'Aggiungi';
}
async function deleteExclusion(id) {
if (!confirm('Eliminare questa esclusione?')) return;
const response = await api.delete(`/api/offices/${currentOfficeId}/exclusions/${id}`);
@@ -335,6 +379,10 @@ function setupEventListeners() {
modals.forEach(m => {
document.getElementById(m.btn).addEventListener('click', () => {
if (m.id !== 'closingDayModal') populateUserSelects();
// Special handling for exclusion to reset edit mode
if (m.id === 'exclusionModal') resetExclusionForm();
document.getElementById(m.id).style.display = 'flex';
});
document.getElementById(m.close).addEventListener('click', () => {
@@ -368,7 +416,7 @@ function setupEventListeners() {
document.getElementById('exclusionForm').addEventListener('submit', (e) => {
e.preventDefault();
addExclusion({
saveExclusion({
user_id: document.getElementById('exclusionUser').value,
start_date: document.getElementById('exclusionStartDate').value || null,
end_date: document.getElementById('exclusionEndDate').value || null,
@@ -380,4 +428,7 @@ function setupEventListeners() {
// Global functions
window.deleteClosingDay = deleteClosingDay;
window.deleteGuarantee = deleteGuarantee;
window.deleteClosingDay = deleteClosingDay;
window.deleteGuarantee = deleteGuarantee;
window.deleteExclusion = deleteExclusion;
window.openEditExclusion = openEditExclusion;

View File

@@ -138,6 +138,28 @@
<button id="clearAssignmentsBtn" class="btn btn-danger">
Elimina Tutte le Assegnazioni
</button>
<button id="clearPresenceBtn" class="btn btn-danger"
title="Elimina stati e assegnazioni per i giorni selezionati">
Elimina Stati
</button>
</div>
<hr style="margin: 1.5rem 0; border: 0; border-top: 1px solid #e5e7eb;">
<div class="form-group">
<label>Test Invio Email</label>
<div style="display: flex; gap: 1rem; align-items: flex-end;">
<div style="flex: 1;">
<small>Data di Riferimento (Opzionale):</small>
<input type="date" id="testEmailDate" class="form-control">
<small class="text-muted" style="display: block; margin-top: 0.25rem;">
Se non specificata, verrà usato il primo giorno lavorativo disponibile.
</small>
</div>
<button id="testEmailBtn" class="btn btn-secondary">
Test Invio Mail
</button>
</div>
</div>
</div>
</div>

View File

@@ -87,7 +87,11 @@
</div>
<div class="legend-item">
<div class="legend-color status-absent"></div>
<span>Assente</span>
<span>Ferie</span>
</div>
<div class="legend-item">
<div class="legend-color status-business_trip"></div>
<span>Trasferta</span>
</div>
</div>
</div>
@@ -155,11 +159,35 @@
</div>
<!-- Exclusion Card -->
<div class="card" id="exclusionCard" style="margin-top: 2rem;">
<div class="card-header">
<h3>Esclusione Assegnazione</h3>
</div>
<div class="card-body">
<p class="text-muted" style="margin-bottom: 1rem;">
Puoi decidere di escluderti automaticamente dalla logica di assegnazione dei posti auto.
Le richieste di esclusione sono visibili agli amministratori.
</p>
<div id="exclusionStatusDisplay"
style="display: none; padding: 1rem; border-radius: 6px; margin-bottom: 1rem; background-color: #f3f4f6; border: 1px solid #e5e7eb;">
<!-- Filled by JS -->
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-dark" id="manageExclusionBtn">Gestisci Esclusione</button>
</div>
</div>
</div>
<div class="card parking-map-card" style="margin-top: 2rem;">
<h3>Mappa Parcheggio</h3>
<img src="/assets/parking-map.png" alt="Mappa Parcheggio"
style="width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--border);">
</div>
</div> <!-- End parking-map-card -->
</div>
</main>
@@ -199,7 +227,11 @@
</button>
<button type="button" class="status-btn qe-status-btn" data-status="absent">
<div class="status-icon status-absent"></div>
<span>Assente</span>
<span>Ferie</span>
</button>
<button type="button" class="status-btn qe-status-btn" data-status="business_trip">
<div class="status-icon status-business_trip"></div>
<span>Trasferta</span>
</button>
<button type="button" class="status-btn qe-status-btn" data-status="clear">
<div class="status-icon"
@@ -241,7 +273,11 @@
</button>
<button class="status-btn" data-status="absent">
<div class="status-icon status-absent"></div>
<span>Assente</span>
<span>Ferie</span>
</button>
<button class="status-btn" data-status="business_trip">
<div class="status-icon status-business_trip"></div>
<span>Trasferta</span>
</button>
</div>
@@ -276,6 +312,59 @@
</div>
</div>
</div>
</div>
<!-- User Exclusion Modal -->
<div class="modal" id="userExclusionModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Gestisci Esclusione</h3>
<button class="modal-close" id="closeUserExclusionModal">&times;</button>
</div>
<div class="modal-body">
<form id="userExclusionForm">
<div class="form-group">
<label style="font-weight: 500; margin-bottom: 0.5rem; display: block;">Durata
Esclusione</label>
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="radio" name="exclusionType" value="forever" checked>
<span>Tempo Indeterminato</span>
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="radio" name="exclusionType" value="range">
<span>Periodo Specifico</span>
</label>
</div>
</div>
<div id="exclusionDateRange" style="display: none;">
<div class="form-group">
<label for="ueStartDate">Data Inizio</label>
<input type="date" id="ueStartDate" class="form-control">
</div>
<div class="form-group">
<label for="ueEndDate">Data Fine</label>
<input type="date" id="ueEndDate" class="form-control">
</div>
</div>
<div class="form-group">
<label for="ueNotes">Motivo (opzionale)</label>
<textarea id="ueNotes" class="form-control" rows="2"
placeholder="Es. Lavoro da remoto per un mese..."></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-danger" id="deleteExclusionBtn"
style="display: none; margin-right: auto;">Rimuovi</button>
<button type="button" class="btn btn-secondary" id="cancelUserExclusion">Annulla</button>
<button type="submit" class="btn btn-dark">Salva</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -74,9 +74,9 @@
<small class="text-muted">Il ruolo è assegnato dal tuo amministratore</small>
</div>
<div class="form-group">
<label for="manager">Manager</label>
<label for="manager">Ufficio</label>
<input type="text" id="manager" disabled>
<small class="text-muted">Il tuo manager è assegnato dall'amministratore</small>
<small class="text-muted">Il tuo ufficio è assegnato dall'amministratore</small>
</div>
<div class="form-actions" id="profileActions">
<button type="submit" class="btn btn-dark">Salva Modifiche</button>
@@ -139,7 +139,7 @@
document.getElementById('name').value = profile.name || '';
document.getElementById('email').value = profile.email;
document.getElementById('role').value = profile.role;
document.getElementById('manager').value = profile.manager_name || 'Nessuno';
document.getElementById('manager').value = profile.office_name || 'Nessuno';
// LDAP mode adjustments
if (isLdapUser) {

View File

@@ -54,17 +54,7 @@
</div>
<div class="card-body">
<form id="notificationForm">
<div class="form-group">
<label class="toggle-label">
<span>Riepilogo Settimanale</span>
<label class="toggle-switch">
<input type="checkbox" id="notifyWeeklyParking">
<span class="toggle-slider"></span>
</label>
</label>
<small class="text-muted">Ricevi il riepilogo settimanale delle assegnazioni parcheggio ogni
Venerdì alle 12:00</small>
</div>
<div class="form-group">
<label class="toggle-label">
<span>Promemoria Giornaliero</span>
@@ -141,7 +131,7 @@
// Notification settings
// Notification settings
document.getElementById('notifyWeeklyParking').checked = currentUser.notify_weekly_parking !== 0;
document.getElementById('notifyDailyParking').checked = currentUser.notify_daily_parking !== 0;
document.getElementById('notifyDailyHour').value = currentUser.notify_daily_parking_hour || 8;
document.getElementById('notifyDailyMinute').value = currentUser.notify_daily_parking_minute || 0;
@@ -164,7 +154,7 @@
e.preventDefault();
const data = {
notify_weekly_parking: document.getElementById('notifyWeeklyParking').checked ? 1 : 0,
notify_weekly_parking: 0,
notify_daily_parking: document.getElementById('notifyDailyParking').checked ? 1 : 0,
notify_daily_parking_hour: parseInt(document.getElementById('notifyDailyHour').value),
notify_daily_parking_minute: parseInt(document.getElementById('notifyDailyMinute').value),

View File

@@ -59,6 +59,7 @@
</select>
</div>
<div id="office-display-header"
style="margin-bottom: 1rem; font-weight: 600; font-size: 1.1rem; color: var(--text);">
Ufficio: <span id="currentOfficeNameDisplay" style="color: var(--primary);">Loading...</span>
@@ -100,8 +101,36 @@
</div>
<div class="legend-item">
<div class="legend-color status-absent"></div>
<span>Assente</span>
<span>Ferie</span>
</div>
<div class="legend-item">
<div class="legend-color status-business_trip"></div>
<span>Trasferta</span>
</div>
</div>
</div>
<!-- Export Card (Admin/Manager only) -->
<div id="exportCard" class="card" style="display: none;">
<h3 style="margin-top: 0; margin-bottom: 1rem; font-size: 1.1rem;">Esporta Report</h3>
<div style="display: flex; gap: 1rem; align-items: flex-end;">
<div>
<small style="display: block; margin-bottom: 0.25rem;">Da:</small>
<input type="date" id="exportStartDate" class="form-control">
</div>
<div>
<small style="display: block; margin-bottom: 0.25rem;">A:</small>
<input type="date" id="exportEndDate" class="form-control">
</div>
<button id="exportBtn" class="btn btn-dark" style="height: 38px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" style="margin-right: 0.5rem;">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Esporta Excel
</button>
</div>
</div>
</div>
@@ -127,7 +156,11 @@
</button>
<button class="status-btn" data-status="absent">
<div class="status-icon status-absent"></div>
<span>Assente</span>
<span>Ferie</span>
</button>
<button class="status-btn" data-status="business_trip">
<div class="status-icon status-business_trip"></div>
<span>Trasferta</span>
</button>
</div>
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella

View File

@@ -97,6 +97,8 @@ app.include_router(users_router)
app.include_router(offices_router)
app.include_router(presence_router)
app.include_router(parking_router)
from app.routes.reports import router as reports_router
app.include_router(reports_router)
# Static Files
app.mount("/css", StaticFiles(directory=str(config.FRONTEND_DIR / "css")), name="css")

View File

@@ -10,3 +10,4 @@ slowapi==0.1.9
python-multipart==0.0.9
idna<4,>=2.5
email-validator>=2.1.0.post1
openpyxl>=3.1.2

View File

@@ -220,75 +220,7 @@ def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Sessi
return False
def send_weekly_parking_summary(user: "User", next_week_dates: List[date], db: "Session") -> bool:
"""Send weekly parking assignment summary for next week (Friday at 12)"""
from database.models import DailyParkingAssignment, NotificationLog
from services.parking import get_spot_display_name
if not user.notify_weekly_parking:
return False
week_ref = get_week_reference(next_week_dates[0])
# Check if already sent for this week
existing = db.query(NotificationLog).filter(
NotificationLog.user_id == user.id,
NotificationLog.notification_type == NotificationType.WEEKLY_PARKING,
NotificationLog.reference_date == week_ref
).first()
if existing:
return False
# Get parking assignments for next week
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user.id,
DailyParkingAssignment.date.in_(next_week_dates)
).all()
if not assignments:
return False
# Build assignment list
assignment_lines = []
# a.date is now a date object
for a in sorted(assignments, key=lambda x: x.date):
day_name = a.date.strftime("%A")
spot_name = get_spot_display_name(a.spot_id, a.office_id, db)
assignment_lines.append(f"<li>{day_name}, {a.date.strftime('%B %d')}: Spot {spot_name}</li>")
start_date = next_week_dates[0].strftime("%B %d")
end_date = next_week_dates[-1].strftime("%B %d, %Y")
subject = f"Your parking spots for {start_date} - {end_date}"
body_html = f"""
<html>
<body>
<h2>Weekly Parking Summary</h2>
<p>Hi {user.name},</p>
<p>Here are your parking spot assignments for the upcoming week:</p>
<ul>
{''.join(assignment_lines)}
</ul>
<p>Parking assignments are now frozen. You can still release or reassign your spots if needed.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
if send_email(user.email, subject, body_html):
log = NotificationLog(
id=generate_uuid(),
user_id=user.id,
notification_type=NotificationType.WEEKLY_PARKING,
reference_date=week_ref,
sent_at=datetime.utcnow()
)
db.add(log)
db.commit()
return True
return False
def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session") -> bool:
@@ -377,11 +309,6 @@ def run_scheduled_notifications(db: "Session"):
next_week = get_next_week_dates(today_date)
send_presence_reminder(user, next_week, db)
# Friday at 12: Weekly parking summary
if current_weekday == 4 and current_hour == 12 and current_minute < 5:
next_week = get_next_week_dates(today_date)
send_weekly_parking_summary(user, next_week, db)
# Daily parking reminder at user's preferred time (working days only)
if current_weekday < 5: # Monday to Friday
user_hour = user.notify_daily_parking_hour or 8

52
services/offices.py Normal file
View File

@@ -0,0 +1,52 @@
from sqlalchemy.orm import Session
from database.models import OfficeSpot
from utils.helpers import generate_uuid
def sync_office_spots(office_id: str, quota: int, prefix: str, db: Session):
"""
Synchronize OfficeSpot records with the office quota.
- If active spots < quota: Create new spots
- If active spots > quota: Remove highest numbered spots (Cascade handles assignments)
- If prefix changes: Rename all spots
"""
# Get all current spots sorted by number
current_spots = db.query(OfficeSpot).filter(
OfficeSpot.office_id == office_id
).order_by(OfficeSpot.spot_number).all()
# 1. Handle Prefix Change
# If prefix changed, we need to update names of ALL existing spots
# We do this first to ensure names are correct even if we don't add/remove
if current_spots:
first_spot = current_spots[0]
# Check simple heuristic: does name start with prefix?
# Better: we can't easily know old prefix from here without querying Office,
# but we can just re-generate names for all valid spots.
for spot in current_spots:
expected_name = f"{prefix}{spot.spot_number}"
if spot.name != expected_name:
spot.name = expected_name
current_count = len(current_spots)
# 2. Add Spots
if current_count < quota:
for i in range(current_count + 1, quota + 1):
new_spot = OfficeSpot(
id=generate_uuid(),
office_id=office_id,
spot_number=i,
name=f"{prefix}{i}",
is_unavailable=False
)
db.add(new_spot)
# 3. Remove Spots
elif current_count > quota:
# Identify spots to remove (highest numbers)
spots_to_remove = current_spots[quota:]
for spot in spots_to_remove:
db.delete(spot)
db.commit()

View File

@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import or_
from database.models import (
DailyParkingAssignment, User, UserPresence, Office,
DailyParkingAssignment, User, UserPresence, Office, OfficeSpot,
ParkingGuarantee, ParkingExclusion, OfficeClosingDay, OfficeWeeklyClosingDay,
UserRole, PresenceStatus
)
@@ -23,45 +23,20 @@ from app import config
def get_spot_prefix(office: Office, db: Session) -> str:
"""Get the spot prefix for an office (from office.spot_prefix or auto-assign)"""
# Logic moved to Office creation/update mostly, but keeping helper if needed
if office.spot_prefix:
return office.spot_prefix
# Auto-assign based on alphabetical order of offices without prefix
offices = db.query(Office).filter(
Office.spot_prefix == None
).order_by(Office.name).all()
# Find existing prefixes
existing_prefixes = set(
o.spot_prefix for o in db.query(Office).filter(
Office.spot_prefix != None
).all()
)
# Find first available letter
office_index = next((i for i, o in enumerate(offices) if o.id == office.id), 0)
letter = 'A'
count = 0
while letter in existing_prefixes or count < office_index:
if letter not in existing_prefixes:
count += 1
letter = chr(ord(letter) + 1)
if ord(letter) > ord('Z'):
letter = 'A'
break
return letter
return "A" # Fallback
def get_spot_display_name(spot_id: str, office_id: str, db: Session) -> str:
"""Get display name for a spot (e.g., 'A3' instead of 'spot-3')"""
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
return spot_id
prefix = get_spot_prefix(office, db)
spot_number = spot_id.replace("spot-", "")
return f"{prefix}{spot_number}"
"""Get display name for a spot"""
# Now easy: fetch from OfficeSpot
# But wait: spot_id in assignment IS the OfficeSpot.id
spot = db.query(OfficeSpot).filter(OfficeSpot.id == spot_id).first()
if spot:
return spot.name
return "Unknown"
def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
@@ -95,35 +70,36 @@ def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
def initialize_parking_pool(office_id: str, quota: int, pool_date: date, db: Session) -> int:
"""Initialize empty parking spots for an office's pool on a given date.
Returns 0 if it's a closing day (no parking available).
"""
# Don't create pool on closing days
Get total capacity for the date.
(Legacy name kept for compatibility, but now it just returns count).
"""
if is_closing_day(office_id, pool_date, db):
return 0
existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date
return db.query(OfficeSpot).filter(
OfficeSpot.office_id == office_id,
OfficeSpot.is_unavailable == False
).count()
if existing > 0:
return existing
for i in range(1, quota + 1):
spot = DailyParkingAssignment(
id=generate_uuid(),
date=pool_date,
spot_id=f"spot-{i}",
user_id=None,
office_id=office_id,
created_at=datetime.now(timezone.utc)
)
db.add(spot)
db.commit()
config.logger.debug(f"Initialized {quota} parking spots for office {office_id} on {pool_date}")
return quota
def get_available_spots(office_id: str, pool_date: date, db: Session) -> list[OfficeSpot]:
"""Get list of unassigned OfficeSpots for a date"""
# 1. Get all active spots
all_spots = db.query(OfficeSpot).filter(
OfficeSpot.office_id == office_id,
OfficeSpot.is_unavailable == False
).all()
# 2. Get assigned spot IDs
assigned_ids = db.query(DailyParkingAssignment.spot_id).filter(
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date
).all()
assigned_set = {a[0] for a in assigned_ids}
# 3. Filter
return [s for s in all_spots if s.id not in assigned_set]
def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
@@ -151,21 +127,31 @@ def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
def is_user_excluded(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
"""Check if user is excluded from parking for this date"""
exclusion = db.query(ParkingExclusion).filter(
exclusions = db.query(ParkingExclusion).filter(
ParkingExclusion.office_id == office_id,
ParkingExclusion.user_id == user_id
).first()
).all()
if not exclusion:
if not exclusions:
return False
# Check date range
if exclusion.start_date and check_date < exclusion.start_date:
return False
if exclusion.end_date and check_date > exclusion.end_date:
return False
# Check against all exclusions
for exclusion in exclusions:
# If any exclusion covers this date, user is excluded
# Check date range
start_ok = True
if exclusion.start_date and check_date < exclusion.start_date:
start_ok = False
end_ok = True
if exclusion.end_date and check_date > exclusion.end_date:
end_ok = False
if start_ok and end_ok:
return True
return True
return False
def has_guarantee(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
@@ -227,47 +213,45 @@ def get_users_wanting_parking(office_id: str, pool_date: date, db: Session) -> l
return candidates
def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
"""
Assign parking spots fairly based on parking ratio.
Called after presence is set for a date.
Returns {assigned: [...], waitlist: [...]}
Creates new DailyParkingAssignment rows only for assigned users.
"""
office = db.query(Office).filter(Office.id == office_id).first()
if not office or not office.parking_quota:
return {"assigned": [], "waitlist": []}
# No parking on closing days
if is_closing_day(office_id, pool_date, db):
return {"assigned": [], "waitlist": [], "closed": True}
# Initialize pool
initialize_parking_pool(office_id, office.parking_quota, pool_date, db)
return {"assigned": [], "waitlist": [], "closed": True}
# Get candidates sorted by fairness
candidates = get_users_wanting_parking(office_id, pool_date, db)
# Get available spots
free_spots = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id == None
).all()
# Get available spots (OfficeSpots not yet in assignments table)
free_spots = get_available_spots(office_id, pool_date, db)
assigned = []
waitlist = []
for candidate in candidates:
if free_spots:
# Sort spots by number to fill A1, A2... order
free_spots.sort(key=lambda s: s.spot_number)
spot = free_spots.pop(0)
spot.user_id = candidate["user_id"]
# Create assignment
assignment = DailyParkingAssignment(
id=generate_uuid(),
date=pool_date,
spot_id=spot.id,
user_id=candidate["user_id"],
office_id=office_id,
created_at=datetime.now(timezone.utc)
)
db.add(assignment)
assigned.append(candidate["user_id"])
else:
waitlist.append(candidate["user_id"])
db.commit()
return {"assigned": assigned, "waitlist": waitlist}
@@ -281,15 +265,29 @@ def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session
if not assignment:
return False
# Capture spot ID before deletion
spot_id = assignment.spot_id
# Release the spot
assignment.user_id = None
# Release the spot (Delete the row)
db.delete(assignment)
db.commit()
# Try to assign to next user in fairness queue
candidates = get_users_wanting_parking(office_id, pool_date, db)
if candidates:
assignment.user_id = candidates[0]["user_id"]
top_candidate = candidates[0]
# Create new assignment for top candidate
new_assignment = DailyParkingAssignment(
id=generate_uuid(),
date=pool_date,
spot_id=spot_id,
user_id=top_candidate["user_id"],
office_id=office_id,
created_at=datetime.now(timezone.utc)
)
db.add(new_assignment)
db.commit()
return True
@@ -309,8 +307,7 @@ def handle_presence_change(user_id: str, change_date: date, old_status: Presence
if not office or not office.parking_quota:
return
# Initialize pool if needed
initialize_parking_pool(office.id, office.parking_quota, change_date, db)
# No initialization needed for sparse model
if old_status == PresenceStatus.PRESENT and new_status in [PresenceStatus.REMOTE, PresenceStatus.ABSENT]:
# User no longer coming - release their spot (will auto-reassign)
@@ -344,13 +341,12 @@ def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Sessi
"""
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id != None
DailyParkingAssignment.date == pool_date
).all()
count = len(assignments)
for a in assignments:
a.user_id = None
db.delete(a)
db.commit()
return count

View File

@@ -18,7 +18,15 @@ security = HTTPBearer(auto_error=False)
def is_admin_from_groups(groups: list[str]) -> bool:
"""Check if user is admin based on Authelia groups"""
return config.AUTHELIA_ADMIN_GROUP in groups
admin_group = config.AUTHELIA_ADMIN_GROUP
is_admin = admin_group in groups
# Case-insensitive check fallback (just in case)
if not is_admin:
is_admin = admin_group.lower() in [g.lower() for g in groups]
print(f"[Authelia] Admin Check: User Groups={groups}, Configured Admin Group='{admin_group}' -> Is Admin? {is_admin}")
return is_admin
def get_or_create_authelia_user(
@@ -43,19 +51,19 @@ def get_or_create_authelia_user(
# Only sync admin status from LLDAP, other roles managed by app admin
if is_admin and user.role != "admin":
user.role = "admin"
user.updated_at = datetime.utcnow().isoformat()
user.updated_at = datetime.utcnow()
db.commit()
db.refresh(user)
elif not is_admin and user.role == "admin":
# Removed from parking_admins group -> demote to employee
user.role = "employee"
user.updated_at = datetime.utcnow().isoformat()
user.updated_at = datetime.utcnow()
db.commit()
db.refresh(user)
# Update name if changed
if user.name != name and name:
user.name = name
user.updated_at = datetime.utcnow().isoformat()
user.updated_at = datetime.utcnow()
db.commit()
db.refresh(user)
return user
@@ -67,8 +75,8 @@ def get_or_create_authelia_user(
name=name or email.split("@")[0],
role="admin" if is_admin else "employee",
password_hash=None, # No password for Authelia users
created_at=datetime.utcnow().isoformat(),
updated_at=datetime.utcnow().isoformat()
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(user)
db.commit()