Compare commits

9 Commits

Author SHA1 Message Date
5d30434dd5 piccoli fix 2026-04-20 20:37:37 +02:00
StefanoSalemi
104ad53a9a feat: aggiunti: loggica random, tema scuro, correzioni mail, miglioramenti generali, cache; 2026-04-17 18:27:37 +02:00
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
36 changed files with 1834 additions and 1062 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
.env
.env.local
.env.production
# IDE
.idea/

439
README.md
View File

@@ -1,225 +1,214 @@
# 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.
- `GET /me/exclusion`: Lista delle proprie auto-esclusioni.
- `POST /me/exclusion`: Crea una nuova auto-esclusione.
- `PUT /me/exclusion/{exclusion_id}`: Modifica una auto-esclusione.
- `DELETE /me/exclusion/{exclusion_id}`: Elimina una auto-esclusione.
### Offices (`/api/offices`)
Gestione uffici, regole di chiusura e quote.
- `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 (Manager/Admin).
- `POST /admin/clear-office-presence`: Pulisce presenze e parcheggi di un ufficio per un range di date (Test/Admin).
### Parking (`/api/parking`)
Gestione assegnazioni posti auto.
- `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.
- `POST /test-email`: Invia email di test (Test Tool).
### Reports (`/api/reports`)
Esportazione dati.
- `GET /team-export`: Esporta dati presenze e parcheggi del team in Excel.
## Utilizzo con AUTHELIA
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
# 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"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
# Server
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000"))
TIMEZONE = os.getenv("TIMEZONE", "UTC")
# CORS
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",")

View File

