Compare commits

..

6 Commits

Author SHA1 Message Date
a7ef46640d added cache system 2026-02-12 22:44:44 +01:00
991569d9eb Update compose.yml 2026-02-12 19:37:37 +00:00
8f5c1e1f94 fix scheduler orario start, immagine parcheggio, added more logs function 2026-02-12 19:57:00 +01:00
a94ec11c80 ad TIMEZONE and more 2026-02-09 12:31:06 +01:00
efa7533179 fix mail sand 2 2026-02-08 19:18:58 +01:00
e0b18fd3c3 fixing mail 2026-02-08 18:08:19 +01:00
23 changed files with 569 additions and 397 deletions

View File

@@ -13,6 +13,8 @@ SECRET_KEY=766299d3235f79a2a9a35aafbc90bec7102f250dfe4aba83500b98e568289b7a
# Usa 0.0.0.0 per permettere connessioni dall'esterno del container (essenziale per Docker/Traefik) # Usa 0.0.0.0 per permettere connessioni dall'esterno del container (essenziale per Docker/Traefik)
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8000 PORT=8000
# Timezone per l'applicazione (cronjobs, notifiche, ecc.)
TIMEZONE=Europe/Rome
# Database (SQLite path) # Database (SQLite path)
# Percorso assoluto nel container # Percorso assoluto nel container
@@ -25,6 +27,7 @@ DATABASE_PATH=/app/data/parking.db
# JWT token expiration (minutes, default 24 hours) # JWT token expiration (minutes, default 24 hours)
ACCESS_TOKEN_EXPIRE_MINUTES=1440 ACCESS_TOKEN_EXPIRE_MINUTES=1440
COOKIE_SECURE=true
# Logging level (DEBUG, INFO, WARNING, ERROR) # Logging level (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO LOG_LEVEL=INFO
@@ -64,14 +67,14 @@ AUTHELIA_LOGOUT_URL=https://auth.rocketscale.it/logout
# Email Notifications # Email Notifications
# ============================================================================= # =============================================================================
# Set to true to enable email sending # Set to true to enable email sending
SMTP_ENABLED=false SMTP_ENABLED=true
# SMTP server configuration # SMTP server configuration
SMTP_HOST=localhost SMTP_HOST="smtp.email.eu-milan-1.oci.oraclecloud.com"
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER= SMTP_USER="ocid1.user.oc1..aaaaaaaa6bollovnlx4vxoq2eh7pzgxxhludqitgxsp6fevpllmqynug2uiq@ocid1.tenancy.oc1..aaaaaaaa6veuezxddkzbxmxnjp5thywdjz42z5qfrd6mmosmqehvebrewola.hj.com"
SMTP_PASSWORD= SMTP_PASSWORD="3)J2E9_Np:}#kozD2Wed"
SMTP_FROM=noreply@parking.local SMTP_FROM="noreply@rocketscale.it"
SMTP_USE_TLS=true SMTP_USE_TLS=true
# When SMTP is disabled, emails are logged to this file # When SMTP is disabled, emails are logged to this file

214
README.md
View File

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

View File

@@ -46,10 +46,12 @@ if SECRET_KEY == "change-me-in-production":
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
# Server # Server
HOST = os.getenv("HOST", "0.0.0.0") HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000")) PORT = int(os.getenv("PORT", "8000"))
TIMEZONE = os.getenv("TIMEZONE", "UTC")
# CORS # CORS
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",") ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",")

View File

