diff --git a/Caddyfile.snippet b/Caddyfile.snippet deleted file mode 100644 index db04d5f..0000000 --- a/Caddyfile.snippet +++ /dev/null @@ -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 -} diff --git a/README.md b/README.md index eb8d8f9..8f603f6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/config.py b/app/config.py index eefe2dd..0fba526 100644 --- a/app/config.py +++ b/app/config.py @@ -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) diff --git a/app/routes/auth.py b/app/routes/auth.py index 22571a3..d274752 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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 } diff --git a/app/routes/offices.py b/app/routes/offices.py index e236bc6..553d9d1 100644 --- a/app/routes/offices.py +++ b/app/routes/offices.py @@ -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, diff --git a/app/routes/parking.py b/app/routes/parking.py index 8cd2c36..792feac 100644 --- a/app/routes/parking.py +++ b/app/routes/parking.py @@ -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""" + +
+Hi {current_user.name},
+This is a test email triggered from the Parking Manager Test Tools.
+Selected Date: {target_date}
+SMTP Status: {'Enabled' if config.SMTP_ENABLED else 'Disabled (File Logging)'}
+If you received this, the notification system is working.
+ + + """ + + 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" + } diff --git a/app/routes/presence.py b/app/routes/presence.py index 1e073eb..e989222 100644 --- a/app/routes/presence.py +++ b/app/routes/presence.py @@ -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 + } diff --git a/app/routes/reports.py b/app/routes/reports.py new file mode 100644 index 0000000..4801d33 --- /dev/null +++ b/app/routes/reports.py @@ -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 + ) diff --git a/app/routes/users.py b/app/routes/users.py index 76e2040..a258961 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -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"} diff --git a/compose.yml b/compose.yml index cd61b3e..28d6289 100644 --- a/compose.yml +++ b/compose.yml @@ -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 \ No newline at end of file diff --git a/database/models.py b/database/models.py index 9644375..76d0494 100644 --- a/database/models.py +++ b/database/models.py @@ -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), + ) diff --git a/fix_db_index.py b/fix_db_index.py new file mode 100644 index 0000000..7502c5a --- /dev/null +++ b/fix_db_index.py @@ -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}") diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 3a36154..a77af61 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -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; } diff --git a/frontend/js/api.js b/frontend/js/api.js index 8fa4dad..4ffec65 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -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; } }; diff --git a/frontend/js/nav.js b/frontend/js/nav.js index c73c865..6f81416 100644 --- a/frontend/js/nav.js +++ b/frontend/js/nav.js @@ -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 = ` ++ Il tuo account ${currentUser.email} è attivo, ma non sei ancora stato assegnato a nessuno ufficio. +
++ Puoi decidere di escluderti automaticamente dalla logica di assegnazione dei posti auto. + Le richieste di esclusione sono visibili agli amministratori. +
+ + + +
- Hi {user.name},
-Here are your parking spot assignments for the upcoming week:
-Parking assignments are now frozen. You can still release or reassign your spots if needed.
-Best regards,
Parking Manager