Compare commits

..

8 Commits

Author SHA1 Message Date
a7ef46640d added cache system 2026-02-12 22:44:44 +01:00
991569d9eb Update compose.yml 2026-02-12 19:37:37 +00:00
8f5c1e1f94 fix scheduler orario start, immagine parcheggio, added more logs function 2026-02-12 19:57:00 +01:00
a94ec11c80 ad TIMEZONE and more 2026-02-09 12:31:06 +01:00
efa7533179 fix mail sand 2 2026-02-08 19:18:58 +01:00
e0b18fd3c3 fixing mail 2026-02-08 18:08:19 +01:00
ae099f04cf fixing 2026-02-08 17:31:32 +01:00
5f4ef6faee aggiunti trasferte, export excel, miglioramenti generali 2026-02-04 12:55:04 +01:00
36 changed files with 2087 additions and 616 deletions

81
.env.prod Normal file
View File

@@ -0,0 +1,81 @@
# Parking Manager Configuration
# =============================================================================
# REQUIRED - Security
# =============================================================================
# MUST be set to a random string of at least 32 characters
# Generate with: openssl rand -hex 32
SECRET_KEY=766299d3235f79a2a9a35aafbc90bec7102f250dfe4aba83500b98e568289b7a
# =============================================================================
# Server
# =============================================================================
# Usa 0.0.0.0 per permettere connessioni dall'esterno del container (essenziale per Docker/Traefik)
HOST=0.0.0.0
PORT=8000
# Timezone per l'applicazione (cronjobs, notifiche, ecc.)
TIMEZONE=Europe/Rome
# Database (SQLite path)
# Percorso assoluto nel container
DATABASE_PATH=/app/data/parking.db
# Lascia vuoto DATABASE_URL per costruirlo automaticamente da DATABASE_PATH
# Oppure usa: DATABASE_URL=sqlite:////app/data/parking.db
# CORS (comma-separated origins)
#ALLOWED_ORIGINS=https://parking.rocketscale.it
# JWT token expiration (minutes, default 24 hours)
ACCESS_TOKEN_EXPIRE_MINUTES=1440
COOKIE_SECURE=true
# Logging level (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO
# =============================================================================
# Rate Limiting
# =============================================================================
# Number of requests allowed per window for sensitive endpoints (login, register)
RATE_LIMIT_REQUESTS=5
# Window size in seconds
RATE_LIMIT_WINDOW=60
# =============================================================================
# Authentication
# =============================================================================
# Set to true when behind Authelia reverse proxy
AUTHELIA_ENABLED=true
# Header names (only change if your proxy uses different headers)
AUTHELIA_HEADER_USER=Remote-User
AUTHELIA_HEADER_NAME=Remote-Name
AUTHELIA_HEADER_EMAIL=Remote-Email
AUTHELIA_HEADER_GROUPS=Remote-Groups
# LLDAP group that maps to admin role
AUTHELIA_ADMIN_GROUP=parking_admins
# External URLs for Authelia mode (used for landing page buttons)
# Login URL - Authelia's login page (users are redirected here to authenticate)
AUTHELIA_LOGIN_URL=https://auth.rocketscale.it
# Registration URL - External registration portal (org-stack self-registration)
REGISTRATION_URL=https://register.rocketscale.it
# Logout URL
AUTHELIA_LOGOUT_URL=https://auth.rocketscale.it/logout
# =============================================================================
# Email Notifications
# =============================================================================
# Set to true to enable email sending
SMTP_ENABLED=true
# SMTP server configuration
SMTP_HOST="smtp.email.eu-milan-1.oci.oraclecloud.com"
SMTP_PORT=587
SMTP_USER="ocid1.user.oc1..aaaaaaaa6bollovnlx4vxoq2eh7pzgxxhludqitgxsp6fevpllmqynug2uiq@ocid1.tenancy.oc1..aaaaaaaa6veuezxddkzbxmxnjp5thywdjz42z5qfrd6mmosmqehvebrewola.hj.com"
SMTP_PASSWORD="3)J2E9_Np:}#kozD2Wed"
SMTP_FROM="noreply@rocketscale.it"
SMTP_USE_TLS=true
# When SMTP is disabled, emails are logged to this file
EMAIL_LOG_FILE=/tmp/parking-emails.log

1
.gitignore vendored
View File

@@ -25,7 +25,6 @@ ENV/
# Environment variables # Environment variables
.env .env
.env.local .env.local
.env.production
# IDE # IDE
.idea/ .idea/

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
}

214
README.md
View File

@@ -1,214 +0,0 @@
# Org-Parking
Un'applicazione leggera gestionale per i parcheggi aziendali, progettata per le organizzazioni. Offre un algoritmo di assegnazione equa, tracciamento delle presenze ed è ottimizzata per basse risorse.
## Funzionalità
- **Modello Centrato sull'Ufficio**: I posti auto appartengono agli Uffici, non ai Manager o agli Utenti.
- **Algoritmo di Assegnazione Equa**: Gli utenti con il rapporto parcheggi/presenze più basso hanno la priorità.
- **Tracciamento Presenze**: Marcatura delle presenze basata su calendario (presente/remoto/assente).
- **Regole di Chiusura Flessibili**: Supporto per chiusure in date specifiche e chiusure settimanali ricorrenti per ufficio.
- **Garanzie ed Esclusioni**: Regole di parcheggio per utente gestite dagli amministratori dell'ufficio.
- **Accesso Basato sui Ruoli**:
- **Admin**: Controllo completo del sistema, creazione uffici, gestione utenti.
- **Manager**: Gestisce le impostazioni del proprio ufficio e il team.
- **Impiegato**: Segna presenza, visualizza calendario, controlla stato parcheggio.
- **Basso Consumo di Memoria**: Ottimizzato per piccoli server (<500MB RAM).
- **Autenticazione**: Auth JWT integrata o integrazione SSO con Authelia.
## Architettura
```
app/
├── routes/ # API endpoints
│ ├── auth.py # Autenticazione
│ ├── users.py # Gestione utenti
│ ├── offices.py # Gestione uffici (quote, regole)
│ ├── presence.py # Marcatura presenze
│ └── parking.py # Logica di assegnazione
└── config.py # Configurazione
database/
├── models.py # Modelli SQLAlchemy ORM
└── connection.py # Setup Database
frontend/ # Frontend Vanilla JS pulito
├── pages/ # Viste HTML
├── js/ # Moduli logici
└── css/ # Stili
```
## Guida Rapida
### Sviluppo Locale
1. **Setup Ambiente**:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
2. **Avvio Server**:
```bash
python main.py
```
Accedi a `http://localhost:8000`
### Deployment Docker (Consigliato)
Questa applicazione è ottimizzata per ambienti con risorse limitate (es. Raspberry Pi, piccoli VPS).
1. **Build**:
```bash
docker compose build
```
2. **Run**:
```bash
docker compose up -d
```
**Nota sull'Uso della Memoria**:
Il `Dockerfile` è configurato per eseguire `uvicorn` con `--workers 1` per minimizzare l'uso della memoria (~50MB in idle). Se in esecuzione su un server più grande, puoi rimuovere questo flag nel `Dockerfile`.
## Configurazione
Copia `.env.example` in `.env` e configura:
| Variabile | Descrizione | Default |
|-----------|-------------|---------|
| `SECRET_KEY` | **Richiesto**. Chiave cripto per JWT. | `""` (Esce se mancante) |
| `DATABASE_URL` | Vedi docs SQLAlchemy. | `sqlite:///data/parking.db` |
| `AUTHELIA_ENABLED`| Abilita supporto header SSO. | `false` |
| `SMTP_ENABLED` | Abilita notifiche email. | `false` |
| `LOG_LEVEL` | Verbosità log. | `INFO` |
## Algoritmo di Equità
I posti auto vengono assegnati giornalmente basandosi su un rapporto di equità:
```
Rapporto = (Giorni Parcheggiati con successo) / (Giorni di Presenza in ufficio)
```
- Gli utenti **Garantiti** vengono assegnati per primi.
- I posti **Rimanenti** sono distribuiti agli utenti presenti iniziando da chi ha il rapporto più **basso**.
- Gli utenti **Esclusi** non ricevono mai un posto.
## API Endpoints
Di seguito la lista delle chiamate API disponibili suddivise per modulo.
### Auth (`/api/auth`)
Gestione autenticazione e sessione.
- `POST /register`: Registrazione nuovo utente (Disabilitato se Authelia è attivo).
- `POST /login`: Login con email e password (ritorna token JWT/cookie).
- `POST /logout`: Logout e invalidazione sessione.
- `GET /me`: Ritorna informazioni sull'utente corrente.
- `GET /config`: Ritorna la configurazione pubblica di autenticazione.
- `GET /holidays/{year}`: Ritorna i giorni festivi per l'anno specificato.
### Users (`/api/users`)
Gestione utenti e profili.
- `GET /`: Lista di tutti gli utenti (Solo Admin).
- `POST /`: Crea un nuovo utente (Solo Admin).
- `GET /{user_id}`: Dettaglio di un utente specifico (Solo Admin).
- `PUT /{user_id}`: Aggiorna dati di un utente (Solo Admin).
- `DELETE /{user_id}`: Elimina un utente (Solo Admin).
- `GET /me/profile`: Ottieni il proprio profilo.
- `PUT /me/profile`: Aggiorna il proprio profilo.
- `GET /me/settings`: Ottieni le proprie impostazioni.
- `PUT /me/settings`: Aggiorna le proprie impostazioni.
- `POST /me/change-password`: Modifica la propria password.
### Offices (`/api/offices`)
Gestione uffici, regole di chiusura e quote.
- `GET /`: Lista di tutti gli uffici.
- `POST /`: Crea un nuovo ufficio (Solo Admin).
- `GET /{office_id}`: Dettagli di un ufficio.
- `PUT /{office_id}`: Aggiorna configurazione ufficio (Solo Admin).
- `DELETE /{office_id}`: Elimina un ufficio (Solo Admin).
- `GET /{office_id}/users`: Lista utenti assegnati all'ufficio.
- `GET /{office_id}/closing-days`: Lista giorni di chiusura specifici.
- `POST /{office_id}/closing-days`: Aggiungi giorno di chiusura.
- `DELETE /{office_id}/closing-days/{id}`: Rimuovi giorno di chiusura.
- `GET /{office_id}/weekly-closing-days`: Lista giorni di chiusura settimanali (es. Sabato/Domenica).
- `POST /{office_id}/weekly-closing-days`: Aggiungi giorno di chiusura settimanale.
- `DELETE /{office_id}/weekly-closing-days/{id}`: Rimuovi giorno di chiusura settimanale.
- `GET /{office_id}/guarantees`: Lista utenti con posto garantito.
- `POST /{office_id}/guarantees`: Aggiungi garanzia posto.
- `DELETE /{office_id}/guarantees/{id}`: Rimuovi garanzia.
- `GET /{office_id}/exclusions`: Lista utenti esclusi dal parcheggio.
- `POST /{office_id}/exclusions`: Aggiungi esclusione.
- `DELETE /{office_id}/exclusions/{id}`: Rimuovi esclusione.
### Presence (`/api/presence`)
Gestione presenze giornaliere.
- `POST /mark`: Segna la propria presenza per una data (Presente/Remoto/Assente).
- `GET /my-presences`: Lista delle proprie presenze.
- `DELETE /{date}`: Rimuovi la propria presenza per una data.
- `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.
### Parking (`/api/parking`)
Gestione assegnazioni posti auto.
- `POST /init-office-pool`: Inizializza i posti disponibili per un giorno.
- `GET /assignments/{date}`: Lista assegnazioni per una data.
- `GET /my-assignments`: Le mie assegnazioni parcheggio.
- `POST /run-allocation`: Esegui manualmente l'algoritmo di assegnazione per una data.
- `POST /clear-assignments`: Cancella tutte le assegnazioni per una data.
- `POST /manual-assign`: Assegna manualmente un posto a un utente.
- `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.
## Utilizzo con AUTHELIA
Org-Parking supporta l'integrazione nativa con **Authelia** (o altri provider SSO compatibili con Forward Auth). Questo permette il Single Sign-On (SSO) e la gestione centralizzata degli utenti.
### Configurazione
1. **Abilita Authelia**:
Nel file `.env`, imposta `AUTHELIA_ENABLED=true`.
2. **Configura gli Header del Proxy**:
Assicurati che il tuo reverse proxy (es. Traefik, Nginx) passi i seguenti header all'applicazione dopo l'autenticazione:
* `Remote-User`: Username dell'utente (spesso uguale all'email).
* `Remote-Email`: Email dell'utente.
* `Remote-Name`: Nome completo dell'utente (Opzionale).
* `Remote-Groups`: Gruppi di appartenenza (separati da virgola).
3. **Gestione Admin**:
L'applicazione assegna automaticamente il ruolo di **Admin** agli utenti che appartengono al gruppo specificato nella variabile `AUTHELIA_ADMIN_GROUP` (default: `parking_admins`).
* Se un utente viene rimosso da questo gruppo su Authelia, perderà i privilegi di admin al login successivo.
* Gli altri ruoli (Manager, Employee) e l'assegnazione agli uffici vengono gestiti manualmente all'interno di Org-Parking da un amministratore.
### Comportamento
* **Creazione Utenti**: Gli utenti vengono creati automaticamente nel database di Org-Parking al loro primo accesso riuscito tramite Authelia.
* **Login/Logout**: Le pagine di login e registrazione interne vengono disabilitate o reindirizzano al portale SSO.
* **Password**: Org-Parking non gestisce le password in questa modalità; l'autenticazione è interamente delegata al provider esterno.
* **Sicurezza**: L'applicazione si fida degli header `Remote-*` solo se `AUTHELIA_ENABLED` è attivo. Assicurarsi che il container non sia esposto direttamente a internet senza passare per il proxy di autenticazione.
## Note di Deployment
- **Database**: SQLite è usato di default per semplicità e basso overhead. I dati persistono in `./data/parking.db`.
- **Sicurezza**:
- Rate limiting è attivo sugli endpoint sensibili (Login/Register).
- Le password sono hashate con Bcrypt.
- L'autenticazione via cookie è sicura di default.
### Risoluzione Problemi Comuni
**Errore: "Il reindirizzamento è stato determinato per essere non sicuro"**
Questo errore appare se si tenta di accedere all'applicazione tramite HTTP (es. `http://lvh.me:8000`) mentre Authelia è su HTTPS. Authelia blocca i reindirizzamenti verso protocolli non sicuri.
**Soluzione**: Accedere all'applicazione tramite il Reverse Proxy (Caddy) usando HTTPS, ad esempio `https://parking.lvh.me`. Assicurarsi che Caddy sia configurato per gestire il dominio e l'autenticazione.
## Licenza
MIT