@@ -104,7 +104,8 @@ def login(request: Request, data: LoginRequest, response: Response, db: Session
value=token, value=token,
httponly=True, httponly=True,
max_age=config.ACCESS_TOKEN_EXPIRE_MINUTES * 60, max_age=config.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
samesite="lax" samesite="lax",
secure=config.COOKIE_SECURE
) )
config.logger.info(f"User logged in: {data.email}") config.logger.info(f"User logged in: {data.email}")
@@ -114,7 +115,12 @@ def login(request: Request, data: LoginRequest, response: Response, db: Session
@router.post("/logout") @router.post("/logout")
def logout(response: Response): def logout(response: Response):
"""Logout and clear session""" """Logout and clear session"""
response.delete_cookie("session_token") response.delete_cookie(
key="session_token",
httponly=True,
samesite="lax",
secure=config.COOKIE_SECURE
)
return {"message": "Logged out"} return {"message": "Logged out"}

View File

@@ -26,6 +26,9 @@ router = APIRouter(prefix="/api/offices", tags=["offices"])
class ValidOfficeCreate(BaseModel): class ValidOfficeCreate(BaseModel):
name: str name: str
parking_quota: int = 0 parking_quota: int = 0
booking_window_enabled: bool = True
booking_window_end_hour: int = 18
booking_window_end_minute: int = 0
class ClosingDayCreate(BaseModel): class ClosingDayCreate(BaseModel):
@@ -121,6 +124,9 @@ def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=D
name=data.name, name=data.name,
parking_quota=data.parking_quota, parking_quota=data.parking_quota,
spot_prefix=get_next_available_prefix(db), spot_prefix=get_next_available_prefix(db),
booking_window_enabled=data.booking_window_enabled,
booking_window_end_hour=data.booking_window_end_hour,
booking_window_end_minute=data.booking_window_end_minute,
created_at=datetime.utcnow() created_at=datetime.utcnow()
) )
db.add(office) db.add(office)
@@ -180,12 +186,12 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi
if data.booking_window_end_hour is not None: if data.booking_window_end_hour is not None:
if not (0 <= data.booking_window_end_hour <= 23): 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 office.booking_window_end_hour = data.booking_window_end_hour
if data.booking_window_end_minute is not None: if data.booking_window_end_minute is not None:
if not (0 <= data.booking_window_end_minute <= 59): 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 office.booking_window_end_minute = data.booking_window_end_minute
office.updated_at = datetime.utcnow() office.updated_at = datetime.utcnow()

View File

