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