View File

@@ -46,10 +46,12 @@ if SECRET_KEY == "change-me-in-production":
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
# Server # Server
HOST = os.getenv("HOST", "0.0.0.0") HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000")) PORT = int(os.getenv("PORT", "8000"))
TIMEZONE = os.getenv("TIMEZONE", "UTC")
# CORS # CORS
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",") ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",")
@@ -68,6 +70,7 @@ AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins")
# External URLs for Authelia mode # External URLs for Authelia mode
# When AUTHELIA_ENABLED, login redirects to Authelia and register to external portal # 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_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 REGISTRATION_URL = os.getenv("REGISTRATION_URL", "") # e.g., https://register.rocketscale.it
# Email configuration (following org-stack pattern) # Email configuration (following org-stack pattern)

View File

@@ -104,7 +104,8 @@ def login(request: Request, data: LoginRequest, response: Response, db: Session
value=token, value=token,
httponly=True, httponly=True,
max_age=config.ACCESS_TOKEN_EXPIRE_MINUTES * 60, max_age=config.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
samesite="lax" samesite="lax",
secure=config.COOKIE_SECURE
) )
config.logger.info(f"User logged in: {data.email}") config.logger.info(f"User logged in: {data.email}")
@@ -114,7 +115,12 @@ def login(request: Request, data: LoginRequest, response: Response, db: Session
@router.post("/logout") @router.post("/logout")
def logout(response: Response): def logout(response: Response):
"""Logout and clear session""" """Logout and clear session"""
response.delete_cookie("session_token") response.delete_cookie(
key="session_token",
httponly=True,
samesite="lax",
secure=config.COOKIE_SECURE
)
return {"message": "Logged out"} return {"message": "Logged out"}
@@ -145,6 +151,7 @@ def get_auth_config():
return { return {
"authelia_enabled": config.AUTHELIA_ENABLED, "authelia_enabled": config.AUTHELIA_ENABLED,
"login_url": config.AUTHELIA_LOGIN_URL if config.AUTHELIA_ENABLED else None, "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 "registration_url": config.REGISTRATION_URL if config.AUTHELIA_ENABLED else None
} }

View File

@@ -26,6 +26,9 @@ router = APIRouter(prefix="/api/offices", tags=["offices"])
class ValidOfficeCreate(BaseModel): class ValidOfficeCreate(BaseModel):
name: str name: str
parking_quota: int = 0 parking_quota: int = 0
booking_window_enabled: bool = True
booking_window_end_hour: int = 18
booking_window_end_minute: int = 0
class ClosingDayCreate(BaseModel): class ClosingDayCreate(BaseModel):
@@ -121,10 +124,18 @@ def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=D
name=data.name, name=data.name,
parking_quota=data.parking_quota, parking_quota=data.parking_quota,
spot_prefix=get_next_available_prefix(db), spot_prefix=get_next_available_prefix(db),
booking_window_enabled=data.booking_window_enabled,
booking_window_end_hour=data.booking_window_end_hour,
booking_window_end_minute=data.booking_window_end_minute,
created_at=datetime.utcnow() created_at=datetime.utcnow()
) )
db.add(office) db.add(office)
db.commit() 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 return office
@router.get("/{office_id}") @router.get("/{office_id}")
@@ -186,6 +197,10 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi
office.updated_at = datetime.utcnow() office.updated_at = datetime.utcnow()
db.commit() db.commit()
# Sync spots
from services.offices import sync_office_spots
sync_office_spots(office.id, office.parking_quota, office.spot_prefix, db)
return { return {
"id": office.id, "id": office.id,
"name": office.name, "name": office.name,
@@ -459,16 +474,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(): if not db.query(User).filter(User.id == data.user_id).first():
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
existing = db.query(ParkingExclusion).filter( # Relaxed unique check - user can have multiple exclusions (different periods)
ParkingExclusion.office_id == office_id, # existing = db.query(ParkingExclusion).filter(
ParkingExclusion.user_id == data.user_id # ParkingExclusion.office_id == office_id,
).first() # ParkingExclusion.user_id == data.user_id
if existing: # ).first()
raise HTTPException(status_code=400, detail="User already has a parking exclusion") # 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: 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") 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( exclusion = ParkingExclusion(
id=generate_uuid(), id=generate_uuid(),
office_id=office_id, office_id=office_id,

View File

@@ -23,7 +23,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database.connection import get_db 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 utils.auth_middleware import get_current_user, require_manager_or_admin
from services.parking import ( from services.parking import (
initialize_parking_pool, get_spot_display_name, release_user_spot, initialize_parking_pool, get_spot_display_name, release_user_spot,
@@ -91,28 +91,51 @@ def init_office_pool(request: InitPoolRequest, db: Session = Depends(get_db), cu
@router.get("/assignments/{date_val}", response_model=List[AssignmentResponse]) @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)): 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, optionally filtered by office""" """Get parking assignments for a date, merging active assignments with empty spots"""
query_date = date_val query_date = date_val
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date) # Defaults to user's office if not specified
if office_id: target_office_id = office_id or current_user.office_id
query = query.filter(DailyParkingAssignment.office_id == 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
# 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()
# 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}
assignments = query.all()
results = [] results = []
for assignment in assignments: # 3. Merge
# Get display name using office's spot prefix for spot in spots:
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) assignment = assignment_map.get(spot.id)
if assignment:
# Active assignment
result = AssignmentResponse( result = AssignmentResponse(
id=assignment.id, id=assignment.id,
date=assignment.date, date=assignment.date,
spot_id=assignment.spot_id, spot_id=spot.id, # The FK
spot_display_name=spot_display_name, spot_display_name=spot.name,
user_id=assignment.user_id, user_id=assignment.user_id,
office_id=assignment.office_id office_id=spot.office_id
) )
if assignment.user_id: if assignment.user_id:
@@ -120,6 +143,18 @@ def get_assignments(date_val: date, office_id: str = None, db: Session = Depends
if user: if user:
result.user_name = user.name result.user_name = user.name
result.user_email = user.email 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) 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
return results
@router.post("/run-allocation") @router.post("/run-allocation")
def run_fair_allocation(data: RunAllocationRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): 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)""" """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: if current_user.role != UserRole.ADMIN and not is_manager:
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this office") raise HTTPException(status_code=403, detail="Not authorized to assign spots for this office")
# Check if spot exists and is free # Check if spot exists (OfficeSpot)
spot = db.query(DailyParkingAssignment).filter( 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.office_id == data.office_id,
DailyParkingAssignment.date == assign_date, DailyParkingAssignment.date == assign_date,
DailyParkingAssignment.spot_id == data.spot_id DailyParkingAssignment.spot_id == data.spot_id
).first() ).first()
if not spot: if existing_assignment:
raise HTTPException(status_code=404, detail="Spot not found")
if spot.user_id:
raise HTTPException(status_code=400, detail="Spot already assigned") raise HTTPException(status_code=400, detail="Spot already assigned")
# Check if user already has a spot for this date (from any manager) # 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.date == assign_date,
DailyParkingAssignment.user_id == data.user_id DailyParkingAssignment.user_id == data.user_id
).first() ).first()
if existing: if user_has_spot:
raise HTTPException(status_code=400, detail="User already has a spot for this date") 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() 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_def.name}
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name}
@router.post("/release-my-spot/{assignment_id}") @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") raise HTTPException(status_code=403, detail="You can only release your own parking spot")
# Get spot display name for notification # 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() db.commit()
# Send notification (self-release, so just confirmation) # 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"} 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 old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None
# Get spot display name for notifications # 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": if data.new_user_id == "auto":
# "Auto assign" means releasing the spot so the system picks the next person # "Auto assign" means releasing the spot so the system picks the next person
@@ -308,47 +352,50 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
if existing: if existing:
raise HTTPException(status_code=400, detail="User already has a spot for this date") 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 assignment.user_id = data.new_user_id
# Send notifications # Send notifications
# Notify old user that spot was reassigned # Notify old user that spot was reassigned
if old_user and old_user.id != new_user.id: 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 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}")
else:
assignment.user_id = None
# Notify old user that spot was released
if old_user:
notify_parking_released(old_user, assignment.date, spot_display_name)
config.logger.info(f"Parking spot {spot_display_name} on {assignment.date} released by {old_user.email if old_user else 'unknown'}")
db.commit() db.commit()
db.refresh(assignment) db.refresh(assignment)
# Build response
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
result = AssignmentResponse( result = AssignmentResponse(
id=assignment.id, id=assignment.id,
date=assignment.date, date=assignment.date,
spot_id=assignment.spot_id, spot_id=assignment.spot_id,
spot_display_name=spot_display_name, spot_display_name=spot_name,
user_id=assignment.user_id, user_id=assignment.user_id,
office_id=assignment.office_id office_id=assignment.office_id
) )
if assignment.user_id: if assignment.user_id:
user = db.query(User).filter(User.id == assignment.user_id).first() result.user_name = new_user.name
if user: result.user_email = new_user.email
result.user_name = user.name
result.user_email = user.email
return result return result
else:
# Release (Delete assignment)
db.delete(assignment)
# Notify old user that spot was released
if old_user:
notify_parking_released(old_user, assignment.date, spot_name)
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}") @router.get("/eligible-users/{assignment_id}")
def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
@@ -394,3 +441,153 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
}) })
return result return result
from typing import Optional, Union
class TestEmailRequest(BaseModel):
date: Optional[str] = None
office_id: str
bulk_send: bool = False # New flag
@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 OR bulk reminder to all (Test Tool)"""
from services.notifications import send_email, send_daily_parking_reminder
from database.models import OfficeClosingDay, OfficeWeeklyClosingDay, User
from datetime import timedelta, datetime
# 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 = None
if data.date:
try:
target_date = datetime.strptime(data.date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=422, detail="Invalid date format. Use YYYY-MM-DD")
if not target_date:
# Find next open day logic (same as before)
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()
found = False
for _ in range(30):
if check_date.weekday() in weekly_closed_set:
check_date += timedelta(days=1)
continue
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
target_date = check_date if found else date.today() + timedelta(days=1)
# BULK MODE
if data.bulk_send:
# Get all assignments for this date/office
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.office_id == data.office_id,
DailyParkingAssignment.date == target_date,
DailyParkingAssignment.user_id.isnot(None)
).all()
count = 0
failed = 0
# Convert date to datetime for the existing function signature if needed, or update function
# send_daily_parking_reminder takes datetime
target_datetime = datetime.combine(target_date, datetime.min.time().replace(hour=8)) # default 8am
for assignment in assignments:
user = db.query(User).filter(User.id == assignment.user_id).first()
if user:
# Force send (bypass preference check? User said "invia mail uguale a quella di promemoria")
# We'll use the existing function but maybe bypass checks?
# send_daily_parking_reminder checks preferences & log.
# Let's bypass log check by deleting log first? Or just implement direct send here.
# User wants to "accertarsi che il sistmea funziona", so using the real function is best.
# BUT we must ensure it sends even if already sent?
# Let's clean logs for this specific test run first to ensure send?
# No, better just call send_email directly with the template logic to strictly test EMAIL sending,
# or use the function to test full LOGIC.
# "invia una mail uguale a quella di promemoria" -> Use logic.
# To force send, we can modify the function or just build the email here manually like the function does.
# Manual build is safer for a "Test Tool" to avoid messing with production logs state.
spot_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
day_name = target_date.strftime("%d/%m/%Y")
subject = f"Promemoria Parcheggio - {day_name} (Test)"
body_html = f"""
<html>
<body>
<h2>Promemoria Parcheggio Giornaliero</h2>
<p>Ciao {user.name},</p>
<p>Hai un posto auto assegnato per il giorno {day_name}:</p>
<p style="font-size: 18px; font-weight: bold;">Posto {spot_name}</p>
<p>Cordiali saluti,<br>Team Parking Manager</p>
<p><em>(Email di test inviata manualmente dall'amministrazione)</em></p>
</body>
</html>
"""
if send_email(user.email, subject, body_html):
count += 1
else:
failed += 1
return {
"success": True,
"mode": "BULK",
"count": count,
"failed": failed,
"date": target_date
}
# SINGLE USER TEST MODE (Existing)
formatted_date = target_date.strftime("%d/%m/%Y")
subject = f"Test Email - Sistema Parcheggi ({formatted_date})"
body_html = f"""
<html>
<body>
<h2>Email di Test - Sistema Parcheggi</h2>
<p>Ciao {current_user.name},</p>
<p>Questa è una email di test inviata dagli strumenti di amministrazione.</p>
<p><strong>Data Selezionata:</strong> {formatted_date}</p>
<p><strong>Stato SMTP:</strong> {'Abilitato' if config.SMTP_ENABLED else 'Disabilitato (File Logging)'}</p>
<p>Se hai ricevuto questa email, il sistema di notifiche funziona correttamente.</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 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 User Management Routes
Admin user CRUD and user self-service (profile, settings, password) 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 fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database.connection import get_db 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.auth_middleware import get_current_user, require_admin
from utils.helpers import ( from utils.helpers import (
generate_uuid, is_ldap_user, is_ldap_admin, generate_uuid, is_ldap_user, is_ldap_admin,
@@ -54,6 +54,20 @@ class ChangePasswordRequest(BaseModel):
new_password: str 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): class UserResponse(BaseModel):
id: str id: str
email: str email: str
@@ -332,3 +346,105 @@ def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db),
db.commit() db.commit()
config.logger.info(f"User {current_user.email} changed password") config.logger.info(f"User {current_user.email} changed password")
return {"message": "Password changed"} 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 restart: unless-stopped
volumes: volumes:
- ./data:/app/data - ./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_file:
- .env - .env
environment: environment:
@@ -12,7 +18,7 @@ services:
- PORT=8000 - PORT=8000
- DATABASE_PATH=/app/data/parking.db - DATABASE_PATH=/app/data/parking.db
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" ]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -20,14 +26,13 @@ services:
networks: networks:
- org-network - org-network
labels: labels:
- "caddy=parking.lvh.me" - "caddy=parcheggio.rocketscale.it"
- "caddy.reverse_proxy={{upstreams 8000}}" - "caddy.reverse_proxy={{upstreams 8000}}"
- "caddy.forward_auth=authelia:9091" - "caddy.forward_auth=authelia:9091"
- "caddy.forward_auth.uri=/api/verify?rd=https://parking.lvh.me/" - "caddy.forward_auth.uri=/api/verify?rd=https://parcheggio.rocketscale.it/"
- "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email" - "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email"
# cambiare l'url delle label per il reverse proxy
networks: networks:
org-network: org-network:
external: true external: true
name: org-stack_org-network

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 786 KiB

After

Width:  |  Height:  |  Size: 829 KiB

View File

@@ -13,7 +13,7 @@
--success: #16a34a; --success: #16a34a;
--success-bg: #dcfce7; --success-bg: #dcfce7;
--warning: #f59e0b; --warning: #f59e0b;
--warning-bg: #fef3c7; --warning-bg: #fde68a;
--danger: #dc2626; --danger: #dc2626;
--danger-bg: #fee2e2; --danger-bg: #fee2e2;
--text: #1f1f1f; --text: #1f1f1f;
@@ -652,6 +652,11 @@ textarea {
border-color: var(--danger) !important; border-color: var(--danger) !important;
} }
.status-business_trip {
background: var(--warning-bg) !important;
border-color: var(--warning) !important;
}
.status-nodata { .status-nodata {
background: white; background: white;
} }
@@ -1788,6 +1793,7 @@ textarea {
transform: translate(-50%, 100%); transform: translate(-50%, 100%);
opacity: 0; opacity: 0;
} }
to { to {
transform: translate(-50%, 0); transform: translate(-50%, 0);
opacity: 1; opacity: 1;
@@ -1798,6 +1804,7 @@ textarea {
from { from {
opacity: 1; opacity: 1;
} }
to { to {
opacity: 0; opacity: 0;
} }

View File

@@ -17,10 +17,38 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadOffices(); await loadOffices();
setupEventListeners(); setupEventListeners();
populateTimeSelects();
}); });
function populateTimeSelects() {
const hoursSelect = document.getElementById('officeCutoffHour');
const minutesSelect = document.getElementById('officeCutoffMinute');
// Clear existing
hoursSelect.innerHTML = '';
minutesSelect.innerHTML = ''; // Re-creating to allow 0-59 range
// Populate Hours 0-23
for (let i = 0; i < 24; i++) {
const val = i.toString().padStart(2, '0');
const opt = document.createElement('option');
opt.value = i;
opt.textContent = val;
hoursSelect.appendChild(opt);
}
// Populate Minutes 0-59
for (let i = 0; i < 60; i++) {
const val = i.toString().padStart(2, '0');
const opt = document.createElement('option');
opt.value = i;
opt.textContent = val;
minutesSelect.appendChild(opt);
}
}
async function loadOffices() { async function loadOffices() {
const response = await api.get('/api/offices'); const response = await api.getCached('/api/offices', 60);
if (response && response.ok) { if (response && response.ok) {
offices = await response.json(); offices = await response.json();
renderOffices(); renderOffices();
@@ -70,6 +98,11 @@ async function editOffice(officeId) {
document.getElementById('officeName').value = office.name; document.getElementById('officeName').value = office.name;
document.getElementById('officeQuota').value = office.parking_quota; document.getElementById('officeQuota').value = office.parking_quota;
// Set booking window settings
document.getElementById('officeWindowEnabled').checked = office.booking_window_enabled !== false;
document.getElementById('officeCutoffHour').value = office.booking_window_end_hour != null ? office.booking_window_end_hour : 18;
document.getElementById('officeCutoffMinute').value = office.booking_window_end_minute != null ? office.booking_window_end_minute : 0;
openModal('Modifica Ufficio'); openModal('Modifica Ufficio');
} }
@@ -82,6 +115,7 @@ async function deleteOffice(officeId) {
const response = await api.delete(`/api/offices/${officeId}`); const response = await api.delete(`/api/offices/${officeId}`);
if (response && response.ok) { if (response && response.ok) {
utils.showMessage('Ufficio eliminato', 'success'); utils.showMessage('Ufficio eliminato', 'success');
api.invalidateCache('/api/offices'); // Clear cache
await loadOffices(); await loadOffices();
} else { } else {
const error = await response.json(); const error = await response.json();
@@ -123,7 +157,10 @@ async function handleOfficeSubmit(e) {
const officeId = document.getElementById('officeId').value; const officeId = document.getElementById('officeId').value;
const data = { const data = {
name: document.getElementById('officeName').value, name: document.getElementById('officeName').value,
parking_quota: parseInt(document.getElementById('officeQuota').value) || 0 parking_quota: parseInt(document.getElementById('officeQuota').value) || 0,
booking_window_enabled: document.getElementById('officeWindowEnabled').checked,
booking_window_end_hour: parseInt(document.getElementById('officeCutoffHour').value),
booking_window_end_minute: parseInt(document.getElementById('officeCutoffMinute').value)
}; };
console.log('Payload:', data); console.log('Payload:', data);
@@ -141,6 +178,7 @@ async function handleOfficeSubmit(e) {
if (response && response.ok) { if (response && response.ok) {
closeModal(); closeModal();
utils.showMessage(officeId ? 'Ufficio aggiornato' : 'Ufficio creato', 'success'); utils.showMessage(officeId ? 'Ufficio aggiornato' : 'Ufficio creato', 'success');
api.invalidateCache('/api/offices'); // Clear cache
await loadOffices(); await loadOffices();
} else { } else {
let errorMessage = 'Errore operazione'; let errorMessage = 'Errore operazione';

View File

@@ -23,14 +23,16 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
async function loadOffices() { async function loadOffices() {
const response = await api.get('/api/offices'); // Cache offices for dropdown (60 min)
const response = await api.getCached('/api/offices', 60);
if (response && response.ok) { if (response && response.ok) {
offices = await response.json(); offices = await response.json();
} }
} }
async function loadUsers() { async function loadUsers() {
const response = await api.get('/api/users'); // Cache users list (15 min)
const response = await api.getCached('/api/users', 15);
if (response && response.ok) { if (response && response.ok) {
users = await response.json(); users = await response.json();
renderUsers(); renderUsers();
@@ -166,6 +168,8 @@ async function deleteUser(userId) {
const response = await api.delete(`/api/users/${userId}`); const response = await api.delete(`/api/users/${userId}`);
if (response && response.ok) { if (response && response.ok) {
utils.showMessage('Utente eliminato', 'success'); utils.showMessage('Utente eliminato', 'success');
api.invalidateCache('/api/users');
api.invalidateCache('/api/offices'); // Invalidate office counts
await loadUsers(); await loadUsers();
} else { } else {
const error = await response.json(); const error = await response.json();
@@ -210,6 +214,8 @@ function setupEventListeners() {
if (response && response.ok) { if (response && response.ok) {
document.getElementById('userModal').style.display = 'none'; document.getElementById('userModal').style.display = 'none';
utils.showMessage('Utente aggiornato', 'success'); utils.showMessage('Utente aggiornato', 'success');
api.invalidateCache('/api/users');
api.invalidateCache('/api/offices'); // Invalidate office counts
await loadUsers(); await loadUsers();
} else { } else {
const error = await response.json(); const error = await response.json();

View File

@@ -23,6 +23,80 @@ const api = {
*/ */
clearToken() { clearToken() {
localStorage.removeItem('access_token'); localStorage.removeItem('access_token');
this.clearCache();
},
/**
* Get data with caching - Returns Response obj or Mock Response
* @param {string} url - API endpoint
* @param {number} ttlMinutes - Time to live in minutes (default 60)
*/
async getCached(url, ttlMinutes = 60) {
const cacheKey = 'cache_' + url;
const cachedItem = localStorage.getItem(cacheKey);
if (cachedItem) {
try {
const { data, timestamp } = JSON.parse(cachedItem);
const age = (Date.now() - timestamp) / 1000 / 60;
if (age < ttlMinutes) {
console.log(`[Cache] Hit for ${url}`);
// Return a mock response-like object
return {
ok: true,
status: 200,
json: async () => data
};
} else {
console.log(`[Cache] Expired for ${url}`);
localStorage.removeItem(cacheKey);
}
} catch (e) {
console.error('[Cache] Error parsing cache', e);
localStorage.removeItem(cacheKey);
}
}
console.log(`[Cache] Miss for ${url}`);
const response = await this.get(url);
if (response && response.ok) {
try {
// Clone response to read body and still return it to caller
const clone = response.clone();
const data = await clone.json();
localStorage.setItem(cacheKey, JSON.stringify({
data: data,
timestamp: Date.now()
}));
} catch (e) {
console.warn('[Cache] failed to save to localStorage', e);
}
}
return response;
},
/**
* Invalidate specific cache key
*/
invalidateCache(url) {
localStorage.removeItem('cache_' + url);
console.log(`[Cache] Invalidated ${url}`);
},
/**
* Clear all API cache
*/
clearCache() {
Object.keys(localStorage).forEach(key => {
if (key.startsWith('cache_')) {
localStorage.removeItem(key);
}
});
console.log('[Cache] Cleared all cache');
}, },
/** /**
@@ -37,14 +111,36 @@ const api = {
* Call this on page load to verify auth status * Call this on page load to verify auth status
*/ */
async checkAuth() { async checkAuth() {
// Try to get current user - works with Authelia headers or JWT const url = '/api/auth/me';
const response = await fetch('/api/auth/me', { // 1. Try Cache (Short TTL: 5 min)
const cacheKey = 'cache_' + url;
const cachedItem = localStorage.getItem(cacheKey);
if (cachedItem) {
try {
const { data, timestamp } = JSON.parse(cachedItem);
if ((Date.now() - timestamp) / 1000 / 60 < 5) {
this._autheliaAuth = true;
return data;
}
} catch (e) { localStorage.removeItem(cacheKey); }
}
// 2. Fetch from Network
const response = await fetch(url, {
headers: this.getToken() ? { 'Authorization': `Bearer ${this.getToken()}` } : {} headers: this.getToken() ? { 'Authorization': `Bearer ${this.getToken()}` } : {}
}); });
if (response.ok) { if (response.ok) {
this._autheliaAuth = true; this._autheliaAuth = true;
return await response.json(); const data = await response.json();
// Save to Cache
localStorage.setItem(cacheKey, JSON.stringify({
data: data,
timestamp: Date.now()
}));
return data;
} }
this._autheliaAuth = false; this._autheliaAuth = false;
@@ -185,9 +281,23 @@ const api = {
* Logout * Logout
*/ */
async 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', {}); await this.post('/api/auth/logout', {});
this.clearToken(); 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) // Get user info (works with both JWT and Authelia)
const currentUser = await api.checkAuth(); const currentUser = await api.checkAuth();
// Render navigation
navContainer.innerHTML = renderNav(currentPath, currentUser?.role);
// Update user info in sidebar // Update user info in sidebar
if (currentUser) { if (currentUser) {
const userNameEl = document.getElementById('userName'); const userNameEl = document.getElementById('userName');
@@ -98,11 +95,55 @@ async function initNav() {
if (userRoleEl) userRoleEl.textContent = currentUser.role || '-'; if (userRoleEl) userRoleEl.textContent = currentUser.role || '-';
} }
// Setup user menu // Setup user menu (logout) & mobile menu
setupUserMenu(); setupUserMenu();
// Setup mobile menu
setupMobileMenu(); 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() { function setupMobileMenu() {

View File

@@ -72,6 +72,15 @@ function populateHourSelect() {
option.textContent = h.toString().padStart(2, '0'); option.textContent = h.toString().padStart(2, '0');
select.appendChild(option); select.appendChild(option);
} }
const minuteSelect = document.getElementById('bookingWindowMinute');
minuteSelect.innerHTML = '';
for (let m = 0; m < 60; m++) {
const option = document.createElement('option');
option.value = m;
option.textContent = m.toString().padStart(2, '0');
minuteSelect.appendChild(option);
}
} }
async function loadOfficeSettings(id) { async function loadOfficeSettings(id) {
@@ -143,6 +152,7 @@ function setupEventListeners() {
} }
}); });
// Test Tools
// Test Tools // Test Tools
document.getElementById('runAllocationBtn').addEventListener('click', async () => { 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; if (!confirm('Sei sicuro di voler avviare l\'assegnazione ORA? Questo potrebbe sovrascrivere le assegnazioni esistenti per la data selezionata.')) return;
@@ -166,7 +176,7 @@ function setupEventListeners() {
utils.showMessage('Avvio assegnazione...', 'success'); utils.showMessage('Avvio assegnazione...', 'success');
while (current <= end) { while (current <= end) {
const dateStr = current.toISOString().split('T')[0]; const dateStr = utils.formatDate(current);
try { try {
await api.post('/api/parking/run-allocation', { await api.post('/api/parking/run-allocation', {
date: dateStr, date: dateStr,
@@ -207,20 +217,155 @@ function setupEventListeners() {
utils.showMessage('Rimozione in corso...', 'warning'); 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) { while (current <= end) {
const dateStr = current.toISOString().split('T')[0]; const dateStr = utils.formatDate(current);
try { try {
const res = await api.post('/api/parking/clear-assignments', { const res = await api.post('/api/parking/clear-assignments', {
date: dateStr, date: dateStr,
office_id: currentOffice.id office_id: currentOffice.id
}); });
totalRemoved += (res.count || 0); if (res && res.ok) {
const data = await res.json();
totalRemoved += (data.count || 0);
}
} catch (e) { } catch (e) {
console.error(`Error clearing ${dateStr}`, e); console.error(`Error clearing ${dateStr}`, e);
} }
current.setDate(current.getDate() + 1); 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 >= 200 && res.status < 300) {
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() : {};
console.error("Test Email Error:", err);
const errMsg = err.detail ? (typeof err.detail === 'object' ? JSON.stringify(err.detail) : err.detail) : 'Invio fallito';
utils.showMessage('Errore: ' + errMsg, 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server', 'error');
}
});
}
const bulkEmailBtn = document.getElementById('bulkEmailBtn');
if (bulkEmailBtn) {
bulkEmailBtn.addEventListener('click', async () => {
const dateVal = document.getElementById('testEmailDate').value;
// Validate office
if (!currentOffice || !currentOffice.id) {
return utils.showMessage('Errore: Nessun ufficio selezionato', 'error');
}
if (!dateVal) {
return utils.showMessage('Per il test a TUTTI è obbligatorio selezionare una data specifica.', 'error');
}
if (!confirm(`Sei sicuro di voler inviare una mail di promemoria a TUTTI gli utenti con parcheggio assegnato per il giorno ${dateVal}?\n\nQuesta azione invierà vere email.`)) return;
utils.showMessage('Invio mail massive in corso...', 'warning');
try {
const res = await api.post('/api/parking/test-email', {
date: dateVal,
office_id: currentOffice.id,
bulk_send: true
});
if (res && res.status >= 200 && res.status < 300) {
const data = await res.json();
if (data.success) {
let msg = `Processo completato per il ${data.date}. Inviate: ${data.count || 0}, Fallite: ${data.failed || 0}.`;
if (data.mode === 'BULK' && (data.count || 0) === 0) msg += " (Nessun assegnatario trovato)";
utils.showMessage(msg, 'success');
} else {
utils.showMessage('Errore durante l\'invio.', 'error');
}
} else {
const err = res ? await res.json() : {};
console.error("Bulk Test Email Error:", err);
const errMsg = err.detail ? (typeof err.detail === 'object' ? JSON.stringify(err.detail) : err.detail) : 'Invio fallito';
utils.showMessage('Errore: ' + errMsg, 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server: ' + e.message, 'error');
}
});
}
} }

View File

@@ -33,6 +33,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize Parking Status // Initialize Parking Status
initParkingStatus(); initParkingStatus();
setupStatusListeners(); setupStatusListeners();
// Initialize Exclusion Logic
initExclusionLogic();
}); });
async function loadPresences() { async function loadPresences() {
@@ -337,19 +340,57 @@ function setupEventListeners() {
const promises = []; const promises = [];
let current = new Date(startDate); let current = new Date(startDate);
// Validate filtering
let skippedCount = 0;
while (current <= endDate) { while (current <= endDate) {
const dStr = current.toISOString().split('T')[0]; const dStr = current.toISOString().split('T')[0];
// 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 {
if (status === 'clear') { if (status === 'clear') {
promises.push(api.delete(`/api/presence/${dStr}`)); promises.push(api.delete(`/api/presence/${dStr}`));
} else { } else {
promises.push(api.post('/api/presence/mark', { date: dStr, status: status })); promises.push(api.post('/api/presence/mark', { date: dStr, status: status }));
} }
}
current.setDate(current.getDate() + 1); current.setDate(current.getDate() + 1);
} }
try { try {
await Promise.all(promises); await Promise.all(promises);
if (skippedCount > 0) {
utils.showMessage(`Inserimento completato! (${skippedCount} giorni chiusi ignorati)`, 'warning');
} else {
utils.showMessage('Inserimento completato!', 'success'); utils.showMessage('Inserimento completato!', 'success');
}
await Promise.all([loadPresences(), loadParkingAssignments()]); await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar(); renderCalendar();
} catch (err) { } 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

@@ -82,15 +82,14 @@ async function loadOffices() {
if (currentUser.role === 'employee') return; if (currentUser.role === 'employee') return;
} }
const response = await api.get('/api/offices'); // Cache offices list
const response = await api.getCached('/api/offices', 60);
if (response && response.ok) { if (response && response.ok) {
offices = await response.json(); offices = await response.json();
let filteredOffices = offices; let filteredOffices = offices;
if (currentUser.role === 'manager') { if (currentUser.role === 'manager') {
// Manager only sees their own office in the filter? // Manager only sees their own office in the filter?
// Actually managers might want to filter if they (hypothetically) managed multiple,
// but currently User has 1 office.
if (currentUser.office_id) { if (currentUser.office_id) {
filteredOffices = offices.filter(o => o.id === currentUser.office_id); filteredOffices = offices.filter(o => o.id === currentUser.office_id);
} else { } else {
@@ -113,6 +112,77 @@ async function loadOffices() {
// Initial update of office display // Initial update of office display
updateOfficeDisplay(); 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() { function getDateRange() {
@@ -183,8 +253,8 @@ async function loadClosingData() {
const promises = officeIdsToLoad.map(async (oid) => { const promises = officeIdsToLoad.map(async (oid) => {
try { try {
const [weeklyRes, specificRes] = await Promise.all([ const [weeklyRes, specificRes] = await Promise.all([
api.get(`/api/offices/${oid}/weekly-closing-days`), api.getCached(`/api/offices/${oid}/weekly-closing-days`, 60),
api.get(`/api/offices/${oid}/closing-days`) api.getCached(`/api/offices/${oid}/closing-days`, 60)
]); ]);
officeClosingRules[oid] = { weekly: [], specific: [] }; officeClosingRules[oid] = { weekly: [], specific: [] };

View File

@@ -267,26 +267,70 @@ async function loadExclusions(officeId) {
</span> </span>
${e.notes ? `<span class="rule-note">${e.notes}</span>` : ''} ${e.notes ? `<span class="rule-note">${e.notes}</span>` : ''}
</div> </div>
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')"> <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; &times;
</button> </button>
</div> </div>
</div>
`).join(''); `).join('');
} }
} }
async function addExclusion(data) { // Global variable to track edit mode
const response = await api.post(`/api/offices/${currentOfficeId}/exclusions`, data); 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) { if (response && response.ok) {
await loadExclusions(currentOfficeId); await loadExclusions(currentOfficeId);
document.getElementById('exclusionModal').style.display = 'none'; document.getElementById('exclusionModal').style.display = 'none';
document.getElementById('exclusionForm').reset(); resetExclusionForm();
} else { } else {
const error = await response.json(); 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) { async function deleteExclusion(id) {
if (!confirm('Eliminare questa esclusione?')) return; if (!confirm('Eliminare questa esclusione?')) return;
const response = await api.delete(`/api/offices/${currentOfficeId}/exclusions/${id}`); const response = await api.delete(`/api/offices/${currentOfficeId}/exclusions/${id}`);
@@ -335,6 +379,10 @@ function setupEventListeners() {
modals.forEach(m => { modals.forEach(m => {
document.getElementById(m.btn).addEventListener('click', () => { document.getElementById(m.btn).addEventListener('click', () => {
if (m.id !== 'closingDayModal') populateUserSelects(); 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.id).style.display = 'flex';
}); });
document.getElementById(m.close).addEventListener('click', () => { document.getElementById(m.close).addEventListener('click', () => {
@@ -368,7 +416,7 @@ function setupEventListeners() {
document.getElementById('exclusionForm').addEventListener('submit', (e) => { document.getElementById('exclusionForm').addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
addExclusion({ saveExclusion({
user_id: document.getElementById('exclusionUser').value, user_id: document.getElementById('exclusionUser').value,
start_date: document.getElementById('exclusionStartDate').value || null, start_date: document.getElementById('exclusionStartDate').value || null,
end_date: document.getElementById('exclusionEndDate').value || null, end_date: document.getElementById('exclusionEndDate').value || null,
@@ -380,4 +428,7 @@ function setupEventListeners() {
// Global functions // Global functions
window.deleteClosingDay = deleteClosingDay; window.deleteClosingDay = deleteClosingDay;
window.deleteGuarantee = deleteGuarantee; window.deleteGuarantee = deleteGuarantee;
window.deleteClosingDay = deleteClosingDay;
window.deleteGuarantee = deleteGuarantee;
window.deleteExclusion = deleteExclusion; window.deleteExclusion = deleteExclusion;
window.openEditExclusion = openEditExclusion;

View File

@@ -93,6 +93,27 @@
<small class="text-muted">Numero totale di posti auto assegnati a questo ufficio</small> <small class="text-muted">Numero totale di posti auto assegnati a questo ufficio</small>
</div> </div>
<div class="form-group">
<label>Orario di Cut-off (Giorno Precedente)</label>
<div style="display: flex; gap: 10px; align-items: center;">
<select id="officeCutoffHour" class="form-control" style="width: 80px;">
<!-- Hours 0-23 generated by JS -->
</select>
<span>:</span>
<select id="officeCutoffMinute" class="form-control" style="width: 80px;">
<option value="0">00</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select>
<label style="margin-left: 10px; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" id="officeWindowEnabled">
Abilita Assegnazione Automatica
</label>
</div>
<small class="text-muted">Orario limite per la prenotazione del giorno successivo</small>
</div>
<div class="form-actions"> <div class="form-actions">

View File

@@ -88,10 +88,7 @@
</select> </select>
<span>:</span> <span>:</span>
<select id="bookingWindowMinute" style="width: 80px;"> <select id="bookingWindowMinute" style="width: 80px;">
<option value="0">00</option> <!-- Populated by JS -->
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select> </select>
</div> </div>
<small class="text-muted">Le presenze inserite prima di questo orario saranno messe in <small class="text-muted">Le presenze inserite prima di questo orario saranno messe in
@@ -138,6 +135,32 @@
<button id="clearAssignmentsBtn" class="btn btn-danger"> <button id="clearAssignmentsBtn" class="btn btn-danger">
Elimina Tutte le Assegnazioni Elimina Tutte le Assegnazioni
</button> </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 (Solo a Me)
</button>
<button id="bulkEmailBtn" class="btn btn-warning"
title="Invia mail reale a tutti gli assegnatari">
Test (A Tutti)
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -87,7 +87,11 @@
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color status-absent"></div> <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> </div>
</div> </div>
@@ -155,11 +159,35 @@
</div> </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;"> <div class="card parking-map-card" style="margin-top: 2rem;">
<h3>Mappa Parcheggio</h3> <h3>Mappa Parcheggio</h3>
<img src="/assets/parking-map.png" alt="Mappa Parcheggio" <img src="/assets/parking-map.png" alt="Mappa Parcheggio"
style="width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--border);"> style="width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--border);">
</div> </div> <!-- End parking-map-card -->
</div> </div>
</main> </main>
@@ -199,7 +227,11 @@
</button> </button>
<button type="button" class="status-btn qe-status-btn" data-status="absent"> <button type="button" class="status-btn qe-status-btn" data-status="absent">
<div class="status-icon status-absent"></div> <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>
<button type="button" class="status-btn qe-status-btn" data-status="clear"> <button type="button" class="status-btn qe-status-btn" data-status="clear">
<div class="status-icon" <div class="status-icon"
@@ -241,7 +273,11 @@
</button> </button>
<button class="status-btn" data-status="absent"> <button class="status-btn" data-status="absent">
<div class="status-icon status-absent"></div> <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> </button>
</div> </div>
@@ -276,6 +312,59 @@
</div> </div>
</div> </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> <small class="text-muted">Il ruolo è assegnato dal tuo amministratore</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="manager">Manager</label> <label for="manager">Ufficio</label>
<input type="text" id="manager" disabled> <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>
<div class="form-actions" id="profileActions"> <div class="form-actions" id="profileActions">
<button type="submit" class="btn btn-dark">Salva Modifiche</button> <button type="submit" class="btn btn-dark">Salva Modifiche</button>
@@ -130,7 +130,7 @@
}); });
async function loadProfile() { async function loadProfile() {
const response = await api.get('/api/users/me/profile'); const response = await api.getCached('/api/users/me/profile', 60);
if (response && response.ok) { if (response && response.ok) {
const profile = await response.json(); const profile = await response.json();
isLdapUser = profile.is_ldap_user; isLdapUser = profile.is_ldap_user;
@@ -139,7 +139,7 @@
document.getElementById('name').value = profile.name || ''; document.getElementById('name').value = profile.name || '';
document.getElementById('email').value = profile.email; document.getElementById('email').value = profile.email;
document.getElementById('role').value = profile.role; 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 // LDAP mode adjustments
if (isLdapUser) { if (isLdapUser) {
@@ -169,6 +169,8 @@
const response = await api.put('/api/users/me/profile', data); const response = await api.put('/api/users/me/profile', data);
if (response && response.ok) { if (response && response.ok) {
utils.showMessage('Profilo aggiornato con successo', 'success'); utils.showMessage('Profilo aggiornato con successo', 'success');
api.invalidateCache('/api/users/me/profile');
api.invalidateCache('/api/auth/me'); // Update nav bar name too
// Update nav display // Update nav display
const nameEl = document.getElementById('userName'); const nameEl = document.getElementById('userName');
if (nameEl) nameEl.textContent = data.name; if (nameEl) nameEl.textContent = data.name;

View File

@@ -54,17 +54,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="notificationForm"> <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"> <div class="form-group">
<label class="toggle-label"> <label class="toggle-label">
<span>Promemoria Giornaliero</span> <span>Promemoria Giornaliero</span>
@@ -141,7 +131,7 @@
// Notification settings // Notification settings
// Notification settings // Notification settings
document.getElementById('notifyWeeklyParking').checked = currentUser.notify_weekly_parking !== 0;
document.getElementById('notifyDailyParking').checked = currentUser.notify_daily_parking !== 0; document.getElementById('notifyDailyParking').checked = currentUser.notify_daily_parking !== 0;
document.getElementById('notifyDailyHour').value = currentUser.notify_daily_parking_hour || 8; document.getElementById('notifyDailyHour').value = currentUser.notify_daily_parking_hour || 8;
document.getElementById('notifyDailyMinute').value = currentUser.notify_daily_parking_minute || 0; document.getElementById('notifyDailyMinute').value = currentUser.notify_daily_parking_minute || 0;
@@ -164,7 +154,7 @@
e.preventDefault(); e.preventDefault();
const data = { 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: document.getElementById('notifyDailyParking').checked ? 1 : 0,
notify_daily_parking_hour: parseInt(document.getElementById('notifyDailyHour').value), notify_daily_parking_hour: parseInt(document.getElementById('notifyDailyHour').value),
notify_daily_parking_minute: parseInt(document.getElementById('notifyDailyMinute').value), notify_daily_parking_minute: parseInt(document.getElementById('notifyDailyMinute').value),

View File

@@ -59,6 +59,7 @@
</select> </select>
</div> </div>
<div id="office-display-header" <div id="office-display-header"
style="margin-bottom: 1rem; font-weight: 600; font-size: 1.1rem; color: var(--text);"> style="margin-bottom: 1rem; font-weight: 600; font-size: 1.1rem; color: var(--text);">
Ufficio: <span id="currentOfficeNameDisplay" style="color: var(--primary);">Loading...</span> Ufficio: <span id="currentOfficeNameDisplay" style="color: var(--primary);">Loading...</span>
@@ -100,8 +101,36 @@
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color status-absent"></div> <div class="legend-color status-absent"></div>
<span>Assente</span> <span>Ferie</span>
</div> </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> </div>
</div> </div>
@@ -127,7 +156,11 @@
</button> </button>
<button class="status-btn" data-status="absent"> <button class="status-btn" data-status="absent">
<div class="status-icon status-absent"></div> <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> </button>
</div> </div>
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella <button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella

26
main.py
View File

@@ -21,12 +21,31 @@ from app.routes.users import router as users_router
from app.routes.offices import router as offices_router from app.routes.offices import router as offices_router
from app.routes.presence import router as presence_router from app.routes.presence import router as presence_router
from app.routes.parking import router as parking_router from app.routes.parking import router as parking_router
from database.connection import init_db from app.routes.parking import router as parking_router
from database.connection import init_db, get_db_session
from services.notifications import run_scheduled_notifications
from services.parking import process_daily_allocations
import asyncio
# Rate limiter setup # Rate limiter setup
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=get_remote_address)
async def scheduler_task():
"""Background task to run scheduled notifications every minute"""
config.logger.info("Scheduler task started")
while True:
try:
with get_db_session() as db:
run_scheduled_notifications(db)
process_daily_allocations(db)
except Exception as e:
config.logger.error(f"Scheduler error: {e}")
# Check every 60 seconds
await asyncio.sleep(60)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Initialize database on startup""" """Initialize database on startup"""
@@ -72,6 +91,9 @@ async def lifespan(app: FastAPI):
log(f"feedback: App reachable via Caddy at {reachable_url}") log(f"feedback: App reachable via Caddy at {reachable_url}")
# Start scheduler
asyncio.create_task(scheduler_task())
yield yield
log("Shutting down Parking Manager application") log("Shutting down Parking Manager application")
@@ -97,6 +119,8 @@ app.include_router(users_router)
app.include_router(offices_router) app.include_router(offices_router)
app.include_router(presence_router) app.include_router(presence_router)
app.include_router(parking_router) app.include_router(parking_router)
from app.routes.reports import router as reports_router
app.include_router(reports_router)
# Static Files # Static Files
app.mount("/css", StaticFiles(directory=str(config.FRONTEND_DIR / "css")), name="css") 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 python-multipart==0.0.9
idna<4,>=2.5 idna<4,>=2.5
email-validator>=2.1.0.post1 email-validator>=2.1.0.post1
openpyxl>=3.1.2

View File

@@ -100,17 +100,17 @@ def notify_parking_assigned(user: "User", assignment_date: date, spot_name: str)
if not user.notify_parking_changes: if not user.notify_parking_changes:
return return
day_name = assignment_date.strftime("%A, %B %d") day_name = assignment_date.strftime("%d/%m/%Y")
subject = f"Parking spot assigned for {day_name}" subject = f"Assegnazione Posto Auto - {day_name}"
body_html = f""" body_html = f"""
<html> <html>
<body> <body>
<h2>Parking Spot Assigned</h2> <h2>Posto Auto Assegnato</h2>
<p>Hi {user.name},</p> <p>Ciao {user.name},</p>
<p>You have been assigned a parking spot for {day_name}:</p> <p>Ti è stato assegnato un posto auto per il giorno {day_name}:</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p> <p style="font-size: 18px; font-weight: bold;">Posto {spot_name}</p>
<p>Best regards,<br>Parking Manager</p> <p>Cordiali saluti,<br>Team Parking Manager</p>
</body> </body>
</html> </html>
""" """
@@ -122,16 +122,16 @@ def notify_parking_released(user: "User", assignment_date: date, spot_name: str)
if not user.notify_parking_changes: if not user.notify_parking_changes:
return return
day_name = assignment_date.strftime("%A, %B %d") day_name = assignment_date.strftime("%d/%m/%Y")
subject = f"Parking spot released for {day_name}" subject = f"Rilascio Posto Auto - {day_name}"
body_html = f""" body_html = f"""
<html> <html>
<body> <body>
<h2>Parking Spot Released</h2> <h2>Posto Auto Rilasciato</h2>
<p>Hi {user.name},</p> <p>Ciao {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been released.</p> <p>Il tuo posto auto (Posto {spot_name}) per il giorno {day_name} è stato rilasciato.</p>
<p>Best regards,<br>Parking Manager</p> <p>Cordiali saluti,<br>Team Parking Manager</p>
</body> </body>
</html> </html>
""" """
@@ -143,16 +143,16 @@ def notify_parking_reassigned(user: "User", assignment_date: date, spot_name: st
if not user.notify_parking_changes: if not user.notify_parking_changes:
return return
day_name = assignment_date.strftime("%A, %B %d") day_name = assignment_date.strftime("%d/%m/%Y")
subject = f"Parking spot reassigned for {day_name}" subject = f"Riassegnazione Posto Auto - {day_name}"
body_html = f""" body_html = f"""
<html> <html>
<body> <body>
<h2>Parking Spot Reassigned</h2> <h2>Posto Auto Riassegnato</h2>
<p>Hi {user.name},</p> <p>Ciao {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been reassigned to {new_user_name}.</p> <p>Il tuo posto auto (Posto {spot_name}) per il giorno {day_name} è stato riassegnato a {new_user_name}.</p>
<p>Best regards,<br>Parking Manager</p> <p>Cordiali saluti,<br>Team Parking Manager</p>
</body> </body>
</html> </html>
""" """
@@ -188,19 +188,19 @@ def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Sessi
return False return False
# Send reminder # Send reminder
start_date = next_week_dates[0].strftime("%B %d") start_date = next_week_dates[0].strftime("%d/%m/%Y")
end_date = next_week_dates[-1].strftime("%B %d, %Y") end_date = next_week_dates[-1].strftime("%d/%m/%Y")
subject = f"Reminder: Please fill your presence for {start_date} - {end_date}" subject = f"Promemoria Presenze - Settimana {start_date} - {end_date}"
body_html = f""" body_html = f"""
<html> <html>
<body> <body>
<h2>Presence Reminder</h2> <h2>Promemoria Compilazione Presenze</h2>
<p>Hi {user.name},</p> <p>Ciao {user.name},</p>
<p>This is a friendly reminder to fill your presence for the upcoming week <p>Ti ricordiamo di compilare le tue presenze per la prossima settimana
({start_date} - {end_date}).</p> ({start_date} - {end_date}).</p>
<p>Please log in to the Parking Manager to mark your presence.</p> <p>Accedi al Parking Manager per segnare le tue presenze.</p>
<p>Best regards,<br>Parking Manager</p> <p>Cordiali saluti,<br>Team Parking Manager</p>
</body> </body>
</html> </html>
""" """
@@ -220,75 +220,7 @@ def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Sessi
return False 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: def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session") -> bool:
@@ -296,7 +228,10 @@ def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session")
from database.models import DailyParkingAssignment, NotificationLog from database.models import DailyParkingAssignment, NotificationLog
from services.parking import get_spot_display_name from services.parking import get_spot_display_name
config.logger.info(f"[SCHEDULER] Checking daily parking reminder for user {user.email}")
if not user.notify_daily_parking: if not user.notify_daily_parking:
config.logger.debug(f"[SCHEDULER] User {user.email} has disabled daily parking notifications")
return False return False
date_str = date_obj.strftime("%Y-%m-%d") date_str = date_obj.strftime("%Y-%m-%d")
@@ -322,17 +257,17 @@ def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session")
return False return False
spot_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) spot_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
day_name = date_obj.strftime("%A, %B %d") day_name = date_obj.strftime("%d/%m/%Y")
subject = f"Parking reminder for {day_name}" subject = f"Promemoria Parcheggio - {day_name}"
body_html = f""" body_html = f"""
<html> <html>
<body> <body>
<h2>Daily Parking Reminder</h2> <h2>Promemoria Parcheggio Giornaliero</h2>
<p>Hi {user.name},</p> <p>Ciao {user.name},</p>
<p>You have a parking spot assigned for today ({day_name}):</p> <p>Hai un posto auto assegnato per oggi ({day_name}):</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p> <p style="font-size: 18px; font-weight: bold;">Posto {spot_name}</p>
<p>Best regards,<br>Parking Manager</p> <p>Cordiali saluti,<br>Team Parking Manager</p>
</body> </body>
</html> </html>
""" """
@@ -359,11 +294,15 @@ def run_scheduled_notifications(db: "Session"):
Schedule: Schedule:
- Thursday at 12:00: Presence reminder for next week - Thursday at 12:00: Presence reminder for next week
- Friday at 12:00: Weekly parking summary - Friday at 12:00: Weekly parking summary
- Daily at user's preferred time: Daily parking reminder (Mon-Fri) - Daily at user's preferred time: Daily parking reminder (Only on open days)
""" """
from database.models import User from database.models import User, OfficeWeeklyClosingDay, OfficeClosingDay
from zoneinfo import ZoneInfo
# Use configured timezone
tz = ZoneInfo(config.TIMEZONE)
now = datetime.now(tz)
now = datetime.now()
current_hour = now.hour current_hour = now.hour
current_minute = now.minute current_minute = now.minute
current_weekday = now.weekday() # 0=Monday, 6=Sunday current_weekday = now.weekday() # 0=Monday, 6=Sunday
@@ -372,19 +311,45 @@ def run_scheduled_notifications(db: "Session"):
users = db.query(User).all() users = db.query(User).all()
for user in users: for user in users:
# Thursday at 12: Presence reminder # Thursday Reminder: DISABLED as per user request
if current_weekday == 3 and current_hour == 12 and current_minute < 5: # if current_weekday == 3 and current_hour == 12 and current_minute < 5:
next_week = get_next_week_dates(today_date) # next_week = get_next_week_dates(today_date)
send_presence_reminder(user, next_week, db) # send_presence_reminder(user, next_week, db)
# Friday at 12: Weekly parking summary # Daily parking reminder at user's preferred time
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 user_hour = user.notify_daily_parking_hour or 8
user_minute = user.notify_daily_parking_minute or 0 user_minute = user.notify_daily_parking_minute or 0
# Check if it's the right time for this user
if current_hour == user_hour and abs(current_minute - user_minute) < 5: if current_hour == user_hour and abs(current_minute - user_minute) < 5:
config.logger.info(f"[SCHEDULER] Triggering Daily Parking Reminder check for user {user.email} (Scheduled: {user_hour}:{user_minute})")
# Check if Office is OPEN today
is_office_open = True
if user.office:
# Check weekly closing days (e.g. Sat/Sun)
# Note: WeekDay enum matches python weekday (0=Mon)
weekly_closed = db.query(OfficeWeeklyClosingDay).filter(
OfficeWeeklyClosingDay.office_id == user.office_id,
OfficeWeeklyClosingDay.weekday == current_weekday
).first()
if weekly_closed:
is_office_open = False
# Check specific closing days (Holidays)
if is_office_open:
specific_closed = db.query(OfficeClosingDay).filter(
OfficeClosingDay.office_id == user.office_id,
OfficeClosingDay.date == today_date
).first()
if specific_closed:
is_office_open = False
else:
# Fallback if no office assigned: default to Mon-Fri open
if current_weekday >= 5:
is_office_open = False
if is_office_open:
send_daily_parking_reminder(user, now, db) send_daily_parking_reminder(user, now, db)

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,55 +13,31 @@ from sqlalchemy.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_
from database.models import ( from database.models import (
DailyParkingAssignment, User, UserPresence, Office, DailyParkingAssignment, User, UserPresence, Office, OfficeSpot,
ParkingGuarantee, ParkingExclusion, OfficeClosingDay, OfficeWeeklyClosingDay, ParkingGuarantee, ParkingExclusion, OfficeClosingDay, OfficeWeeklyClosingDay,
UserRole, PresenceStatus UserRole, PresenceStatus
) )
from utils.helpers import generate_uuid from utils.helpers import generate_uuid
from app import config from app import config
from services.notifications import notify_parking_assigned
def get_spot_prefix(office: Office, db: Session) -> str: def get_spot_prefix(office: Office, db: Session) -> str:
"""Get the spot prefix for an office (from office.spot_prefix or auto-assign)""" """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: if office.spot_prefix:
return office.spot_prefix return office.spot_prefix
return "A" # Fallback
# 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
def get_spot_display_name(spot_id: str, office_id: str, db: Session) -> str: 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')""" """Get display name for a spot"""
office = db.query(Office).filter(Office.id == office_id).first() # Now easy: fetch from OfficeSpot
if not office: # But wait: spot_id in assignment IS the OfficeSpot.id
return spot_id spot = db.query(OfficeSpot).filter(OfficeSpot.id == spot_id).first()
if spot:
prefix = get_spot_prefix(office, db) return spot.name
spot_number = spot_id.replace("spot-", "") return "Unknown"
return f"{prefix}{spot_number}"
def is_closing_day(office_id: str, check_date: date, db: Session) -> bool: def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
@@ -95,35 +71,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: 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): if is_closing_day(office_id, pool_date, db):
return 0 return 0
existing = db.query(DailyParkingAssignment).filter( return db.query(OfficeSpot).filter(
DailyParkingAssignment.office_id == office_id, OfficeSpot.office_id == office_id,
DailyParkingAssignment.date == pool_date OfficeSpot.is_unavailable == False
).count() ).count()
if existing > 0:
return existing
for i in range(1, quota + 1): def get_available_spots(office_id: str, pool_date: date, db: Session) -> list[OfficeSpot]:
spot = DailyParkingAssignment( """Get list of unassigned OfficeSpots for a date"""
id=generate_uuid(), # 1. Get all active spots
date=pool_date, all_spots = db.query(OfficeSpot).filter(
spot_id=f"spot-{i}", OfficeSpot.office_id == office_id,
user_id=None, OfficeSpot.is_unavailable == False
office_id=office_id, ).all()
created_at=datetime.now(timezone.utc)
)
db.add(spot)
db.commit() # 2. Get assigned spot IDs
config.logger.debug(f"Initialized {quota} parking spots for office {office_id} on {pool_date}") assigned_ids = db.query(DailyParkingAssignment.spot_id).filter(
return quota 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: def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
@@ -151,22 +128,32 @@ 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: 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""" """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.office_id == office_id,
ParkingExclusion.user_id == user_id ParkingExclusion.user_id == user_id
).first() ).all()
if not exclusion: if not exclusions:
return False return False
# Check against all exclusions
for exclusion in exclusions:
# If any exclusion covers this date, user is excluded
# Check date range # Check date range
start_ok = True
if exclusion.start_date and check_date < exclusion.start_date: if exclusion.start_date and check_date < exclusion.start_date:
return False start_ok = False
if exclusion.end_date and check_date > exclusion.end_date:
return 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: def has_guarantee(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
"""Check if user has a parking guarantee for this date""" """Check if user has a parking guarantee for this date"""
@@ -227,47 +214,59 @@ def get_users_wanting_parking(office_id: str, pool_date: date, db: Session) -> l
return candidates return candidates
def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict: def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
""" """
Assign parking spots fairly based on parking ratio. Assign parking spots fairly based on parking ratio.
Called after presence is set for a date. Creates new DailyParkingAssignment rows only for assigned users.
Returns {assigned: [...], waitlist: [...]}
""" """
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): if is_closing_day(office_id, pool_date, db):
return {"assigned": [], "waitlist": [], "closed": True} return {"assigned": [], "waitlist": [], "closed": True}
# Initialize pool
initialize_parking_pool(office_id, office.parking_quota, pool_date, db)
# Get candidates sorted by fairness # Get candidates sorted by fairness
candidates = get_users_wanting_parking(office_id, pool_date, db) candidates = get_users_wanting_parking(office_id, pool_date, db)
# Get available spots # Get available spots (OfficeSpots not yet in assignments table)
free_spots = db.query(DailyParkingAssignment).filter( free_spots = get_available_spots(office_id, pool_date, db)
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id == None
).all()
assigned = [] assigned = []
waitlist = [] waitlist = []
for candidate in candidates: for candidate in candidates:
if free_spots: 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 = 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"]) assigned.append(candidate["user_id"])
else: else:
waitlist.append(candidate["user_id"]) waitlist.append(candidate["user_id"])
db.commit() db.commit()
# Send notifications to successful assignees
for user_id in assigned:
user = db.query(User).filter(User.id == user_id).first()
if user:
# Re-fetch the assignment to get the spot details
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.date == pool_date
).first()
if assignment:
spot_name = get_spot_display_name(assignment.spot_id, office_id, db)
notify_parking_assigned(user, pool_date, spot_name)
return {"assigned": assigned, "waitlist": waitlist} return {"assigned": assigned, "waitlist": waitlist}
@@ -282,14 +281,28 @@ def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session
if not assignment: if not assignment:
return False return False
# Release the spot # Capture spot ID before deletion
assignment.user_id = None spot_id = assignment.spot_id
# Release the spot (Delete the row)
db.delete(assignment)
db.commit() db.commit()
# Try to assign to next user in fairness queue # Try to assign to next user in fairness queue
candidates = get_users_wanting_parking(office_id, pool_date, db) candidates = get_users_wanting_parking(office_id, pool_date, db)
if candidates: 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() db.commit()
return True return True
@@ -309,8 +322,7 @@ def handle_presence_change(user_id: str, change_date: date, old_status: Presence
if not office or not office.parking_quota: if not office or not office.parking_quota:
return return
# Initialize pool if needed # No initialization needed for sparse model
initialize_parking_pool(office.id, office.parking_quota, change_date, db)
if old_status == PresenceStatus.PRESENT and new_status in [PresenceStatus.REMOTE, PresenceStatus.ABSENT]: if old_status == PresenceStatus.PRESENT and new_status in [PresenceStatus.REMOTE, PresenceStatus.ABSENT]:
# User no longer coming - release their spot (will auto-reassign) # User no longer coming - release their spot (will auto-reassign)
@@ -320,15 +332,20 @@ def handle_presence_change(user_id: str, change_date: date, old_status: Presence
# Check booking window # Check booking window
should_assign = True should_assign = True
if office.booking_window_enabled: if office.booking_window_enabled:
from zoneinfo import ZoneInfo
tz = ZoneInfo(config.TIMEZONE)
now = datetime.now(tz)
# Allocation time is Day-1 at cutoff hour # Allocation time is Day-1 at cutoff hour
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time()) cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
cutoff_dt = cutoff_dt.replace( cutoff_dt = cutoff_dt.replace(
hour=office.booking_window_end_hour, hour=office.booking_window_end_hour,
minute=office.booking_window_end_minute minute=office.booking_window_end_minute,
tzinfo=tz
) )
# If now is before cutoff, do not assign yet (wait for batch job) # If now is before cutoff, do not assign yet (wait for batch job)
if datetime.utcnow() < cutoff_dt: if now < cutoff_dt:
should_assign = False should_assign = False
config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})") config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})")
@@ -344,13 +361,12 @@ def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Sessi
""" """
assignments = db.query(DailyParkingAssignment).filter( assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.office_id == office_id, DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date, DailyParkingAssignment.date == pool_date
DailyParkingAssignment.user_id != None
).all() ).all()
count = len(assignments) count = len(assignments)
for a in assignments: for a in assignments:
a.user_id = None db.delete(a)
db.commit() db.commit()
return count return count
@@ -366,3 +382,35 @@ def run_batch_allocation(office_id: str, pool_date: date, db: Session) -> dict:
# 2. Run fair allocation # 2. Run fair allocation
return assign_parking_fairly(office_id, pool_date, db) return assign_parking_fairly(office_id, pool_date, db)
def process_daily_allocations(db: Session):
"""
Check if any office's booking window has just closed and run batch allocation.
Run by scheduler every minute.
HALT: Checks if the cutoff time for TOMORROW has been reached.
"""
from zoneinfo import ZoneInfo
# Use configured timezone
tz = ZoneInfo(config.TIMEZONE)
now = datetime.now(tz)
# Check all offices with window enabled
offices = db.query(Office).filter(Office.booking_window_enabled == True).all()
config.logger.debug(f"[SCHEDULER] Checking booking windows for {len(offices)} offices - Current Time: {now.strftime('%H:%M')}")
for office in offices:
# Cutoff is defined as "Previous Day" (today) at Booking End Hour
# If NOW matches the cutoff time, we run allocation for TOMORROW
if now.hour == office.booking_window_end_hour and now.minute == office.booking_window_end_minute:
target_date = now.date() + timedelta(days=1)
config.logger.info(f"[SCHEDULER] CUTOFF REACHED for {office.name} (Cutoff: {office.booking_window_end_hour}:{office.booking_window_end_minute}). Starting Assegnazione Giornaliera parcheggi for {target_date}")
try:
run_batch_allocation(office.id, target_date, db)
config.logger.info(f"[SCHEDULER] Assegnazione Giornaliera parcheggi completed for {office.name} on {target_date}")
except Exception as e:
config.logger.error(f"[SCHEDULER] Failed Assegnazione Giornaliera parcheggi for {office.name}: {e}")