@@ -443,26 +443,34 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
return result return result
from typing import Optional, Union
class TestEmailRequest(BaseModel): class TestEmailRequest(BaseModel):
date: date = None date: Optional[str] = None
office_id: str office_id: str
bulk_send: bool = False # New flag
@router.post("/test-email") @router.post("/test-email")
def send_test_email_tool(data: TestEmailRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): 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)""" """Send a test email to the current user OR bulk reminder to all (Test Tool)"""
from services.notifications import send_email from services.notifications import send_email, send_daily_parking_reminder
from database.models import OfficeClosingDay, OfficeWeeklyClosingDay from database.models import OfficeClosingDay, OfficeWeeklyClosingDay, User
from datetime import timedelta from datetime import timedelta, datetime
# Verify office access # Verify office access
if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id: 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") 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: if not target_date:
# Find next open day # Find next open day logic (same as before)
# Start from tomorrow (or today? Prompt says "dopo il giorno corrente" -> after today)
check_date = date.today() + timedelta(days=1) check_date = date.today() + timedelta(days=1)
# Load closing rules # Load closing rules
@@ -476,15 +484,11 @@ def send_test_email_tool(data: TestEmailRequest, db: Session = Depends(get_db),
OfficeClosingDay.date >= check_date OfficeClosingDay.date >= check_date
).all() ).all()
# Max lookahead 30 days to avoid infinite loop
found = False found = False
for _ in range(30): for _ in range(30):
# Check weekly
if check_date.weekday() in weekly_closed_set: if check_date.weekday() in weekly_closed_set:
check_date += timedelta(days=1) check_date += timedelta(days=1)
continue continue
# Check specific
is_specific = False is_specific = False
for d in specific_closed: for d in specific_closed:
s = d.date s = d.date
@@ -492,31 +496,89 @@ def send_test_email_tool(data: TestEmailRequest, db: Session = Depends(get_db),
if s <= check_date <= e: if s <= check_date <= e:
is_specific = True is_specific = True
break break
if is_specific: if is_specific:
check_date += timedelta(days=1) check_date += timedelta(days=1)
continue continue
found = True found = True
break break
if found: target_date = check_date if found else date.today() + timedelta(days=1)
target_date = check_date
else: # BULK MODE
# Fallback if data.bulk_send:
target_date = date.today() + timedelta(days=1) # Get all assignments for this date/office
assignments = db.query(DailyParkingAssignment).filter(
# Send Email DailyParkingAssignment.office_id == data.office_id,
subject = f"Test Email - Parking System ({target_date})" 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""" body_html = f"""
<html> <html>
<body> <body>
<h2>Parking System Test Email</h2> <h2>Email di Test - Sistema Parcheggi</h2>
<p>Hi {current_user.name},</p> <p>Ciao {current_user.name},</p>
<p>This is a test email triggered from the Parking Manager Test Tools.</p> <p>Questa è una email di test inviata dagli strumenti di amministrazione.</p>
<p><strong>Selected Date:</strong> {target_date}</p> <p><strong>Data Selezionata:</strong> {formatted_date}</p>
<p><strong>SMTP Status:</strong> {'Enabled' if config.SMTP_ENABLED else 'Disabled (File Logging)'}</p> <p><strong>Stato SMTP:</strong> {'Abilitato' if config.SMTP_ENABLED else 'Disabilitato (File Logging)'}</p>
<p>If you received this, the notification system is working.</p> <p>Se hai ricevuto questa email, il sistema di notifiche funziona correttamente.</p>
</body> </body>
</html> </html>
""" """

View File

@@ -26,10 +26,10 @@ services:
networks: networks:
- org-network - org-network
labels: labels:
- "caddy=parking.lvh.me" - "caddy=parcheggio.rocketscale.it"
- "caddy.reverse_proxy={{upstreams 8000}}" - "caddy.reverse_proxy={{upstreams 8000}}"
- "caddy.forward_auth=authelia:9091" - "caddy.forward_auth=authelia:9091"
- "caddy.forward_auth.uri=/api/verify?rd=https://parking.lvh.me/" - "caddy.forward_auth.uri=/api/verify?rd=https://parcheggio.rocketscale.it/"
- "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email" - "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email"
# cambiare l'url delle label per il reverse proxy # cambiare l'url delle label per il reverse proxy

View File

@@ -241,15 +241,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): 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

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

View File

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

View File

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

View File

@@ -72,6 +72,15 @@ function populateHourSelect() {
option.textContent = h.toString().padStart(2, '0'); option.textContent = h.toString().padStart(2, '0');
select.appendChild(option); select.appendChild(option);
} }
const minuteSelect = document.getElementById('bookingWindowMinute');
minuteSelect.innerHTML = '';
for (let m = 0; m < 60; m++) {
const option = document.createElement('option');
option.value = m;
option.textContent = m.toString().padStart(2, '0');
minuteSelect.appendChild(option);
}
} }
async function loadOfficeSettings(id) { async function loadOfficeSettings(id) {
@@ -288,11 +297,7 @@ function setupEventListeners() {
office_id: currentOffice.id office_id: currentOffice.id
}); });
if (res && res.status !== 403 && res.status !== 500 && res.ok !== false) { if (res && res.status >= 200 && res.status < 300) {
// 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(); const data = await res.json();
if (data.success) { if (data.success) {
@@ -306,7 +311,9 @@ function setupEventListeners() {
} }
} else { } else {
const err = res ? await res.json() : {}; const err = res ? await res.json() : {};
utils.showMessage('Errore: ' + (err.detail || 'Invio fallito'), 'error'); 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) { } catch (e) {
console.error(e); console.error(e);
@@ -314,4 +321,51 @@ function setupEventListeners() {
} }
}); });
} }
const bulkEmailBtn = document.getElementById('bulkEmailBtn');
if (bulkEmailBtn) {
bulkEmailBtn.addEventListener('click', async () => {
const dateVal = document.getElementById('testEmailDate').value;
// Validate office
if (!currentOffice || !currentOffice.id) {
return utils.showMessage('Errore: Nessun ufficio selezionato', 'error');
}
if (!dateVal) {
return utils.showMessage('Per il test a TUTTI è obbligatorio selezionare una data specifica.', 'error');
}
if (!confirm(`Sei sicuro di voler inviare una mail di promemoria a TUTTI gli utenti con parcheggio assegnato per il giorno ${dateVal}?\n\nQuesta azione invierà vere email.`)) return;
utils.showMessage('Invio mail massive in corso...', 'warning');
try {
const res = await api.post('/api/parking/test-email', {
date: dateVal,
office_id: currentOffice.id,
bulk_send: true
});
if (res && res.status >= 200 && res.status < 300) {
const data = await res.json();
if (data.success) {
let msg = `Processo completato per il ${data.date}. Inviate: ${data.count || 0}, Fallite: ${data.failed || 0}.`;
if (data.mode === 'BULK' && (data.count || 0) === 0) msg += " (Nessun assegnatario trovato)";
utils.showMessage(msg, 'success');
} else {
utils.showMessage('Errore durante l\'invio.', 'error');
}
} else {
const err = res ? await res.json() : {};
console.error("Bulk Test Email Error:", err);
const errMsg = err.detail ? (typeof err.detail === 'object' ? JSON.stringify(err.detail) : err.detail) : 'Invio fallito';
utils.showMessage('Errore: ' + errMsg, 'error');
}
} catch (e) {
console.error(e);
utils.showMessage('Errore di comunicazione col server: ' + e.message, 'error');
}
});
}
} }

View File

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

View File

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

View File

@@ -88,10 +88,7 @@
</select> </select>
<span>:</span> <span>:</span>
<select id="bookingWindowMinute" style="width: 80px;"> <select id="bookingWindowMinute" style="width: 80px;">
<option value="0">00</option> <!-- Populated by JS -->
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select> </select>
</div> </div>
<small class="text-muted">Le presenze inserite prima di questo orario saranno messe in <small class="text-muted">Le presenze inserite prima di questo orario saranno messe in
@@ -157,7 +154,11 @@
</small> </small>
</div> </div>
<button id="testEmailBtn" class="btn btn-secondary"> <button id="testEmailBtn" class="btn btn-secondary">
Test Invio Mail Test (Solo a Me)
</button>
<button id="bulkEmailBtn" class="btn btn-warning"
title="Invia mail reale a tutti gli assegnatari">
Test (A Tutti)
</button> </button>
</div> </div>
</div> </div>

View File

@@ -130,7 +130,7 @@
}); });
async function loadProfile() { async function loadProfile() {
const response = await api.get('/api/users/me/profile'); const response = await api.getCached('/api/users/me/profile', 60);
if (response && response.ok) { if (response && response.ok) {
const profile = await response.json(); const profile = await response.json();
isLdapUser = profile.is_ldap_user; isLdapUser = profile.is_ldap_user;
@@ -169,6 +169,8 @@
const response = await api.put('/api/users/me/profile', data); const response = await api.put('/api/users/me/profile', data);
if (response && response.ok) { if (response && response.ok) {
utils.showMessage('Profilo aggiornato con successo', 'success'); utils.showMessage('Profilo aggiornato con successo', 'success');
api.invalidateCache('/api/users/me/profile');
api.invalidateCache('/api/auth/me'); // Update nav bar name too
// Update nav display // Update nav display
const nameEl = document.getElementById('userName'); const nameEl = document.getElementById('userName');
if (nameEl) nameEl.textContent = data.name; if (nameEl) nameEl.textContent = data.name;

24
main.py
View File

@@ -21,12 +21,31 @@ from app.routes.users import router as users_router
from app.routes.offices import router as offices_router from app.routes.offices import router as offices_router
from app.routes.presence import router as presence_router from app.routes.presence import router as presence_router
from app.routes.parking import router as parking_router from app.routes.parking import router as parking_router
from database.connection import init_db from app.routes.parking import router as parking_router
from database.connection import init_db, get_db_session
from services.notifications import run_scheduled_notifications
from services.parking import process_daily_allocations
import asyncio
# Rate limiter setup # Rate limiter setup
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=get_remote_address)
async def scheduler_task():
"""Background task to run scheduled notifications every minute"""
config.logger.info("Scheduler task started")
while True:
try:
with get_db_session() as db:
run_scheduled_notifications(db)
process_daily_allocations(db)
except Exception as e:
config.logger.error(f"Scheduler error: {e}")
# Check every 60 seconds
await asyncio.sleep(60)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Initialize database on startup""" """Initialize database on startup"""
@@ -72,6 +91,9 @@ async def lifespan(app: FastAPI):
log(f"feedback: App reachable via Caddy at {reachable_url}") log(f"feedback: App reachable via Caddy at {reachable_url}")
# Start scheduler
asyncio.create_task(scheduler_task())
yield yield
log("Shutting down Parking Manager application") log("Shutting down Parking Manager application")