@@ -104,7 +104,8 @@ def login(request: Request, data: LoginRequest, response: Response, db: Session
value=token,
httponly=True,
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}")
@@ -114,7 +115,12 @@ def login(request: Request, data: LoginRequest, response: Response, db: Session
@router.post("/logout")
def logout(response: Response):
"""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"}

View File

@@ -26,6 +26,10 @@ router = APIRouter(prefix="/api/offices", tags=["offices"])
class ValidOfficeCreate(BaseModel):
name: str
parking_quota: int = 0
booking_window_enabled: bool = False
booking_window_end_hour: int = 18
booking_window_end_minute: int = 0
assignment_mode: str = "random"
class ClosingDayCreate(BaseModel):
@@ -58,6 +62,7 @@ class OfficeSettingsUpdate(BaseModel):
booking_window_enabled: bool | None = None
booking_window_end_hour: int | None = None
booking_window_end_minute: int | None = None
assignment_mode: str | None = None
# Helper check
@@ -87,7 +92,11 @@ def list_offices(db: Session = Depends(get_db), user=Depends(require_manager_or_
"name": office.name,
"parking_quota": office.parking_quota,
"spot_prefix": office.spot_prefix,
"user_count": user_counts.get(office.id, 0)
"user_count": user_counts.get(office.id, 0),
"booking_window_enabled": office.booking_window_enabled,
"booking_window_end_hour": office.booking_window_end_hour,
"booking_window_end_minute": office.booking_window_end_minute,
"assignment_mode": office.assignment_mode
}
for office in offices
]
@@ -121,6 +130,10 @@ def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=D
name=data.name,
parking_quota=data.parking_quota,
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,
assignment_mode=data.assignment_mode,
created_at=datetime.utcnow()
)
db.add(office)
@@ -151,7 +164,8 @@ def get_office_details(office_id: str, db: Session = Depends(get_db), user=Depen
"user_count": user_count,
"booking_window_enabled": office.booking_window_enabled,
"booking_window_end_hour": office.booking_window_end_hour,
"booking_window_end_minute": office.booking_window_end_minute
"booking_window_end_minute": office.booking_window_end_minute,
"assignment_mode": getattr(office, 'assignment_mode', 'fairness')
}
@@ -180,14 +194,17 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi
if data.booking_window_end_hour is not None:
if not (0 <= data.booking_window_end_hour <= 23):
raise HTTPException(status_code=400, detail="Hour must be 0-23")
raise HTTPException(status_code=400, detail="Hour must be 0-23")
office.booking_window_end_hour = data.booking_window_end_hour
if data.booking_window_end_minute is not None:
if not (0 <= data.booking_window_end_minute <= 59):
raise HTTPException(status_code=400, detail="Minute must be 0-59")
raise HTTPException(status_code=400, detail="Minute must be 0-59")
office.booking_window_end_minute = data.booking_window_end_minute
if data.assignment_mode is not None:
office.assignment_mode = data.assignment_mode
office.updated_at = datetime.utcnow()
db.commit()
@@ -202,7 +219,8 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi
"spot_prefix": office.spot_prefix,
"booking_window_enabled": office.booking_window_enabled,
"booking_window_end_hour": office.booking_window_end_hour,
"booking_window_end_minute": office.booking_window_end_minute
"booking_window_end_minute": office.booking_window_end_minute,
"assignment_mode": getattr(office, 'assignment_mode', 'fairness')
}
@router.delete("/{office_id}")
@@ -271,6 +289,29 @@ def add_office_closing_day(office_id: str, data: ClosingDayCreate, db: Session =
)
db.add(closing_day)
db.commit()
# Clear presence and parking assignments for this new closed period
end_date = data.end_date or data.date
users = db.query(User).filter(User.office_id == office_id).all()
user_ids = [u.id for u in users]
if user_ids:
# Delete Parking Assignments
db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id.in_(user_ids),
DailyParkingAssignment.date >= data.date,
DailyParkingAssignment.date <= end_date
).delete(synchronize_session=False)
# Delete Presence
db.query(UserPresence).filter(
UserPresence.user_id.in_(user_ids),
UserPresence.date >= data.date,
UserPresence.date <= end_date
).delete(synchronize_session=False)
db.commit()
return {"id": closing_day.id, "message": "Closing day added"}

View File

@@ -30,6 +30,7 @@ from services.parking import (
run_batch_allocation, clear_assignments_for_office_date
)
from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned
from utils.helpers import generate_uuid
from app import config
router = APIRouter(prefix="/api/parking", tags=["parking"])
@@ -271,6 +272,9 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
db.add(new_assignment)
db.commit()
# Send Notification
notify_parking_assigned(user, assign_date, spot_def.name)
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_def.name}
@@ -443,26 +447,34 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
return result
from typing import Optional, Union
class TestEmailRequest(BaseModel):
date: date = None
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 (Test Tool)"""
from services.notifications import send_email
from database.models import OfficeClosingDay, OfficeWeeklyClosingDay
from datetime import timedelta
"""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 = data.date
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
# Start from tomorrow (or today? Prompt says "dopo il giorno corrente" -> after today)
# Find next open day logic (same as before)
check_date = date.today() + timedelta(days=1)
# Load closing rules
@@ -476,15 +488,11 @@ def send_test_email_tool(data: TestEmailRequest, db: Session = Depends(get_db),
OfficeClosingDay.date >= check_date
).all()
# Max lookahead 30 days to avoid infinite loop
found = False
for _ in range(30):
# Check weekly
if check_date.weekday() in weekly_closed_set:
check_date += timedelta(days=1)
continue
# Check specific
is_specific = False
for d in specific_closed:
s = d.date
@@ -492,31 +500,89 @@ def send_test_email_tool(data: TestEmailRequest, db: Session = Depends(get_db),
if s <= check_date <= e:
is_specific = True
break
if is_specific:
check_date += timedelta(days=1)
continue
found = True
break
if found:
target_date = check_date
else:
# Fallback
target_date = date.today() + timedelta(days=1)
# Send Email
subject = f"Test Email - Parking System ({target_date})"
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>Parking System Test Email</h2>
<p>Hi {current_user.name},</p>
<p>This is a test email triggered from the Parking Manager Test Tools.</p>
<p><strong>Selected Date:</strong> {target_date}</p>
<p><strong>SMTP Status:</strong> {'Enabled' if config.SMTP_ENABLED else 'Disabled (File Logging)'}</p>
<p>If you received this, the notification system is working.</p>
<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>
"""

View File

@@ -268,9 +268,20 @@ def get_team_presences(start_date: date, end_date: date, office_id: str = None,
office_lookup = {o.id: o.name for o in offices}
# Build response
from services.parking import get_user_parking_ratio
result = []
for user in users:
user_presences = [p for p in presences if p.user_id == user.id]
office_mode = "fairness"
if user.office_id:
office = next((o for o in offices if o.id == user.office_id), None)
if office:
office_mode = office.assignment_mode or "fairness"
user_ratio = None
if office_mode == "fairness" and user.office_id:
user_ratio = get_user_parking_ratio(user.id, user.office_id, db)
result.append({
"id": user.id,
@@ -279,7 +290,8 @@ def get_team_presences(start_date: date, end_date: date, office_id: str = None,
"office_name": office_lookup.get(user.office_id),
"presences": [{"date": p.date, "status": p.status} for p in user_presences],
"parking_dates": parking_lookup.get(user.id, []),
"parking_info": parking_info_lookup.get(user.id, [])
"parking_info": parking_info_lookup.get(user.id, []),
"ratio": user_ratio
})
return result

View File

@@ -18,7 +18,7 @@ services:
- PORT=8000
- DATABASE_PATH=/app/data/parking.db
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
timeout: 10s
retries: 3
@@ -26,13 +26,13 @@ services:
networks:
- org-network
labels:
- "caddy=parking.lvh.me"
- "caddy=parcheggio.rocketscale.it"
- "caddy.reverse_proxy={{upstreams 8000}}"
- "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"
# cambiare l'url delle label per il reverse proxy
networks:
org-network:
external: true
external: true

View File

@@ -61,6 +61,8 @@ class Office(Base):
booking_window_enabled = Column(Boolean, default=False)
booking_window_end_hour = Column(Integer, default=18) # 0-23
booking_window_end_minute = Column(Integer, default=0) # 0-59
assignment_mode = Column(String, default="fairness")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -241,15 +243,7 @@ class NotificationLog(Base):
)
notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # parking_change
subject = Column(Text, nullable=False)
body = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
sent_at = Column(DateTime) # null = not sent yet
__table_args__ = (
Index('idx_queue_pending', 'sent_at'),
)
class OfficeSpot(Base):

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 786 KiB

After

Width:  |  Height:  |  Size: 829 KiB

View File

@@ -13,9 +13,11 @@
--success: #16a34a;
--success-bg: #dcfce7;
--warning: #f59e0b;
--warning-bg: #fef3c7;
--warning-bg: #fde68a;
--danger: #dc2626;
--danger-bg: #fee2e2;
--info: #3b82f6;
--info-bg: #dbeafe;
--text: #1f1f1f;
--text-secondary: #666;
--text-muted: #999;
@@ -25,6 +27,49 @@
--bg-white: #fff;
--sidebar-width: 260px;
--header-height: 64px;
--bg-weekend: #f5f5f5;
--bg-holiday: #fff7ed;
--bg-closed: #e5e7eb;
--text-closed: #9ca3af;
--border-closed: #d1d5db;
--spot-free-bg: #f0fdf4;
--spot-free-border: #22c55e;
--spot-free-text: #15803d;
--spot-occ-bg: #fefce8;
--spot-occ-border: #eab308;
--spot-occ-text: #a16207;
}
[data-theme='dark'] {
--primary: #60a5fa;
--primary-hover: #93c5fd;
--secondary: #9ca3af;
--success: #22c55e;
--success-bg: #064e3b;
--warning: #fbbf24;
--warning-bg: #78350f;
--danger: #ef4444;
--danger-bg: #7f1d1d;
--info: #60a5fa;
--info-bg: #1e3a8a;
--text: #f3f4f6;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--border: #374151;
--border-dark: #4b5563;
--bg: #111827;
--bg-white: #1f2937;
--bg-weekend: #111827; /* Dark background for weekend */
--bg-holiday: #451a03; /* Dark brown/orange for holiday */
--bg-closed: #374151; /* Gray-700 for closed */
--text-closed: #6b7280; /* Gray-500 for closed date text */
--border-closed: #4b5563; /* Gray-600 for closed border */
--spot-free-bg: #064e3b;
--spot-free-border: #059669;
--spot-free-text: #4ade80;
--spot-occ-bg: #422006;
--spot-occ-border: #ca8a04;
--spot-occ-text: #fde047;
}
/* ============================================================================
@@ -46,6 +91,31 @@ body {
min-height: 100vh;
}
/* ============================================================================
Dark mode global overrides for native browser elements
============================================================================ */
[data-theme='dark'] input,
[data-theme='dark'] select,
[data-theme='dark'] textarea {
background: var(--bg-white);
color: var(--text);
border-color: var(--border-dark);
color-scheme: dark;
}
[data-theme='dark'] input[type="date"]::-webkit-calendar-picker-indicator {
filter: invert(1);
}
[data-theme='dark'] input::placeholder,
[data-theme='dark'] textarea::placeholder {
color: var(--text-muted);
}
[data-theme='dark'] .user-button:hover {
background: var(--bg);
}
a {
color: inherit;
text-decoration: none;
@@ -72,7 +142,7 @@ textarea {
top: 0;
bottom: 0;
width: var(--sidebar-width);
background: white;
background: var(--bg-white);
color: var(--text);
border-right: 1px solid var(--border);
display: flex;
@@ -183,7 +253,7 @@ textarea {
left: 0;
right: 0;
margin-bottom: 0.5rem;
background: white;
background: var(--bg-white);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
overflow: hidden;
@@ -226,7 +296,7 @@ textarea {
justify-content: space-between;
padding: 0 1.5rem;
min-height: 53px;
background: white;
background: var(--bg-white);
border-bottom: 1px solid var(--border);
gap: 0.75rem;
}
@@ -253,7 +323,7 @@ textarea {
Cards
============================================================================ */
.card {
background: white;
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
@@ -302,7 +372,7 @@ textarea {
}
.btn-secondary {
background: white;
background: var(--bg-white);
color: var(--text);
border: 1px solid var(--border-dark);
}
@@ -330,10 +400,12 @@ textarea {
display: flex;
align-items: center;
justify-content: center;
color: var(--text);
}
.btn-icon:hover {
background: var(--bg);
color: var(--text);
}
.btn-full {
@@ -364,7 +436,8 @@ textarea {
font-size: 0.9rem;
border: 1px solid var(--border-dark);
border-radius: 6px;
background: white;
background: var(--bg-white);
color: var(--text);
}
.form-input:focus,
@@ -430,7 +503,7 @@ textarea {
}
.modal-content {
background: white;
background: var(--bg-white);
border-radius: 12px;
width: 100%;
max-width: 480px;
@@ -490,12 +563,12 @@ textarea {
.message.success {
background: var(--success-bg);
color: #166534;
color: var(--success);
}
.message.error {
background: var(--danger-bg);
color: #991b1b;
color: var(--danger);
}
.badge {
@@ -508,17 +581,17 @@ textarea {
.badge-success {
background: var(--success-bg);
color: #166534;
color: var(--success);
}
.badge-warning {
background: var(--warning-bg);
color: #92400e;
color: var(--warning);
}
.badge-danger {
background: var(--danger-bg);
color: #991b1b;
color: var(--danger);
}
/* ============================================================================
@@ -591,11 +664,11 @@ textarea {
}
.calendar-day.weekend {
background: #f5f5f5;
background: var(--bg-weekend);
}
.calendar-day.holiday {
background: #fff7ed;
background: var(--bg-holiday);
}
.calendar-day.today {
@@ -643,8 +716,8 @@ textarea {
}
.status-remote {
background: #dbeafe !important;
border-color: #3b82f6 !important;
background: var(--info-bg) !important;
border-color: var(--info) !important;
}
.status-absent {
@@ -658,19 +731,19 @@ textarea {
}
.status-nodata {
background: white;
background: var(--bg-white);
}
/* Closed Day */
.calendar-day.closed {
background: #e5e7eb;
color: #9ca3af;
background: var(--bg-closed);
color: var(--text-closed);
cursor: not-allowed;
border-color: #d1d5db;
border-color: var(--border-closed);
}
.calendar-day.closed:hover {
border-color: #d1d5db;
border-color: var(--border-closed);
}
.calendar-day.closed .day-number {
@@ -678,8 +751,8 @@ textarea {
}
.team-calendar td.closed {
background: #e5e7eb;
color: #9ca3af;
background: var(--bg-closed);
color: var(--text-closed);
cursor: not-allowed;
}
@@ -723,7 +796,7 @@ textarea {
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: white;
background: var(--bg-white);
border: 2px solid var(--border);
border-radius: 8px;
cursor: pointer;
@@ -789,12 +862,12 @@ textarea {
.team-calendar th.weekend,
.team-calendar td.weekend {
background: #f5f5f5;
background: var(--bg-weekend);
}
.team-calendar th.holiday,
.team-calendar td.holiday {
background: #fff7ed;
background: var(--bg-holiday);
}
.team-calendar th.today {
@@ -820,7 +893,7 @@ textarea {
min-width: 150px;
position: sticky;
left: 0;
background: white;
background: var(--bg-white);
z-index: 1;
}
@@ -828,7 +901,7 @@ textarea {
text-align: left !important;
position: sticky;
left: 0;
background: white;
background: var(--bg-white);
z-index: 1;
}
@@ -880,7 +953,7 @@ textarea {
}
.parking-spot {
background: white;
background: var(--bg-white);
border: 2px solid var(--border);
border-radius: 8px;
padding: 1rem;
@@ -1009,7 +1082,7 @@ textarea {
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
background-color: var(--bg-white);
transition: 0.3s;
border-radius: 50%;
}
@@ -1034,7 +1107,7 @@ textarea {
}
.auth-card {
background: white;
background: var(--bg-white);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
padding: 2.5rem;
@@ -1078,7 +1151,7 @@ textarea {
.settings-section,
.profile-section {
background: white;
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
@@ -1136,6 +1209,8 @@ textarea {
border-radius: 4px;
font-size: 0.9rem;
min-width: 140px;
background: var(--bg-white);
color: var(--text);
}
.profile-field {
@@ -1194,7 +1269,7 @@ textarea {
}
.rule-section {
background: white;
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
@@ -1265,7 +1340,7 @@ textarea {
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: white;
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: 4px;
}
@@ -1298,14 +1373,15 @@ textarea {
border: 1px solid var(--border-dark);
border-radius: 4px;
font-size: 0.9rem;
background: white;
background: var(--bg-white);
color: var(--text);
}
/* ============================================================================
Admin Tables
============================================================================ */
.admin-table {
background: white;
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
@@ -1433,12 +1509,18 @@ textarea {
.team-calendar-table th.weekend,
.team-calendar-table td.weekend {
background: #f5f5f5;
background: var(--bg-weekend);
}
.team-calendar-table th.holiday,
.team-calendar-table td.holiday {
background: #fff7ed;
background: var(--bg-holiday);
}
.team-calendar-table th.closed,
.team-calendar-table td.closed {
background: var(--bg-closed);
color: var(--text-closed);
}
.team-calendar-table .member-name {
@@ -1482,7 +1564,7 @@ textarea {
}
.team-calendar-table .calendar-cell.status-remote {
background: #dbeafe !important;
background: var(--info-bg) !important;
border-color: var(--border) !important;
}
@@ -1530,8 +1612,8 @@ textarea {
.parking-badge-sm {
display: inline-block;
background: #dbeafe;
color: #1e40af;
background: var(--info-bg);
color: var(--info);
font-size: 0.55rem;
font-weight: 600;
padding: 0.1rem 0.25rem;
@@ -1647,7 +1729,8 @@ textarea {
border: 1px solid var(--border-dark);
border-radius: 6px;
font-size: 0.9rem;
background: white;
background: var(--bg-white);
color: var(--text);
min-width: 150px;
}

View File

@@ -7,6 +7,7 @@ let currentUser = null;
let offices = [];
document.addEventListener('DOMContentLoaded', async () => {
populateTimeSelects();
currentUser = await api.requireAuth();
if (!currentUser) return;
@@ -19,8 +20,35 @@ document.addEventListener('DOMContentLoaded', async () => {
setupEventListeners();
});
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() {
const response = await api.get('/api/offices');
const response = await api.get('/api/offices'); // Force fresh load
if (response && response.ok) {
offices = await response.json();
renderOffices();
@@ -31,7 +59,7 @@ function renderOffices() {
const tbody = document.getElementById('officesBody');
if (offices.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center">Nessun ufficio trovato</td></tr>';
tbody.innerHTML = '<tr><td colspan="5" class="text-center">Nessun gruppo trovato</td></tr>';
return;
}
@@ -44,7 +72,7 @@ function renderOffices() {
<td>${office.user_count || 0} utenti</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="editOffice('${office.id}')">Modifica</button>
<button class="btn btn-sm btn-danger" onclick="deleteOffice('${office.id}')" ${office.user_count > 0 ? 'title="Impossibile eliminare uffici con utenti" disabled' : ''}>Elimina</button>
<button class="btn btn-sm btn-danger" onclick="deleteOffice('${office.id}')" ${office.user_count > 0 ? 'title="Impossibile eliminare gruppi con utenti" disabled' : ''}>Elimina</button>
</td>
</tr>
`;
@@ -70,29 +98,51 @@ async function editOffice(officeId) {
document.getElementById('officeName').value = office.name;
document.getElementById('officeQuota').value = office.parking_quota;
openModal('Modifica Ufficio');
// Set assignment mode mapping
const modeSelect = document.getElementById('assignmentModeSelect');
if (office.booking_window_enabled === false) {
modeSelect.value = 'realtime';
} else {
modeSelect.value = office.assignment_mode || 'random';
}
// Set cutoff time
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;
updateCutoffVisibility();
openModal('Modifica Gruppo');
}
function updateCutoffVisibility() {
const mode = document.getElementById('assignmentModeSelect').value;
const group = document.getElementById('cutoffGroup');
if (group) {
group.style.display = (mode === 'realtime') ? 'none' : 'block';
}
}
async function deleteOffice(officeId) {
const office = offices.find(o => o.id === officeId);
if (!office) return;
if (!confirm(`Eliminare l'ufficio "${office.name}"?`)) return;
if (!confirm(`Eliminare il gruppo "${office.name}"?`)) return;
const response = await api.delete(`/api/offices/${officeId}`);
if (response && response.ok) {
utils.showMessage('Ufficio eliminato', 'success');
utils.showMessage('Gruppo eliminato', 'success');
api.invalidateCache('/api/offices'); // Clear cache
await loadOffices();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Impossibile eliminare l\'ufficio', 'error');
utils.showMessage(error.detail || 'Impossibile eliminare il gruppo', 'error');
}
}
function setupEventListeners() {
// Add button
document.getElementById('addOfficeBtn').addEventListener('click', () => {
openModal('Nuovo Ufficio');
openModal('Nuovo Gruppo');
});
// Modal close
@@ -109,6 +159,9 @@ function setupEventListeners() {
// Form submit
const form = document.getElementById('officeForm');
form.addEventListener('submit', handleOfficeSubmit);
// Assignment mode change
document.getElementById('assignmentModeSelect').addEventListener('change', updateCutoffVisibility);
}
async function handleOfficeSubmit(e) {
@@ -121,9 +174,15 @@ async function handleOfficeSubmit(e) {
saveBtn.innerHTML = 'Salvataggio...';
const officeId = document.getElementById('officeId').value;
const mode = document.getElementById('assignmentModeSelect').value;
const data = {
name: document.getElementById('officeName').value,
parking_quota: parseInt(document.getElementById('officeQuota').value) || 0
parking_quota: parseInt(document.getElementById('officeQuota').value) || 0,
booking_window_enabled: mode !== 'realtime',
booking_window_end_hour: parseInt(document.getElementById('officeCutoffHour').value),
booking_window_end_minute: parseInt(document.getElementById('officeCutoffMinute').value),
assignment_mode: mode === 'realtime' ? 'random' : mode
};
console.log('Payload:', data);
@@ -140,7 +199,8 @@ async function handleOfficeSubmit(e) {
if (response && response.ok) {
closeModal();
utils.showMessage(officeId ? 'Ufficio aggiornato' : 'Ufficio creato', 'success');
utils.showMessage(officeId ? 'Gruppo aggiornato' : 'Gruppo creato', 'success');
api.invalidateCache('/api/offices'); // Clear cache
await loadOffices();
} else {
let errorMessage = 'Errore operazione';

View File

@@ -23,14 +23,16 @@ document.addEventListener('DOMContentLoaded', async () => {
});
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) {
offices = await response.json();
}
}
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) {
users = await response.json();
renderUsers();
@@ -127,7 +129,7 @@ async function editUser(userId) {
// Populate office dropdown
const officeSelect = document.getElementById('editOffice');
officeSelect.innerHTML = '<option value="">Nessun ufficio</option>';
officeSelect.innerHTML = '<option value="">Nessun gruppo</option>';
offices.forEach(o => {
const option = document.createElement('option');
option.value = o.id;
@@ -166,6 +168,8 @@ async function deleteUser(userId) {
const response = await api.delete(`/api/users/${userId}`);
if (response && response.ok) {
utils.showMessage('Utente eliminato', 'success');
api.invalidateCache('/api/users');
api.invalidateCache('/api/offices'); // Invalidate office counts
await loadUsers();
} else {
const error = await response.json();
@@ -210,6 +214,8 @@ function setupEventListeners() {
if (response && response.ok) {
document.getElementById('userModal').style.display = 'none';
utils.showMessage('Utente aggiornato', 'success');
api.invalidateCache('/api/users');
api.invalidateCache('/api/offices'); // Invalidate office counts
await loadUsers();
} else {
const error = await response.json();

View File

@@ -23,6 +23,80 @@ const api = {
*/
clearToken() {
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
*/
async checkAuth() {
// Try to get current user - works with Authelia headers or JWT
const response = await fetch('/api/auth/me', {
const url = '/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()}` } : {}
});
if (response.ok) {
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;

View File

@@ -75,7 +75,7 @@ const ModalLogic = {
},
openModal(data) {
const { dateStr, userName, presence, parking, userId, isReadOnly } = data;
const { dateStr, userName, presence, parking, userId, isParkingOnly } = data;
this.currentDate = dateStr;
this.currentUserId = userId; // Optional, for team view
@@ -84,8 +84,9 @@ const ModalLogic = {
const modal = document.getElementById('dayModal');
const title = document.getElementById('dayModalTitle');
const userLabel = document.getElementById('dayModalUser');
const statusButtons = document.querySelector('#dayModal .status-buttons');
const clearBtn = document.getElementById('clearDayBtn');
title.textContent = utils.formatDateDisplay(dateStr);
// Show/Hide User Name (for Team Calendar)
if (userName && userLabel) {
@@ -95,22 +96,26 @@ const ModalLogic = {
userLabel.style.display = 'none';
}
// Highlight status
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
const status = btn.dataset.status;
if (presence && presence.status === status) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Clear button visibility
const clearBtn = document.getElementById('clearDayBtn');
if (presence) {
clearBtn.style.display = 'block';
// Presence Logic
if (isParkingOnly) {
if (statusButtons) statusButtons.style.display = 'none';
if (clearBtn) clearBtn.style.display = 'none';
title.textContent = `Gestione Parcheggio - ${utils.formatDateDisplay(dateStr)}`;
} else {
clearBtn.style.display = 'none';
if (statusButtons) statusButtons.style.display = 'grid';
if (clearBtn) clearBtn.style.display = presence ? 'block' : 'none';
title.textContent = utils.formatDateDisplay(dateStr);
// Highlight status
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
const status = btn.dataset.status;
if (presence && presence.status === status) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// Parking Section & Reset Form
@@ -122,7 +127,8 @@ const ModalLogic = {
if (parking) {
parkingSection.style.display = 'block';
const spotName = parking.spot_display_name || parking.spot_id;
parkingInfo.innerHTML = `<strong>Parcheggio:</strong> Posto ${spotName}`;
const occupantInfo = userName ? ` (Occupato da ${userName})` : '';
parkingInfo.innerHTML = `<strong>Posto ${spotName}</strong>${occupantInfo}`;
} else {
parkingSection.style.display = 'none';
}

View File

@@ -42,6 +42,20 @@ const ICONS = {
settings: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>`,
sun: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>`,
moon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>`
};
@@ -50,8 +64,7 @@ const NAV_ITEMS = [
{ href: '/team-calendar', icon: 'users', label: 'Calendario del team' },
{ href: '/team-rules', icon: 'rules', label: 'Regole parcheggio', roles: ['admin', 'manager'] },
{ href: '/admin/users', icon: 'user', label: 'Gestione Utenti', roles: ['admin'] },
{ href: '/admin/offices', icon: 'building', label: 'Gestione Uffici', roles: ['admin'] },
{ href: '/parking-settings', icon: 'settings', label: 'Impostazioni Ufficio', roles: ['admin', 'manager'] }
{ href: '/admin/offices', icon: 'building', label: 'Gestione Gruppi', roles: ['admin'] }
];
function getIcon(name) {
@@ -98,9 +111,10 @@ async function initNav() {
// Setup user menu (logout) & mobile menu
setupUserMenu();
setupMobileMenu();
setupThemeToggle();
// CHECK: Block access if user has no office (and is not admin)
// Admins are allowed to access "Gestione Uffici" even without an office
// Admins are allowed to access "Gestione Gruppi" even without an office
if (currentUser && !currentUser.office_id && currentUser.role !== 'admin') {
navContainer.innerHTML = ''; // Clear nav
@@ -124,14 +138,14 @@ async function initNav() {
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<h2 style="margin-bottom: 1rem;">Ufficio non assegnato</h2>
<h2 style="margin-bottom: 1rem;">Gruppo 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.
Il tuo account <strong>${currentUser.email}</strong> è attivo, ma non sei ancora stato assegnato a nessuno gruppo.
</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>
Contatta l'amministratore di sistema per richiedere l'assegnazione al tuo gruppo 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>
@@ -212,6 +226,35 @@ function setupUserMenu() {
}
}
function setupThemeToggle() {
// Apply immediate theme to avoid flash
const savedTheme = localStorage.getItem('theme') || 'system';
applyTheme(savedTheme);
// Watch for system theme changes if set to system
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (localStorage.getItem('theme') === 'system') {
applyTheme('system');
}
});
}
function applyTheme(theme) {
let isDark = false;
if (theme === 'system') {
isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
} else {
isDark = theme === 'dark';
}
if (isDark) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('theme', theme);
}
// Export for use in other scripts
window.getIcon = getIcon;

View File

@@ -1,317 +0,0 @@
let currentUser = null;
let currentOffice = null;
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
if (!currentUser) return;
// Only Manager or Admin
if (!['admin', 'manager'].includes(currentUser.role)) {
window.location.href = '/';
return;
}
// Initialize UI
populateHourSelect();
// Set default date to tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
document.getElementById('testDateStart').valueAsDate = tomorrow;
await loadOffices();
setupEventListeners();
});
async function loadOffices() {
const select = document.getElementById('officeSelect');
const card = document.getElementById('officeSelectionCard');
const content = document.getElementById('settingsContent');
// Only Admins see the selector
if (currentUser.role === 'admin') {
card.style.display = 'block';
content.style.display = 'none'; // Hide until selected
try {
const response = await api.get('/api/offices');
if (response && response.ok) {
const offices = await response.json();
offices.forEach(office => {
const option = document.createElement('option');
option.value = office.id;
option.textContent = office.name;
select.appendChild(option);
});
}
} catch (e) {
console.error(e);
utils.showMessage('Errore caricamento uffici', 'error');
}
} else {
// Manager uses their own office
card.style.display = 'none';
content.style.display = 'block';
if (currentUser.office_id) {
await loadOfficeSettings(currentUser.office_id);
} else {
utils.showMessage('Nessun ufficio assegnato al manager', 'error');
}
}
}
function populateHourSelect() {
const select = document.getElementById('bookingWindowHour');
select.innerHTML = '';
for (let h = 0; h < 24; h++) {
const option = document.createElement('option');
option.value = h;
option.textContent = h.toString().padStart(2, '0');
select.appendChild(option);
}
}
async function loadOfficeSettings(id) {
const officeId = id;
if (!officeId) {
utils.showMessage('Nessun ufficio selezionato', 'error');
return;
}
try {
const response = await api.get(`/api/offices/${officeId}`);
if (!response.ok) throw new Error('Failed to load office');
const office = await response.json();
currentOffice = office;
// Populate form
document.getElementById('bookingWindowEnabled').checked = office.booking_window_enabled || false;
document.getElementById('bookingWindowHour').value = office.booking_window_end_hour ?? 18; // Default 18
document.getElementById('bookingWindowMinute').value = office.booking_window_end_minute ?? 0;
updateVisibility();
} catch (e) {
console.error(e);
utils.showMessage('Errore nel caricamento impostazioni', 'error');
}
}
function updateVisibility() {
const enabled = document.getElementById('bookingWindowEnabled').checked;
document.getElementById('cutoffTimeGroup').style.display = enabled ? 'block' : 'none';
}
function setupEventListeners() {
// Office Select
document.getElementById('officeSelect').addEventListener('change', (e) => {
const id = e.target.value;
if (id) {
document.getElementById('settingsContent').style.display = 'block';
loadOfficeSettings(id);
} else {
document.getElementById('settingsContent').style.display = 'none';
}
});
// Toggle visibility
document.getElementById('bookingWindowEnabled').addEventListener('change', updateVisibility);
// Save Settings
document.getElementById('scheduleForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentOffice) return;
const data = {
booking_window_enabled: document.getElementById('bookingWindowEnabled').checked,
booking_window_end_hour: parseInt(document.getElementById('bookingWindowHour').value),
booking_window_end_minute: parseInt(document.getElementById('bookingWindowMinute').value)
};
try {
const res = await api.put(`/api/offices/${currentOffice.id}`, data);
if (res) {
utils.showMessage('Impostazioni salvate con successo', 'success');
currentOffice = res;
}
} catch (e) {
utils.showMessage('Errore nel salvataggio', 'error');
}
});
// Test Tools
// Test Tools
document.getElementById('runAllocationBtn').addEventListener('click', async () => {
if (!confirm('Sei sicuro di voler avviare l\'assegnazione ORA? Questo potrebbe sovrascrivere le assegnazioni esistenti per la data selezionata.')) return;
const dateStart = document.getElementById('testDateStart').value;
const dateEnd = document.getElementById('testDateEnd').value;
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
let start = new Date(dateStart);
let end = dateEnd ? new Date(dateEnd) : new Date(dateStart);
if (end < start) {
return utils.showMessage('La data di fine deve essere successiva alla data di inizio', 'error');
}
let current = new Date(start);
let successCount = 0;
let errorCount = 0;
utils.showMessage('Avvio assegnazione...', 'success');
while (current <= end) {
const dateStr = utils.formatDate(current);
try {
await api.post('/api/parking/run-allocation', {
date: dateStr,
office_id: currentOffice.id
});
successCount++;
} catch (e) {
console.error(`Error for ${dateStr}`, e);
errorCount++;
}
current.setDate(current.getDate() + 1);
}
if (errorCount === 0) {
utils.showMessage(`Assegnazione completata per ${successCount} giorni.`, 'success');
} else {
utils.showMessage(`Completato con errori: ${successCount} successi, ${errorCount} errori.`, 'warning');
}
});
document.getElementById('clearAssignmentsBtn').addEventListener('click', async () => {
if (!confirm('ATTENZIONE: Stai per eliminare TUTTE le assegnazioni per il periodo selezionato. 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');
let start = new Date(dateStart);
let end = dateEnd ? new Date(dateEnd) : new Date(dateStart);
if (end < start) {
return utils.showMessage('La data di fine deve essere successiva alla data di inizio', 'error');
}
let current = new Date(start);
let totalRemoved = 0;
utils.showMessage('Rimozione in corso...', 'warning');
// Loop is fine, but maybe redundant if we could batch clean?
// Backend clear-assignments is per day.
while (current <= end) {
const dateStr = utils.formatDate(current);
try {
const res = await api.post('/api/parking/clear-assignments', {
date: dateStr,
office_id: currentOffice.id
});
if (res && res.ok) {
const data = await res.json();
totalRemoved += (data.count || 0);
}
} catch (e) {
console.error(`Error clearing ${dateStr}`, e);
}
current.setDate(current.getDate() + 1);
}
utils.showMessage(`Operazione eseguita.`, 'warning');
});
const clearPresenceBtn = document.getElementById('clearPresenceBtn');
if (clearPresenceBtn) {
clearPresenceBtn.addEventListener('click', async () => {
if (!confirm('ATTENZIONE: Stai per eliminare TUTTI GLI STATI (Presente/Assente/ecc) e relative assegnazioni per tutti gli utenti dell\'ufficio nel periodo selezionato. \n\nQuesta azione è irreversibile. Procedere?')) return;
const dateStart = document.getElementById('testDateStart').value;
const dateEnd = document.getElementById('testDateEnd').value;
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
// Validate office
if (!currentOffice || !currentOffice.id) {
return utils.showMessage('Errore: Nessun ufficio selezionato', 'error');
}
const endDateVal = dateEnd || dateStart;
utils.showMessage('Rimozione stati in corso...', 'warning');
try {
const res = await api.post('/api/presence/admin/clear-office-presence', {
start_date: dateStart,
end_date: endDateVal,
office_id: currentOffice.id
});
if (res && res.ok) {
const data = await res.json();
utils.showMessage(`Operazione completata. Rimossi ${data.count_presence} stati e ${data.count_parking} assegnazioni.`, 'success');
} else {
const err = await res.json();
utils.showMessage('Errore: ' + (err.detail || 'Operazione fallita'), 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server', 'error');
}
});
}
const testEmailBtn = document.getElementById('testEmailBtn');
if (testEmailBtn) {
testEmailBtn.addEventListener('click', async () => {
const dateVal = document.getElementById('testEmailDate').value;
// Validate office
if (!currentOffice || !currentOffice.id) {
return utils.showMessage('Errore: Nessun ufficio selezionato', 'error');
}
utils.showMessage('Invio mail di test in corso...', 'warning');
try {
const res = await api.post('/api/parking/test-email', {
date: dateVal || null,
office_id: currentOffice.id
});
if (res && res.status !== 403 && res.status !== 500 && res.ok !== false) {
// API wrapper usually returns response object or parses JSON?
// api.post returns response object if 200-299, but wrapper handles some.
// Let's assume standard fetch response or check wrapper.
// api.js Wrapper returns fetch Response.
const data = await res.json();
if (data.success) {
let msg = `Email inviata con successo per la data: ${data.date}.`;
if (data.mode === 'FILE') {
msg += ' (SMTP disabilitato: Loggato su file)';
}
utils.showMessage(msg, 'success');
} else {
utils.showMessage('Invio fallito. Controlla i log del server.', 'error');
}
} else {
const err = res ? await res.json() : {};
utils.showMessage('Errore: ' + (err.detail || 'Invio fallito'), 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server', 'error');
}
});
}
}

View File

@@ -175,11 +175,12 @@ function renderCalendar() {
return date >= start && date <= end;
});
const isClosed = isWeeklyClosed || isSpecificClosed;
const isClosed = isHoliday || isWeeklyClosed || isSpecificClosed;
if (isClosed) {
cell.classList.add('closed');
cell.title = "Ufficio Chiuso";
if (isHoliday) cell.title = "Festività";
else cell.title = "Gruppo Chiuso";
} else if (presence) {
cell.classList.add(`status-${presence.status}`);
}
@@ -233,36 +234,44 @@ async function handleClearPresence(date) {
async function handleReleaseParking(assignmentId) {
if (!confirm('Rilasciare il parcheggio per questa data?')) return;
const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`);
utils.showMessage('Rilascio in corso...', 'warning');
const response = await api.post('/api/parking/reassign-spot', {
assignment_id: assignmentId,
new_user_id: null
});
if (response && response.ok) {
await loadParkingAssignments();
utils.showMessage('Posto liberato con successo', 'success');
await Promise.all([loadParkingAssignments(), loadDailyStatus()]);
renderCalendar();
ModalLogic.closeModal();
} else {
const error = await response.json();
alert(error.detail || 'Impossibile rilasciare il parcheggio');
utils.showMessage(error.detail || 'Impossibile rilasciare il parcheggio', 'error');
}
}
async function handleReassignParking(assignmentId, newUserId) {
// Basic validation handled by select; confirm
if (!assignmentId || !newUserId) {
alert('Seleziona un utente');
utils.showMessage('Seleziona un utente', 'error');
return;
}
utils.showMessage('Riassegnazione in corso...', 'warning');
const response = await api.post('/api/parking/reassign-spot', {
assignment_id: assignmentId,
new_user_id: newUserId
});
if (response && response.ok) {
await loadParkingAssignments();
utils.showMessage('Posto riassegnato con successo', 'success');
await Promise.all([loadParkingAssignments(), loadDailyStatus()]);
renderCalendar();
ModalLogic.closeModal();
} else {
const error = await response.json();
alert(error.detail || 'Impossibile riassegnare il parcheggio');
utils.showMessage(error.detail || 'Impossibile riassegnare il parcheggio', 'error');
}
}
@@ -418,10 +427,10 @@ function initParkingStatus() {
if (headerDisplay) headerDisplay.textContent = currentUser.office_name;
} else {
const nameDisplay = document.getElementById('statusOfficeName');
if (nameDisplay) nameDisplay.textContent = 'Tuo Ufficio';
if (nameDisplay) nameDisplay.textContent = 'Tuo Gruppo';
const headerDisplay = document.getElementById('currentOfficeDisplay');
if (headerDisplay) headerDisplay.textContent = 'Tuo Ufficio';
if (headerDisplay) headerDisplay.textContent = 'Tuo Gruppo';
}
}
@@ -455,6 +464,34 @@ async function loadDailyStatus() {
const officeId = currentUser.office_id;
const grid = document.getElementById('spotsGrid');
const badge = document.getElementById('spotsCountBadge');
// Check if it's a closing day
const dayOfWeek = statusDate.getDay();
const isHoliday = utils.isItalianHoliday(statusDate);
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);
const check = new Date(statusDate); check.setHours(0, 0, 0, 0);
return check >= start && check <= end;
});
if (isHoliday || isWeeklyClosed || isSpecificClosed) {
if (grid) {
grid.innerHTML = `
<div style="width:100%; text-align:center; padding:1rem 1rem; color: var(--text-secondary); background: var(--bg-hover); border-radius: 8px;">
<div style="font-size: 1.2rem; font-weight: 500;">Ufficio Chiuso</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.8;">Nessun parcheggio disponibile in questa data.</div>
</div>`;
}
if (badge) badge.style.display = 'none';
return;
} else {
if (badge) badge.style.display = 'inline-block';
}
// Keep grid height to avoid jump if possible, or just loading styling
if (grid) grid.innerHTML = '<div style="width:100%; text-align:center; padding:2rem; color:var(--text-secondary);">Caricamento...</div>';
@@ -505,10 +542,9 @@ function renderParkingStatus(assignments) {
// Colors: Free = Green (default), Occupied = Yellow (requested)
// Yellow palette: Border #eab308, bg #fefce8, text #a16207, icon #eab308
const borderColor = isFree ? '#22c55e' : '#eab308';
const bgColor = isFree ? '#f0fdf4' : '#fefce8';
const textColor = isFree ? '#15803d' : '#a16207';
const iconColor = isFree ? '#22c55e' : '#eab308';
const borderColor = isFree ? 'var(--spot-free-border)' : 'var(--spot-occ-border)';
const bgColor = isFree ? 'var(--spot-free-bg)' : 'var(--spot-occ-bg)';
const textColor = isFree ? 'var(--spot-free-text)' : 'var(--spot-occ-text)';
const el = document.createElement('div');
el.className = 'spot-card';
@@ -527,15 +563,38 @@ function renderParkingStatus(assignments) {
transition: all 0.2s;
`;
// New Car Icon (Front Facing Sedan style or similar simple shape)
// Using a cleaner SVG path
el.innerHTML = `
<div style="font-weight: 600; font-size: 1.1rem; color: #1f2937;">${spotName}</div>
<div style="font-weight: 600; font-size: 1.1rem; color: var(--text);">${spotName}</div>
<div style="color: ${textColor}; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;" title="${statusText}">
${statusText}
</div>
`;
if (isFree && (currentUser.role === 'admin' || currentUser.role === 'manager')) {
el.style.cursor = 'pointer';
el.addEventListener('mouseenter', () => el.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)');
el.addEventListener('mouseleave', () => el.style.boxShadow = 'none');
el.title = "Clicca per assegnare manualmente";
el.addEventListener('click', () => {
openAdminAssignModal(a.spot_id, spotName);
});
}
if (!isFree && (currentUser.role === 'admin' || currentUser.role === 'manager')) {
el.style.cursor = 'pointer';
el.addEventListener('mouseenter', () => el.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)');
el.addEventListener('mouseleave', () => el.style.boxShadow = 'none');
el.title = "Clicca per gestire questo posto";
el.addEventListener('click', () => {
ModalLogic.openModal({
dateStr: a.date,
parking: a,
userName: a.user_name,
isParkingOnly: true
});
});
}
grid.appendChild(el);
});
@@ -569,6 +628,97 @@ function setupStatusListeners() {
loadDailyStatus();
}
});
setupAdminAssignModal();
}
// ----------------------------------------------------------------------------
// Admin Manual Assign Logic
// ----------------------------------------------------------------------------
let currentManualSpotId = null;
function setupAdminAssignModal() {
const closeBtn = document.getElementById('closeAdminAssignModal');
const cancelBtn = document.getElementById('cancelAdminAssign');
const form = document.getElementById('adminAssignForm');
const modal = document.getElementById('adminAssignSpotModal');
if (closeBtn) closeBtn.addEventListener('click', () => modal.style.display = 'none');
if (cancelBtn) cancelBtn.addEventListener('click', () => modal.style.display = 'none');
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const targetUserId = document.getElementById('adminAssignUser').value;
if (!targetUserId || !currentManualSpotId) return;
const dateStr = utils.formatDate(statusDate);
utils.showMessage('Assegnazione in corso...', 'warning');
const response = await api.post('/api/parking/manual-assign', {
date: dateStr,
user_id: targetUserId,
spot_id: currentManualSpotId,
office_id: currentUser.office_id
});
if (response && response.ok) {
utils.showMessage('Posto assegnato con successo', 'success');
modal.style.display = 'none';
loadDailyStatus(); // refresh parking status grid
// optionally refresh my presences if it affected the logged in admin
loadParkingAssignments();
} else {
const err = await response.json();
utils.showMessage(err.detail || 'Impossibile assegnare il parcheggio', 'error');
}
});
}
}
async function openAdminAssignModal(spotId, spotName) {
currentManualSpotId = spotId;
const modal = document.getElementById('adminAssignSpotModal');
const infoDisplay = document.getElementById('adminAssignSpotInfo');
const selectUser = document.getElementById('adminAssignUser');
const dateStr = utils.formatDate(statusDate);
infoDisplay.innerHTML = `Seleziona l'utente a cui assegnare il posto <strong>${spotName}</strong> per la giornata del <strong>${dateStr}</strong>.`;
selectUser.innerHTML = '<option value="">Caricamento...</option>';
modal.style.display = 'flex';
// Fetch team presences for the office effectively identifying people physically present but without parking
try {
const response = await api.get(`/api/presence/team?start_date=${dateStr}&end_date=${dateStr}&office_id=${currentUser.office_id}`);
if (response && response.ok) {
const result = await response.json();
const members = Array.isArray(result) ? result : [];
// Filter out people who already have parking
const availableMembers = members.filter(m => {
const presence = m.presences && m.presences.find(p => p.date === dateStr);
const hasParking = m.parking_dates && m.parking_dates.includes(dateStr);
return presence && presence.status === 'present' && !hasParking;
});
if (availableMembers.length === 0) {
selectUser.innerHTML = '<option value="">Nessun utente presente e senza parcheggio oggi...</option>';
} else {
selectUser.innerHTML = '<option value="">Seleziona utente...</option>';
availableMembers.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = `${user.name}`;
selectUser.appendChild(option);
});
}
}
} catch (e) {
console.error("Errore fetch team presences:", e);
console.error(e);
selectUser.innerHTML = '<option value="">Errore caricamento utenti</option>';
}
}

View File

@@ -26,7 +26,6 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadOffices();
await loadTeamData();
await loadTeamData();
// Initialize Modal Logic
ModalLogic.init({
@@ -48,7 +47,7 @@ function updateOfficeDisplay() {
// If user is employee, show their office name directly
if (currentUser.role === 'employee') {
display.textContent = currentUser.office_name || "Mio Ufficio";
display.textContent = currentUser.office_name || "Mio Gruppo";
return;
}
@@ -63,10 +62,10 @@ function updateOfficeDisplay() {
// let text = option.textContent.split('(')[0].trim();
display.textContent = option.textContent;
} else {
display.textContent = "Tutti gli Uffici";
display.textContent = "Tutti i Gruppi";
}
} else {
display.textContent = "Tutti gli Uffici";
display.textContent = "Tutti i Gruppi";
}
}
@@ -79,18 +78,20 @@ async function loadOffices() {
if (currentUser.role !== 'admin') {
select.style.display = 'none';
// Employees stop here, Managers continue to allow auto-selection logic below
if (currentUser.role === 'employee') return;
if (currentUser.role === 'employee') {
updateOfficeDisplay();
return;
}
}
const response = await api.get('/api/offices');
// Cache offices list
const response = await api.getCached('/api/offices', 60);
if (response && response.ok) {
offices = await response.json();
let filteredOffices = offices;
if (currentUser.role === 'manager') {
// 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) {
filteredOffices = offices.filter(o => o.id === currentUser.office_id);
} else {
@@ -254,8 +255,8 @@ async function loadClosingData() {
const promises = officeIdsToLoad.map(async (oid) => {
try {
const [weeklyRes, specificRes] = await Promise.all([
api.get(`/api/offices/${oid}/weekly-closing-days`),
api.get(`/api/offices/${oid}/closing-days`)
api.getCached(`/api/offices/${oid}/weekly-closing-days`, 60),
api.getCached(`/api/offices/${oid}/closing-days`, 60)
]);
officeClosingRules[oid] = { weekly: [], specific: [] };
@@ -313,7 +314,9 @@ function renderCalendar() {
// Build header row
const dayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
let headerHtml = '<th>Nome</th><th>Ufficio</th>';
const showGroup = currentUser.role === 'admin';
let headerHtml = '<th>Nome</th>';
if (showGroup) headerHtml += '<th>Gruppo</th>';
for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate);
@@ -343,15 +346,30 @@ function renderCalendar() {
// Build body rows
if (teamData.length === 0) {
body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">Nessun membro del team trovato</td></tr>`;
body.innerHTML = `<tr><td colspan="${dayCount + (showGroup ? 2 : 1)}" class="text-center">Nessun membro del team trovato</td></tr>`;
return;
}
let bodyHtml = '';
teamData.forEach(member => {
let nameHtml = member.name || 'Unknown';
if (member.ratio !== undefined && member.ratio !== null) {
nameHtml += `
<span style="margin-left: 6px; font-size: 0.8rem; color: var(--text-secondary); cursor: help;" title="(Giorni in cui hai avuto parcheggio) / (Giorni in sede)">
<svg style="vertical-align: text-bottom; margin-right: 2px;" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
${member.ratio.toFixed(2)}
</span>
`;
}
bodyHtml += `<tr>
<td class="member-name">${member.name || 'Unknown'}</td>
<td class="member-manager">${member.office_name || '-'}</td>`;
<td class="member-name" style="text-align: left; padding-left: 1rem;">${nameHtml}</td>`;
if (showGroup) {
bodyHtml += `<td class="member-manager">${member.office_name || '-'}</td>`;
}
for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate);
@@ -385,7 +403,7 @@ function renderCalendar() {
// (Already have dateStr)
const memberRules = officeClosingRules[member.office_id];
let isClosed = false;
let isClosed = isHoliday;
if (memberRules) {
// Check weekly

View File

@@ -7,6 +7,7 @@
let currentUser = null;
let offices = [];
let currentOfficeId = null;
let currentOffice = null;
let officeUsers = [];
let currentWeeklyClosingDays = [];
@@ -20,6 +21,7 @@ document.addEventListener('DOMContentLoaded', async () => {
return;
}
populateHourSelect();
await loadOffices();
setupEventListeners();
});
@@ -73,6 +75,27 @@ async function loadOfficeRules(officeId) {
document.getElementById('rulesContent').style.display = 'block';
document.getElementById('noOfficeMessage').style.display = 'none';
// Load full office object for algorithm settings
try {
const response = await api.get(`/api/offices/${officeId}`);
if (response && response.ok) {
currentOffice = await response.json();
// Populate algorithm form
const modeSelect = document.getElementById('assignmentModeSelect');
if (currentOffice.booking_window_enabled === false) {
modeSelect.value = 'realtime';
} else {
modeSelect.value = currentOffice.assignment_mode || 'random';
}
document.getElementById('bookingWindowHour').value = currentOffice.booking_window_end_hour ?? 18;
document.getElementById('bookingWindowMinute').value = currentOffice.booking_window_end_minute ?? 0;
updateAlgorithmVisibility();
}
} catch (e) {
console.error("Error loading office details:", e);
}
// Load users for this office (for dropdowns)
await loadOfficeUsers(officeId);
@@ -91,6 +114,67 @@ async function loadOfficeUsers(officeId) {
}
}
function populateHourSelect() {
const hourSelect = document.getElementById('bookingWindowHour');
const minuteSelect = document.getElementById('bookingWindowMinute');
if (!hourSelect || !minuteSelect) return;
hourSelect.innerHTML = '';
for (let h = 0; h < 24; h++) {
const option = document.createElement('option');
option.value = h;
option.textContent = h.toString().padStart(2, '0');
hourSelect.appendChild(option);
}
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);
}
}
function updateAlgorithmVisibility() {
const mode = document.getElementById('assignmentModeSelect').value;
const group = document.getElementById('cutoffTimeGroup');
if (group) group.style.display = (mode === 'realtime') ? 'none' : 'block';
}
async function saveAlgorithmSettings(e) {
e.preventDefault();
if (!currentOfficeId) return;
const btn = e.target.querySelector('button[type="submit"]');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Salvataggio...';
const mode = document.getElementById('assignmentModeSelect').value;
const data = {
assignment_mode: mode === 'realtime' ? 'random' : mode,
booking_window_enabled: mode !== 'realtime',
booking_window_end_hour: parseInt(document.getElementById('bookingWindowHour').value),
booking_window_end_minute: parseInt(document.getElementById('bookingWindowMinute').value)
};
try {
const res = await api.put(`/api/offices/${currentOfficeId}`, data);
if (res) {
utils.showMessage('Impostazioni algoritmo salvate', 'success');
currentOffice = res;
}
} catch (e) {
console.error(e);
utils.showMessage('Errore nel salvataggio impostazioni', 'error');
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// Weekly Closing Days
async function loadWeeklyClosingDays(officeId) {
const response = await api.get(`/api/offices/${officeId}/weekly-closing-days`);
@@ -134,6 +218,7 @@ async function saveWeeklyClosingDays() {
await Promise.all(promises);
utils.showMessage('Giorni di chiusura aggiornati', 'success');
api.invalidateCache(`/api/offices/${currentOfficeId}/weekly-closing-days`);
await loadWeeklyClosingDays(currentOfficeId);
} catch (error) {
console.error(error);
@@ -176,6 +261,7 @@ async function loadClosingDays(officeId) {
async function addClosingDay(data) {
const response = await api.post(`/api/offices/${currentOfficeId}/closing-days`, data);
if (response && response.ok) {
api.invalidateCache(`/api/offices/${currentOfficeId}/closing-days`);
await loadClosingDays(currentOfficeId);
document.getElementById('closingDayModal').style.display = 'none';
document.getElementById('closingDayForm').reset();
@@ -189,6 +275,7 @@ async function deleteClosingDay(id) {
if (!confirm('Eliminare questo giorno di chiusura?')) return;
const response = await api.delete(`/api/offices/${currentOfficeId}/closing-days/${id}`);
if (response && response.ok) {
api.invalidateCache(`/api/offices/${currentOfficeId}/closing-days`);
await loadClosingDays(currentOfficeId);
}
}
@@ -423,6 +510,230 @@ function setupEventListeners() {
notes: document.getElementById('exclusionNotes').value || null
});
});
// Algorithm settings events
document.getElementById('assignmentModeSelect').addEventListener('change', updateAlgorithmVisibility);
document.getElementById('algorithmForm').addEventListener('submit', saveAlgorithmSettings);
// Test Tools Logic
// Set default date to tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const testDateStart = document.getElementById('testDateStart');
if (testDateStart) testDateStart.valueAsDate = tomorrow;
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;
const dateStart = document.getElementById('testDateStart').value;
const dateEnd = document.getElementById('testDateEnd').value;
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
let start = new Date(dateStart);
let end = dateEnd ? new Date(dateEnd) : new Date(dateStart);
if (end < start) {
return utils.showMessage('La data di fine deve essere successiva alla data di inizio', 'error');
}
let current = new Date(start);
let successCount = 0;
let errorCount = 0;
utils.showMessage('Avvio assegnazione...', 'success');
while (current <= end) {
const dateStr = utils.formatDate(current);
try {
await api.post('/api/parking/run-allocation', {
date: dateStr,
office_id: currentOfficeId
});
successCount++;
} catch (e) {
console.error(`Error for ${dateStr}`, e);
errorCount++;
}
current.setDate(current.getDate() + 1);
}
if (errorCount === 0) {
utils.showMessage(`Assegnazione completata per ${successCount} giorni.`, 'success');
} else {
utils.showMessage(`Completato con errori: ${successCount} successi, ${errorCount} errori.`, 'warning');
}
});
document.getElementById('clearAssignmentsBtn').addEventListener('click', async () => {
if (!confirm('ATTENZIONE: Stai per eliminare TUTTE le assegnazioni per il periodo selezionato. 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');
let start = new Date(dateStart);
let end = dateEnd ? new Date(dateEnd) : new Date(dateStart);
if (end < start) {
return utils.showMessage('La data di fine deve essere successiva alla data di inizio', 'error');
}
let current = new Date(start);
let totalRemoved = 0;
utils.showMessage('Rimozione in corso...', 'warning');
while (current <= end) {
const dateStr = utils.formatDate(current);
try {
const res = await api.post('/api/parking/clear-assignments', {
date: dateStr,
office_id: currentOfficeId
});
if (res && res.ok) {
const data = await res.json();
totalRemoved += (data.count || 0);
}
} catch (e) {
console.error(`Error clearing ${dateStr}`, e);
}
current.setDate(current.getDate() + 1);
}
utils.showMessage(`Operazione eseguita.`, '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 del gruppo 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 (!currentOfficeId) {
return utils.showMessage('Errore: Nessun gruppo 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: currentOfficeId
});
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 (!currentOfficeId) {
return utils.showMessage('Errore: Nessun gruppo 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: currentOfficeId
});
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 (!currentOfficeId) {
return utils.showMessage('Errore: Nessun gruppo 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: currentOfficeId,
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');
}
});
}
}
// Global functions

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gestione Uffici - Parking Manager</title>
<title>Gestione Gruppi - Parking Manager</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css">
</head>
@@ -42,7 +42,7 @@
<main class="main-content">
<header class="page-header">
<h2>Gestione Uffici</h2>
<h2>Gestione Gruppi</h2>
<div class="header-actions">
</div>
</header>
@@ -50,8 +50,8 @@
<div class="content-wrapper">
<div class="card">
<div style="padding-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0;">Lista Uffici</h3>
<button class="btn btn-dark" id="addOfficeBtn">Nuovo Ufficio</button>
<h3 style="margin: 0;">Lista Gruppi</h3>
<button class="btn btn-dark" id="addOfficeBtn">Nuovo Gruppo</button>
</div>
<div class="data-table-container">
<table class="data-table" id="officesTable">
@@ -75,7 +75,7 @@
<div class="modal" id="officeModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="officeModalTitle">Nuovo Ufficio</h3>
<h3 id="officeModalTitle">Nuovo Gruppo</h3>
<button class="modal-close" id="closeOfficeModal">&times;</button>
</div>
<div class="modal-body">
@@ -83,14 +83,41 @@
<input type="hidden" id="officeId">
<div class="form-group">
<label for="officeName">Nome Ufficio</label>
<label for="officeName">Nome Gruppo</label>
<input type="text" id="officeName" required>
</div>
<div class="form-group">
<label for="officeQuota">Quota Parcheggio</label>
<input type="number" id="officeQuota" min="0" value="0" required>
<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 gruppo</small>
</div>
<div class="form-group">
<label for="assignmentModeSelect">Metodo di Assegnazione</label>
<select id="assignmentModeSelect" class="form-control">
<option value="realtime">In Tempo Reale (FIFO)</option>
<option value="random">Automatico - Casuale (Batch)</option>
<option value="fairness">Automatico - Punteggio (Batch)</option>
</select>
<small class="text-muted">FIFO: primo che arriva prende il posto. Automatico: assegnazione collettiva dopo il cut-off.</small>
</div>
<div class="form-group" id="cutoffGroup">
<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>
</div>
<small class="text-muted">Orario limite per la prenotazione del giorno successivo (solo per modalità Automatica)</small>
</div>

View File

@@ -64,7 +64,7 @@
class="sort-icon"></span></th>
<th class="sortable" data-sort="role" style="cursor: pointer;">Ruolo <span
class="sort-icon"></span></th>
<th class="sortable" data-sort="office_name" style="cursor: pointer;">Ufficio <span
<th class="sortable" data-sort="office_name" style="cursor: pointer;">Gruppo <span
class="sort-icon"></span></th>
<th class="sortable" data-sort="parking_ratio" style="cursor: pointer;">Punteggio <span
class="sort-icon"></span></th>

View File

@@ -1,177 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Office Settings - Parking Manager</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<aside class="sidebar">
<div class="sidebar-header">
<h1>Gestione Parcheggi</h1>
</div>
<nav class="sidebar-nav"></nav>
<div class="sidebar-footer">
<div class="user-menu">
<button class="user-button" id="userMenuButton">
<div class="user-avatar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<div class="user-info">
<div class="user-name" id="userName">Caricamento...</div>
<div class="user-role" id="userRole">-</div>
</div>
</button>
<div class="user-dropdown" id="userDropdown" style="display: none;">
<a href="/profile" class="dropdown-item">Profilo</a>
<a href="/settings" class="dropdown-item">Impostazioni</a>
<hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Esci</button>
</div>
</div>
</div>
</aside>
<main class="main-content">
<header class="page-header">
<h2>Impostazioni Ufficio</h2>
</header>
<div class="content-wrapper">
<!-- Office Selection Card (Admin Only) -->
<div class="card" id="officeSelectionCard" style="margin-bottom: 1.5rem; display: none;">
<div style="display: flex; align-items: center; gap: 1rem;">
<label for="officeSelect" style="font-weight: 500; min-width: max-content;">Seleziona
Ufficio:</label>
<select id="officeSelect" class="form-select" style="flex: 1; max-width: 300px;">
<option value="">Seleziona Ufficio</option>
</select>
</div>
</div>
<div id="settingsContent" style="display: none;">
<!-- Card 1: Batch Scheduling Settings -->
<div class="card">
<div class="card-header">
<h3>Schedulazione Automatica</h3>
</div>
<div class="card-body">
<form id="scheduleForm">
<div class="form-group">
<label class="toggle-label">
<span>Abilita Assegnazione Batch</span>
<label class="toggle-switch">
<input type="checkbox" id="bookingWindowEnabled">
<span class="toggle-slider"></span>
</label>
</label>
<small class="text-muted">Se abilitato, l'assegnazione dei posti avverrà solo dopo
l'orario
di cut-off del giorno precedente.</small>
</div>
<div class="form-group" id="cutoffTimeGroup">
<label>Orario di Cut-off (Giorno Precedente)</label>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<select id="bookingWindowHour" style="width: 80px;">
<!-- Populated by JS -->
</select>
<span>:</span>
<select id="bookingWindowMinute" style="width: 80px;">
<option value="0">00</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select>
</div>
<small class="text-muted">Le presenze inserite prima di questo orario saranno messe in
attesa.</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-dark">Salva Impostazioni</button>
</div>
</form>
</div>
</div>
<!-- Card 2: Testing Tools -->
<div class="card">
<div class="card-header"
style="display: flex; justify-content: space-between; align-items: center;">
<h3>Strumenti di Test</h3>
<span class="badge badge-warning">Testing Only</span>
</div>
<div class="card-body">
<p class="text-muted" style="margin-bottom: 1rem;">Usa questi strumenti per verificare il
funzionamento dell'assegnazione automatica.</p>
<div class="form-group">
<label>Range di Date di Test</label>
<div style="display: flex; gap: 1rem;">
<div>
<small>Da:</small>
<input type="date" id="testDateStart" class="form-control" style="width: 160px;">
</div>
<div>
<small>A (incluso):</small>
<input type="date" id="testDateEnd" class="form-control" style="width: 160px;">
</div>
</div>
<small class="text-muted">Lascia "A" vuoto per eseguire su un singolo giorno.</small>
</div>
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
<button id="runAllocationBtn" class="btn btn-primary">
Esegui Assegnazione Ora
</button>
<button id="clearAssignmentsBtn" class="btn btn-danger">
Elimina Tutte le Assegnazioni
</button>
<button id="clearPresenceBtn" class="btn btn-danger"
title="Elimina stati e assegnazioni per i giorni selezionati">
Elimina Stati
</button>
</div>
<hr style="margin: 1.5rem 0; border: 0; border-top: 1px solid #e5e7eb;">
<div class="form-group">
<label>Test Invio Email</label>
<div style="display: flex; gap: 1rem; align-items: flex-end;">
<div style="flex: 1;">
<small>Data di Riferimento (Opzionale):</small>
<input type="date" id="testEmailDate" class="form-control">
<small class="text-muted" style="display: block; margin-top: 0.25rem;">
Se non specificata, verrà usato il primo giorno lavorativo disponibile.
</small>
</div>
<button id="testEmailBtn" class="btn btn-secondary">
Test Invio Mail
</button>
</div>
</div>
</div>
</div>
</div> <!-- End settingsContent -->
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/parking-settings.js"></script>
</body>
</html>

View File

@@ -108,7 +108,7 @@
<!-- Date Navigation (Centered) -->
<div style="display: flex; justify-content: center; margin-bottom: 2rem;">
<div
style="display: flex; align-items: center; gap: 0.5rem; background: white; padding: 0.5rem; border-radius: 8px; border: 1px solid var(--border);">
style="display: flex; align-items: center; gap: 0.5rem; background: transparent; padding: 0.5rem; border-radius: 8px;">
<button class="btn-icon" id="statusPrevDay"
style="border: none; width: 32px; height: 32px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -117,11 +117,11 @@
</svg>
</button>
<div style="position: relative; text-align: center; min-width: 200px;">
<div style="position: relative; text-align: center; min-width: 250px; cursor: pointer;">
<div id="statusDateDisplay"
style="font-weight: 600; font-size: 1rem; text-transform: capitalize;"></div>
style="font-weight: 600; font-size: 1.1rem; text-transform: capitalize; color: var(--text);"></div>
<input type="date" id="statusDatePicker"
style="position: absolute; inset: 0; opacity: 0; cursor: pointer;">
style="position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%;">
</div>
<button class="btn-icon" id="statusNextDay"
@@ -139,7 +139,7 @@
<div
style="display: flex; align-items: center; justify-content: flex-start; gap: 1rem; margin-bottom: 1.5rem;">
<div style="font-weight: 600; font-size: 1.1rem; color: var(--text);">
Ufficio: <span id="currentOfficeDisplay" style="color: var(--primary);">...</span>
Gruppo: <span id="currentOfficeDisplay" style="color: var(--primary);">...</span>
</div>
<span class="badge"
style="background: #eff6ff; color: #1d4ed8; border: 1px solid #dbeafe; padding: 0.35rem 0.75rem; font-size: 0.85rem;">
@@ -367,7 +367,30 @@
</div>
<!-- Admin Manual Assign Spot Modal -->
<div class="modal" id="adminAssignSpotModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Assegna Posto Manualmente</h3>
<button class="modal-close" id="closeAdminAssignModal">&times;</button>
</div>
<div class="modal-body">
<p id="adminAssignSpotInfo" style="margin-bottom: 1rem;"></p>
<form id="adminAssignForm">
<div class="form-group" style="margin-bottom: 1rem;">
<label for="adminAssignUser">Seleziona Utente (Presente in Sede)</label>
<select id="adminAssignUser" class="form-control" required style="width: 100%;">
<option value="">Caricamento utenti...</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelAdminAssign">Annulla</button>
<button type="submit" class="btn btn-dark">Assegna</button>
</div>
</form>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/nav.js"></script>

View File

@@ -74,9 +74,9 @@
<small class="text-muted">Il ruolo è assegnato dal tuo amministratore</small>
</div>
<div class="form-group">
<label for="manager">Ufficio</label>
<label for="manager">Gruppo</label>
<input type="text" id="manager" disabled>
<small class="text-muted">Il tuo ufficio è assegnato dall'amministratore</small>
<small class="text-muted">Il tuo gruppo è assegnato dall'amministratore</small>
</div>
<div class="form-actions" id="profileActions">
<button type="submit" class="btn btn-dark">Salva Modifiche</button>
@@ -130,7 +130,7 @@
});
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) {
const profile = await response.json();
isLdapUser = profile.is_ldap_user;
@@ -169,6 +169,8 @@
const response = await api.put('/api/users/me/profile', data);
if (response && response.ok) {
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
const nameEl = document.getElementById('userName');
if (nameEl) nameEl.textContent = data.name;

View File

@@ -47,7 +47,23 @@
<div class="content-wrapper">
<!-- Theme Settings Card -->
<div class="card" style="margin-bottom: 2rem;">
<div class="card-header">
<h3>Tema</h3>
</div>
<div class="card-body">
<div class="form-group">
<label style="font-weight: 500; display: block; margin-bottom: 0.5rem;">Tema dell'applicazione</label>
<select id="themeSelect" class="form-select" style="max-width: 300px;">
<option value="system">Sistema (Predefinito)</option>
<option value="light">Chiaro</option>
<option value="dark">Scuro</option>
</select>
<small class="text-muted" style="display: block; margin-top: 0.5rem;">Scegli il tema preferito da utilizzare nell'applicazione.</small>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Notifiche Parcheggio</h3>
@@ -138,6 +154,10 @@
document.getElementById('notifyParkingChanges').checked = currentUser.notify_parking_changes !== 0;
updateDailyTimeVisibility();
// Populate Theme
const savedTheme = localStorage.getItem('theme') || 'system';
document.getElementById('themeSelect').value = savedTheme;
}
function updateDailyTimeVisibility() {
@@ -173,6 +193,15 @@
// Toggle daily time visibility
document.getElementById('notifyDailyParking').addEventListener('change', updateDailyTimeVisibility);
// Save Theme dynamically
document.getElementById('themeSelect').addEventListener('change', (e) => {
const theme = e.target.value;
if (typeof applyTheme === 'function') {
applyTheme(theme);
utils.showMessage('Tema aggiornato', 'success');
}
});
}
</script>
</body>

View File

@@ -55,14 +55,14 @@
<option value="month">Mese</option>
</select>
<select id="officeFilter" class="form-select" style="min-width: 200px;">
<option value="">Tutti gli Uffici</option>
<option value="">Tutti i Gruppi</option>
</select>
</div>
<div id="office-display-header"
style="margin-bottom: 1rem; font-weight: 600; font-size: 1.1rem; color: var(--text);">
Ufficio: <span id="currentOfficeNameDisplay" style="color: var(--primary);">Loading...</span>
Gruppo: <span id="currentOfficeNameDisplay" style="color: var(--primary);">Loading...</span>
</div>
<div class="calendar-header">

View File

@@ -60,6 +60,43 @@
</div>
<div id="rulesContent" style="display: none;">
<!-- Card: Algorithm Settings -->
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<h3>Impostazioni Algoritmo Parcheggio</h3>
</div>
<div class="card-body">
<form id="algorithmForm">
<div class="form-group" style="padding-bottom: 1rem; border-bottom: 1px solid #e5e7eb; margin-bottom: 1rem;">
<label style="font-weight: 500; display: block; margin-bottom: 0.5rem;">Metodo di Assegnazione</label>
<p class="text-muted" style="margin-bottom: 0.5rem;">Scegli come assegnare i posti auto ai membri del gruppo.</p>
<select id="assignmentModeSelect" class="form-select" style="max-width: 300px;">
<option value="realtime">In Tempo Reale (FIFO)</option>
<option value="random">Automatico - Casuale (Batch)</option>
<option value="fairness">Automatico - Punteggio (Batch)</option>
</select>
<small class="text-muted" style="display: block; margin-top: 0.5rem;">FIFO: assegnazione immediata. Automatico: assegnazione collettiva dopo il cut-off.</small>
</div>
<div class="form-group" id="cutoffTimeGroup">
<label style="font-weight: 500;">Orario di Cut-off (Giorno Precedente)</label>
<div style="display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem;">
<select id="bookingWindowHour" style="width: 80px;">
</select>
<span>:</span>
<select id="bookingWindowMinute" style="width: 80px;">
</select>
</div>
<small class="text-muted">Orario limite per la prenotazione del giorno successivo (solo per modalità Automatica)</small>
</div>
<div class="form-actions" style="margin-top: 1.5rem;">
<button type="submit" class="btn btn-dark">Salva Impostazioni</button>
</div>
</form>
</div>
</div>
<!-- Weekly Closing Days -->
<div class="card">
<div class="card-header">
@@ -118,13 +155,76 @@
<div id="exclusionsList" class="rules-list"></div>
</div>
</div>
<!-- Card: Testing Tools -->
<div class="card" style="margin-top: 1.5rem;">
<div class="card-header"
style="display: flex; justify-content: space-between; align-items: center;">
<h3>Strumenti di Test</h3>
<span class="badge" style="background: #fff7ed; color: #c2410c; border: 1px solid #ffedd5; padding: 0.25rem 0.5rem; font-size: 0.75rem;">Testing Only</span>
</div>
<div class="card-body">
<p class="text-muted" style="margin-bottom: 1rem;">Usa questi strumenti per verificare il
funzionamento dell'assegnazione automatica.</p>
<div class="form-group">
<label>Range di Date di Test</label>
<div style="display: flex; gap: 1rem;">
<div>
<small>Da:</small>
<input type="date" id="testDateStart" class="form-control" style="width: 160px;">
</div>
<div>
<small>A (incluso):</small>
<input type="date" id="testDateEnd" class="form-control" style="width: 160px;">
</div>
</div>
<small class="text-muted">Lascia "A" vuoto per eseguire su un singolo giorno.</small>
</div>
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
<button id="runAllocationBtn" class="btn btn-primary">
Esegui Assegnazione Ora
</button>
<button id="clearAssignmentsBtn" class="btn btn-danger">
Elimina Tutte le Assegnazioni
</button>
<button id="clearPresenceBtn" class="btn btn-danger"
title="Elimina stati e assegnazioni per i giorni selezionati">
Elimina Stati
</button>
</div>
<hr style="margin: 1.5rem 0; border: 0; border-top: 1px solid #e5e7eb;">
<div class="form-group">
<label>Test Invio Email</label>
<div style="display: flex; gap: 1rem; align-items: flex-end;">
<div style="flex: 1;">
<small>Data di Riferimento (Opzionale):</small>
<input type="date" id="testEmailDate" class="form-control">
<small class="text-muted" style="display: block; margin-top: 0.25rem;">
Se non specificata, verrà usato il primo giorno lavorativo disponibile.
</small>
</div>
<button id="testEmailBtn" class="btn btn-secondary">
Test (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 id="noOfficeMessage">
<div class="card">
<div class="card-body text-center">
<p>Seleziona un ufficio sopra per gestirne le regole di parcheggio</p>
<p>Seleziona un gruppo sopra per gestirne le regole di parcheggio</p>
</div>
</div>
</div>

29
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.presence import router as presence_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
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
async def lifespan(app: FastAPI):
"""Initialize database on startup"""
@@ -72,6 +91,9 @@ async def lifespan(app: FastAPI):
log(f"feedback: App reachable via Caddy at {reachable_url}")
# Start scheduler
asyncio.create_task(scheduler_task())
yield
log("Shutting down Parking Manager application")
@@ -173,10 +195,7 @@ async def settings_page():
return FileResponse(config.FRONTEND_DIR / "pages" / "settings.html")
@app.get("/parking-settings")
async def parking_settings_page():
"""Parking Settings page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "parking-settings.html")
@app.get("/favicon.svg")

25
migrate_db.py Normal file
View File

@@ -0,0 +1,25 @@
import sqlite3
def migrate():
db_path = "data/parking.db"
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Add the new column
cursor.execute("ALTER TABLE offices ADD COLUMN assignment_mode VARCHAR DEFAULT 'fairness'")
print("Success: Added assignment_mode to offices table.")
conn.commit()
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("Info: Column assignment_mode already exists.")
else:
print(f"Error: {e}")
finally:
if conn:
conn.close()
if __name__ == "__main__":
migrate()

26
rename_script.py Normal file
View File

@@ -0,0 +1,26 @@
import os
frontend_dir = r"\\wsl.localhost\Ubuntu\home\ssalemi\Progetti\Rocketscale\org-parking\frontend"
targets = [
("Ufficio", "Gruppo"),
("Uffici", "Gruppi"),
("ufficio", "gruppo"),
("uffici", "gruppi")
]
for root, _, files in os.walk(frontend_dir):
for filename in files:
if filename.endswith(".html") or filename.endswith(".js"):
filepath = os.path.join(root, filename)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
original_content = content
# Replacing case sensitive
for old, new in targets:
content = content.replace(old, new)
if content != original_content:
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
print(f"Updated {filepath}")

View File

@@ -100,17 +100,17 @@ def notify_parking_assigned(user: "User", assignment_date: date, spot_name: str)
if not user.notify_parking_changes:
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"""
<html>
<body>
<h2>Parking Spot Assigned</h2>
<p>Hi {user.name},</p>
<p>You have been assigned a parking spot for {day_name}:</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p>
<p>Best regards,<br>Parking Manager</p>
<h2>Posto Auto Assegnato</h2>
<p>Ciao {user.name},</p>
<p>Ti è stato assegnato un posto auto 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>
</body>
</html>
"""
@@ -122,37 +122,64 @@ def notify_parking_released(user: "User", assignment_date: date, spot_name: str)
if not user.notify_parking_changes:
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"""
<html>
<body>
<h2>Parking Spot Released</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been released.</p>
<p>Best regards,<br>Parking Manager</p>
<h2>Posto Auto Rilasciato</h2>
<p>Ciao {user.name},</p>
<p>Il tuo posto auto (Posto {spot_name}) per il giorno {day_name} è stato rilasciato.</p>
<p>Cordiali saluti,<br>Team Parking Manager</p>
</body>
</html>
"""
send_email(user.email, subject, body_html)
def notify_parking_released_to_user(user: "User", assignment_date: date, spot_name: str, previous_user_name: str = None):
"""Send notification when a parking spot is granted due to someone else releasing it"""
if not user.notify_parking_changes:
return
day_name = assignment_date.strftime("%d/%m/%Y")
subject = f"Assegnazione Riparatoria - {day_name}"
if previous_user_name:
message = f"Ti è stato ceduto il posto da {previous_user_name} per il giorno {day_name}:"
else:
message = f"Hai ottenuto un posto in ritardo per una rinuncia per il giorno {day_name}:"
body_html = f"""
<html>
<body>
<h2>Posto Auto Assegnato (Rinuncia)</h2>
<p>Ciao {user.name},</p>
<p>{message}</p>
<p style="font-size: 18px; font-weight: bold;">Posto {spot_name}</p>
<p>Cordiali saluti,<br>Team Parking Manager</p>
</body>
</html>
"""
send_email(user.email, subject, body_html)
def notify_parking_reassigned(user: "User", assignment_date: date, spot_name: str, new_user_name: str):
"""Send notification when parking spot is reassigned to someone else"""
if not user.notify_parking_changes:
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"""
<html>
<body>
<h2>Parking Spot Reassigned</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been reassigned to {new_user_name}.</p>
<p>Best regards,<br>Parking Manager</p>
<h2>Posto Auto Riassegnato</h2>
<p>Ciao {user.name},</p>
<p>Il tuo posto auto (Posto {spot_name}) per il giorno {day_name} è stato riassegnato a {new_user_name}.</p>
<p>Cordiali saluti,<br>Team Parking Manager</p>
</body>
</html>
"""
@@ -188,19 +215,19 @@ def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Sessi
return False
# Send reminder
start_date = next_week_dates[0].strftime("%B %d")
end_date = next_week_dates[-1].strftime("%B %d, %Y")
start_date = next_week_dates[0].strftime("%d/%m/%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"""
<html>
<body>
<h2>Presence Reminder</h2>
<p>Hi {user.name},</p>
<p>This is a friendly reminder to fill your presence for the upcoming week
<h2>Promemoria Compilazione Presenze</h2>
<p>Ciao {user.name},</p>
<p>Ti ricordiamo di compilare le tue presenze per la prossima settimana
({start_date} - {end_date}).</p>
<p>Please log in to the Parking Manager to mark your presence.</p>
<p>Best regards,<br>Parking Manager</p>
<p>Accedi al Parking Manager per segnare le tue presenze.</p>
<p>Cordiali saluti,<br>Team Parking Manager</p>
</body>
</html>
"""
@@ -225,25 +252,17 @@ def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Sessi
def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session") -> bool:
"""Send daily parking reminder for a specific date"""
from database.models import DailyParkingAssignment, NotificationLog
from database.models import DailyParkingAssignment
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:
config.logger.debug(f"[SCHEDULER] User {user.email} has disabled daily parking notifications")
return False
date_str = date_obj.strftime("%Y-%m-%d")
assignment_date = date_obj.date()
# Check if already sent for this date
existing = db.query(NotificationLog).filter(
NotificationLog.user_id == user.id,
NotificationLog.notification_type == NotificationType.DAILY_PARKING,
NotificationLog.reference_date == date_str
).first()
if existing:
return False
# Get parking assignment for this date
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user.id,
@@ -254,31 +273,22 @@ def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session")
return False
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"""
<html>
<body>
<h2>Daily Parking Reminder</h2>
<p>Hi {user.name},</p>
<p>You have a parking spot assigned for today ({day_name}):</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p>
<p>Best regards,<br>Parking Manager</p>
<h2>Promemoria Parcheggio Giornaliero</h2>
<p>Ciao {user.name},</p>
<p>Hai un posto auto assegnato per oggi ({day_name}):</p>
<p style="font-size: 18px; font-weight: bold;">Posto {spot_name}</p>
<p>Cordiali saluti,<br>Team 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.DAILY_PARKING,
reference_date=date_str,
sent_at=datetime.utcnow()
)
db.add(log)
db.commit()
return True
return False
@@ -291,11 +301,15 @@ def run_scheduled_notifications(db: "Session"):
Schedule:
- Thursday at 12:00: Presence reminder for next week
- 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
now = datetime.now()
# Use configured timezone
tz = ZoneInfo(config.TIMEZONE)
now = datetime.now(tz)
current_hour = now.hour
current_minute = now.minute
current_weekday = now.weekday() # 0=Monday, 6=Sunday
@@ -304,14 +318,45 @@ def run_scheduled_notifications(db: "Session"):
users = db.query(User).all()
for user in users:
# Thursday at 12: Presence reminder
if current_weekday == 3 and current_hour == 12 and current_minute < 5:
next_week = get_next_week_dates(today_date)
send_presence_reminder(user, next_week, db)
# Thursday Reminder: DISABLED as per user request
# if current_weekday == 3 and current_hour == 12 and current_minute < 5:
# next_week = get_next_week_dates(today_date)
# send_presence_reminder(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_minute = user.notify_daily_parking_minute or 0
if current_hour == user_hour and abs(current_minute - user_minute) < 5:
# Daily parking reminder at user's preferred time
user_hour = user.notify_daily_parking_hour or 8
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 current_minute == user_minute:
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)

View File

@@ -19,6 +19,7 @@ from database.models import (
)
from utils.helpers import generate_uuid
from app import config
from services.notifications import notify_parking_assigned
def get_spot_prefix(office: Office, db: Session) -> str:
@@ -215,15 +216,29 @@ def get_users_wanting_parking(office_id: str, pool_date: date, db: Session) -> l
def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
"""
Assign parking spots fairly based on parking ratio.
Assign parking spots based on the office's assignment_mode (fairness or random).
Creates new DailyParkingAssignment rows only for assigned users.
"""
if is_closing_day(office_id, pool_date, db):
return {"assigned": [], "waitlist": [], "closed": True}
# Get candidates sorted by fairness
# Retrieve office to check assignment mode
office = db.query(Office).filter(Office.id == office_id).first()
mode = office.assignment_mode if office and getattr(office, 'assignment_mode', None) else "fairness"
# Get candidates sorted by fairness (guaranteed first, then by ratio)
candidates = get_users_wanting_parking(office_id, pool_date, db)
if mode == "random":
import random
guaranteed = [c for c in candidates if c["has_guarantee"]]
non_guaranteed = [c for c in candidates if not c["has_guarantee"]]
# Shuffle non-guaranteed users to pick randomly
random.shuffle(non_guaranteed)
candidates = guaranteed + non_guaranteed
# Get available spots (OfficeSpots not yet in assignments table)
free_spots = get_available_spots(office_id, pool_date, db)
@@ -252,6 +267,20 @@ def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
waitlist.append(candidate["user_id"])
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}
@@ -266,6 +295,10 @@ def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session
if not assignment:
return False
# Get old user name for notification
old_user = db.query(User).filter(User.id == user_id).first()
old_user_name = old_user.name if old_user else "un collega"
# Capture spot ID before deletion
spot_id = assignment.spot_id
@@ -290,6 +323,13 @@ def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session
db.add(new_assignment)
db.commit()
# Notify the lucky user
from services.notifications import notify_parking_released_to_user
top_user = db.query(User).filter(User.id == top_candidate["user_id"]).first()
if top_user:
spot_name = get_spot_display_name(spot_id, office_id, db)
notify_parking_released_to_user(top_user, pool_date, spot_name, old_user_name)
return True
@@ -313,26 +353,6 @@ def handle_presence_change(user_id: str, change_date: date, old_status: Presence
# User no longer coming - release their spot (will auto-reassign)
release_user_spot(office.id, user_id, change_date, db)
elif new_status == PresenceStatus.PRESENT:
# Check booking window
should_assign = True
if office.booking_window_enabled:
# Allocation time is Day-1 at cutoff hour
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
cutoff_dt = cutoff_dt.replace(
hour=office.booking_window_end_hour,
minute=office.booking_window_end_minute
)
# If now is before cutoff, do not assign yet (wait for batch job)
if datetime.utcnow() < cutoff_dt:
should_assign = False
config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})")
if should_assign:
# User coming in - run fair assignment for this date
assign_parking_fairly(office.id, change_date, db)
def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Session) -> int:
"""
@@ -362,3 +382,52 @@ def run_batch_allocation(office_id: str, pool_date: date, db: Session) -> dict:
# 2. Run fair allocation
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:
# Non eseguiamo l'assegnazione se oggi è un giorno di chiusura
# (è già stata fatta l'assegnazione per i giorni futuri nell'ultimo giorno lavorativo)
if is_closing_day(office.id, now.date(), db):
config.logger.info(f"[SCHEDULER] Skipping batch allocation for {office.name} because today ({now.date()}) is a closing day.")
continue
# Troviamo il prossimo giorno lavorativo a partire da "domani"
target_date = now.date() + timedelta(days=1)
days_ahead = 1
while is_closing_day(office.id, target_date, db) and days_ahead <= 30:
target_date += timedelta(days=1)
days_ahead += 1
if days_ahead > 30:
config.logger.warning(f"[SCHEDULER] Could not find a working day within 30 days for {office.name}.")
continue
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}")

View File

@@ -25,7 +25,6 @@ def is_admin_from_groups(groups: list[str]) -> bool:
if not is_admin:
is_admin = admin_group.lower() in [g.lower() for g in groups]
print(f"[Authelia] Admin Check: User Groups={groups}, Configured Admin Group='{admin_group}' -> Is Admin? {is_admin}")
return is_admin
@@ -100,7 +99,7 @@ def get_current_user(
remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "")
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:
raise HTTPException(