48
upgrade_db_v1.py Normal file
View File

@@ -0,0 +1,48 @@
import sqlite3
import os
import sys
# Default path inside Docker container
DEFAULT_DB_PATH = "/app/data/parking.db"
def migrate():
# Allow overriding DB path via env var or argument
db_path = os.getenv("DATABASE_PATH", DEFAULT_DB_PATH)
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not os.path.exists(db_path):
print(f"Error: Database file not found at {db_path}")
print("Usage: python upgrade_db_v1.py [path_to_db]")
sys.exit(1)
print(f"Migrating database at: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
columns_to_add = [
("booking_window_enabled", "BOOLEAN", "0"),
("booking_window_end_hour", "INTEGER", "18"),
("booking_window_end_minute", "INTEGER", "0")
]
for col_name, col_type, default_val in columns_to_add:
try:
# Check if column exists
cursor.execute(f"SELECT {col_name} FROM offices LIMIT 1")
except sqlite3.OperationalError:
# Column doesn't exist, add it
print(f"Adding column: {col_name} ({col_type})")
try:
cursor.execute(f"ALTER TABLE offices ADD COLUMN {col_name} {col_type} DEFAULT {default_val}")
except Exception as e:
print(f"Failed to add column {col_name}: {e}")
else:
print(f"Column {col_name} already exists. Skipping.")
conn.commit()
conn.close()
print("Migration complete.")
if __name__ == "__main__":
migrate()

View File

@@ -18,7 +18,14 @@ security = HTTPBearer(auto_error=False)
def is_admin_from_groups(groups: list[str]) -> bool: def is_admin_from_groups(groups: list[str]) -> bool:
"""Check if user is admin based on Authelia groups""" """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]
return is_admin
def get_or_create_authelia_user( def get_or_create_authelia_user(
@@ -43,19 +50,19 @@ def get_or_create_authelia_user(
# Only sync admin status from LLDAP, other roles managed by app admin # Only sync admin status from LLDAP, other roles managed by app admin
if is_admin and user.role != "admin": if is_admin and user.role != "admin":
user.role = "admin" user.role = "admin"
user.updated_at = datetime.utcnow().isoformat() user.updated_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(user) db.refresh(user)
elif not is_admin and user.role == "admin": elif not is_admin and user.role == "admin":
# Removed from parking_admins group -> demote to employee # Removed from parking_admins group -> demote to employee
user.role = "employee" user.role = "employee"
user.updated_at = datetime.utcnow().isoformat() user.updated_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(user) db.refresh(user)
# Update name if changed # Update name if changed
if user.name != name and name: if user.name != name and name:
user.name = name user.name = name
user.updated_at = datetime.utcnow().isoformat() user.updated_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(user) db.refresh(user)
return user return user
@@ -67,8 +74,8 @@ def get_or_create_authelia_user(
name=name or email.split("@")[0], name=name or email.split("@")[0],
role="admin" if is_admin else "employee", role="admin" if is_admin else "employee",
password_hash=None, # No password for Authelia users password_hash=None, # No password for Authelia users
created_at=datetime.utcnow().isoformat(), created_at=datetime.utcnow(),
updated_at=datetime.utcnow().isoformat() updated_at=datetime.utcnow()
) )
db.add(user) db.add(user)
db.commit() db.commit()
@@ -92,7 +99,7 @@ def get_current_user(
remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "") remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "")
remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "") remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "")
print(f"[Authelia] Headers: user={remote_user}, email={remote_email}, name={remote_name}, groups={remote_groups}") remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "")
if not remote_user: if not remote_user:
raise HTTPException( raise HTTPException(