View File

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

View File

@@ -19,6 +19,7 @@ from database.models import (
) )
from utils.helpers import generate_uuid from utils.helpers import generate_uuid
from app import config from app import config
from services.notifications import notify_parking_assigned
def get_spot_prefix(office: Office, db: Session) -> str: def get_spot_prefix(office: Office, db: Session) -> str:
@@ -252,6 +253,20 @@ def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
waitlist.append(candidate["user_id"]) waitlist.append(candidate["user_id"])
db.commit() db.commit()
# Send notifications to successful assignees
for user_id in assigned:
user = db.query(User).filter(User.id == user_id).first()
if user:
# Re-fetch the assignment to get the spot details
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.date == pool_date
).first()
if assignment:
spot_name = get_spot_display_name(assignment.spot_id, office_id, db)
notify_parking_assigned(user, pool_date, spot_name)
return {"assigned": assigned, "waitlist": waitlist} return {"assigned": assigned, "waitlist": waitlist}
@@ -317,15 +332,20 @@ def handle_presence_change(user_id: str, change_date: date, old_status: Presence
# Check booking window # Check booking window
should_assign = True should_assign = True
if office.booking_window_enabled: if office.booking_window_enabled:
from zoneinfo import ZoneInfo
tz = ZoneInfo(config.TIMEZONE)
now = datetime.now(tz)
# Allocation time is Day-1 at cutoff hour # Allocation time is Day-1 at cutoff hour
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time()) cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
cutoff_dt = cutoff_dt.replace( cutoff_dt = cutoff_dt.replace(
hour=office.booking_window_end_hour, hour=office.booking_window_end_hour,
minute=office.booking_window_end_minute minute=office.booking_window_end_minute,
tzinfo=tz
) )
# If now is before cutoff, do not assign yet (wait for batch job) # If now is before cutoff, do not assign yet (wait for batch job)
if datetime.utcnow() < cutoff_dt: if now < cutoff_dt:
should_assign = False should_assign = False
config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})") config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})")
@@ -362,3 +382,35 @@ def run_batch_allocation(office_id: str, pool_date: date, db: Session) -> dict:
# 2. Run fair allocation # 2. Run fair allocation
return assign_parking_fairly(office_id, pool_date, db) return assign_parking_fairly(office_id, pool_date, db)
def process_daily_allocations(db: Session):
"""
Check if any office's booking window has just closed and run batch allocation.
Run by scheduler every minute.
HALT: Checks if the cutoff time for TOMORROW has been reached.
"""
from zoneinfo import ZoneInfo
# Use configured timezone
tz = ZoneInfo(config.TIMEZONE)
now = datetime.now(tz)
# Check all offices with window enabled
offices = db.query(Office).filter(Office.booking_window_enabled == True).all()
config.logger.debug(f"[SCHEDULER] Checking booking windows for {len(offices)} offices - Current Time: {now.strftime('%H:%M')}")
for office in offices:
# Cutoff is defined as "Previous Day" (today) at Booking End Hour
# If NOW matches the cutoff time, we run allocation for TOMORROW
if now.hour == office.booking_window_end_hour and now.minute == office.booking_window_end_minute:
target_date = now.date() + timedelta(days=1)
config.logger.info(f"[SCHEDULER] CUTOFF REACHED for {office.name} (Cutoff: {office.booking_window_end_hour}:{office.booking_window_end_minute}). Starting Assegnazione Giornaliera parcheggi for {target_date}")
try:
run_batch_allocation(office.id, target_date, db)
config.logger.info(f"[SCHEDULER] Assegnazione Giornaliera parcheggi completed for {office.name} on {target_date}")
except Exception as e:
config.logger.error(f"[SCHEDULER] Failed Assegnazione Giornaliera parcheggi for {office.name}: {e}")

48
upgrade_db_v1.py Normal file
View File

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

View File

@@ -25,7 +25,6 @@ def is_admin_from_groups(groups: list[str]) -> bool:
if not is_admin: if not is_admin:
is_admin = admin_group.lower() in [g.lower() for g in groups] 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 return is_admin
@@ -100,7 +99,7 @@ def get_current_user(
remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "") remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "")
remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "") remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "")
print(f"[Authelia] Headers: user={remote_user}, email={remote_email}, name={remote_name}, groups={remote_groups}") remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "")
if not remote_user: if not remote_user:
raise HTTPException( raise HTTPException(