Primo commit

This commit is contained in:
2026-01-13 11:20:12 +01:00
parent ce9e2fdf2a
commit 17453f5d13
51 changed files with 3883 additions and 2508 deletions

20
.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv/
pip-log.txt
pip-delete-this-directory.txt
.env
.env.example
.git/
.gitignore
data/
.vscode/
.idea/
coverage.xml
*.coverage
.pytest_cache/

253
CLAUDE.md
View File

@@ -1,253 +0,0 @@
# CLAUDE.md - Project Intelligence
## Project Overview
**Org-Parking** is a manager-centric parking spot management system for organizations. It features fair parking assignment based on presence/parking ratio, supporting both standalone JWT authentication and Authelia/LLDAP SSO integration.
### Technology Stack
- **Backend:** FastAPI + SQLAlchemy + SQLite
- **Frontend:** Vanilla JavaScript (no frameworks)
- **Auth:** JWT tokens + Authelia SSO support
- **Containerization:** Docker + Docker Compose
- **Rate Limiting:** slowapi
### Architecture
```
app/
├── config.py → Configuration with logging and validation
└── routes/ → API endpoints (auth, users, managers, presence, parking)
services/ → Business logic (parking algorithm, auth, notifications)
database/ → SQLAlchemy models and connection
frontend/ → Static HTML pages + JS modules
utils/
├── auth_middleware.py → JWT/Authelia authentication
└── helpers.py → Shared utility functions
```
## Build & Run Commands
```bash
# Development
SECRET_KEY=dev-secret-key python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
# Docker
docker compose up -d
# Dependencies
pip install -r requirements.txt
# Initialize test database
python create_test_db.py
```
## Code Style & Conventions
### Python
- FastAPI async patterns with `Depends()` for dependency injection
- Pydantic models for request/response validation
- SQLAlchemy ORM (no raw SQL)
- Use `generate_uuid()` from `utils.helpers` for UUIDs
- Use `config.logger` for logging (not print statements)
- Dates stored as TEXT in "YYYY-MM-DD" format
### JavaScript
- ES6 modules with centralized API client (`/js/api.js`)
- Token stored in localStorage, auto-included in requests
- Utility functions in `/js/utils.js`
- Role-based navigation in `/js/nav.js`
### Authentication
- Dual mode: JWT tokens (standalone) or Authelia headers (SSO)
- LDAP users have `password_hash = None`
- Use helper: `is_ldap_user(user)` from `utils.helpers`
### Utility Functions (`utils/helpers.py`)
```python
from utils.helpers import (
generate_uuid, # Use instead of str(uuid.uuid4())
is_ldap_user, # Check if user is LDAP-managed
is_ldap_admin, # Check if user is LDAP admin
validate_password, # Returns list of validation errors
format_password_errors, # Format errors into user message
get_notification_default # Get setting value with default
)
```
---
## Configuration (`app/config.py`)
Configuration is environment-based with required validation:
### Required
- `SECRET_KEY` - **MUST** be set (app exits if missing)
### Security
- `RATE_LIMIT_REQUESTS` - Requests per window (default: 5)
- `RATE_LIMIT_WINDOW` - Window in seconds (default: 60)
### Email (org-stack pattern)
- `SMTP_ENABLED` - Set to `true` to enable SMTP sending
- When disabled, emails are logged to `EMAIL_LOG_FILE`
- Follows org-stack pattern: direct send with file fallback
### Logging
- `LOG_LEVEL` - DEBUG, INFO, WARNING, ERROR (default: INFO)
- Use `config.logger` for all logging
---
## Notifications (`services/notifications.py`)
Simplified notification service following org-stack pattern:
```python
from services.notifications import (
send_email, # Direct send or file fallback
notify_parking_assigned, # When spot assigned
notify_parking_released, # When spot released
notify_parking_reassigned, # When spot reassigned
send_presence_reminder, # Weekly presence reminder
send_weekly_parking_summary, # Friday parking summary
send_daily_parking_reminder, # Daily parking reminder
run_scheduled_notifications # Called by cron/scheduler
)
```
### Email Behavior
1. If `SMTP_ENABLED=true`: Send via SMTP
2. If SMTP fails or disabled: Log to `EMAIL_LOG_FILE`
3. Never throws - always returns success/failure
---
## Recent Improvements
### Security Enhancements
- **Required SECRET_KEY**: App exits if not set
- **Rate limiting**: Login/register endpoints limited to 5 req/min
- **Password validation**: Requires uppercase, lowercase, number, 8+ chars
- **Proper logging**: All security events logged
### Performance Optimizations
- **Fixed N+1 queries** in:
- `list_users()` - Batch query for manager names and counts
- `list_managers()` - Batch query for managed user counts
- `get_manager_guarantees()` - Batch query for user names
- `get_manager_exclusions()` - Batch query for user names
### Code Consolidation
- **Utility functions** (`utils/helpers.py`):
- `generate_uuid()` - Replaces 50+ `str(uuid.uuid4())` calls
- `is_ldap_user()` - Replaces 4+ duplicated checks
- `validate_password()` - Consistent password validation
- **Simplified notifications** - Removed queue system, direct send
### Logging Improvements
- Centralized logging via `config.logger`
- Replaced `print()` with proper logging
- Security events logged (login, password change, etc.)
---
## API Quick Reference
### Authentication
- `POST /api/auth/register` - Create user (rate limited)
- `POST /api/auth/login` - Get JWT token (rate limited)
- `GET /api/auth/me` - Current user (JWT or Authelia)
### Presence
- `POST /api/presence/mark` - Mark single day
- `POST /api/presence/mark-bulk` - Mark multiple days
- `GET /api/presence/team` - Team calendar with parking
### Parking
- `POST /api/parking/manual-assign` - Manager assigns spot
- `POST /api/parking/reassign-spot` - Reassign existing spot
- `GET /api/parking/eligible-users/{id}` - Users for reassignment
### Manager Settings
- `GET/POST/DELETE /api/managers/closing-days`
- `GET/POST/DELETE /api/managers/weekly-closing-days`
- `GET/POST/DELETE /api/managers/guarantees`
- `GET/POST/DELETE /api/managers/exclusions`
---
## Development Notes
### Adding a New Route
1. Create file in `app/routes/`
2. Use `APIRouter(prefix="/api/...", tags=["..."])`
3. Register in `main.py`: `app.include_router(...)`
4. Add auth dependency: `current_user: User = Depends(get_current_user)`
5. Use `config.logger` for logging
6. Use `generate_uuid()` for new records
### Database Changes
No migration system (Alembic) configured. Schema changes require:
1. Update [database/models.py](database/models.py)
2. Delete SQLite file or write manual migration
3. Run `create_test_db.py` for fresh database
### Email Testing
With `SMTP_ENABLED=false`, check email log:
```bash
tail -f /tmp/parking-emails.log
```
### Running Scheduled Notifications
Add to cron or systemd timer:
```bash
# Every 5 minutes
*/5 * * * * cd /path/to/org-parking && python -c "
from database.connection import get_db
from services.notifications import run_scheduled_notifications
db = next(get_db())
run_scheduled_notifications(db)
"
```
---
## File Quick Links
| Purpose | File |
|---------|------|
| Main entry | [main.py](main.py) |
| Configuration | [app/config.py](app/config.py) |
| Database models | [database/models.py](database/models.py) |
| Parking algorithm | [services/parking.py](services/parking.py) |
| Notifications | [services/notifications.py](services/notifications.py) |
| Auth middleware | [utils/auth_middleware.py](utils/auth_middleware.py) |
| Utility helpers | [utils/helpers.py](utils/helpers.py) |
| Frontend API client | [frontend/js/api.js](frontend/js/api.js) |
| CSS styles | [frontend/css/styles.css](frontend/css/styles.css) |
| Docker config | [compose.yml](compose.yml) |
| Environment template | [.env.example](.env.example) |
---
## Deployment Notes
### Remote Server
- Host: `rocketscale.it`
- User: `rocky`
- SSH: `ssh rocky@rocketscale.it`
- Project path: `/home/rocky/org-parking`
- Related project: `/home/rocky/org-stack` (LLDAP, Authelia, etc.)
### Environment Variables
Copy `.env.example` to `.env` and configure:
```bash
# Generate secure key
openssl rand -hex 32
```
### Production Checklist
- [ ] Set strong `SECRET_KEY`
- [ ] Configure `ALLOWED_ORIGINS` (not `*`)
- [ ] Set `AUTHELIA_ENABLED=true` if using SSO
- [ ] Configure SMTP or check email log file
- [ ] Set up notification scheduler (cron/systemd)

13
Caddyfile.snippet Normal file
View File

@@ -0,0 +1,13 @@
parking.lvh.me {
# Integrazione Authelia per autenticazione
forward_auth authelia:9091 {
uri /api/verify?rd=https://parking.lvh.me/
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
# Proxy inverso verso il container parking sulla porta 8000
reverse_proxy parking:8000
# Usa certificati gestiti internamente per lvh.me (locale)
tls internal
}

27
DEPENDENCIES.md Normal file
View File

@@ -0,0 +1,27 @@
# Spiegazione delle Dipendenze del Progetto
Questo documento spiega il motivo per cui ogni libreria elencata nel file `requirements.txt` è necessaria per il funzionamento dell'applicazione.
## Librerie Principali
### 1. Framework e Server Web
* **`fastapi`**: È il cuore dell'applicazione. Si tratta di un framework web moderno e veloce per costruire API con Python. Gestisce le richieste HTTP, il routing (gli URL) e la validazione dei dati.
* **`uvicorn[standard]`**: È il server ASGI necessario per eseguire l'applicazione FastAPI. FastAPI da solo non può girare; ha bisogno di un server come Uvicorn per accettare le connessioni dai client (browser, altre app). La versione `[standard]` include dipendenze extra per migliorare le prestazioni (come `uvloop`) e gestire meglio i protocolli web.
### 2. Database e Dati
* **`sqlalchemy`**: È l'ORM (Object-Relational Mapper) utilizzato per interagire con il database. Permette di scrivere codice Python per creare, leggere, aggiornare ed eliminare dati nel database invece di scrivere SQL puro.
* **`pydantic[email]`**: Usato per la validazione dei dati. Assicura che i dati inviati all'API (come i dati di registrazione utente) siano nel formato corretto. L'extra `[email]` include la libreria `email-validator` per verificare che gli indirizzi email siano validi.
* **`pydantic-settings`**: Un'estensione di Pydantic specifica per la gestione delle configurazioni. Permette di leggere le impostazioni da variabili d'ambiente in modo sicuro e tipizzato.
### 3. Autenticazione e Sicurezza
* **`python-jose[cryptography]`**: Serve per gestire i JSON Web Tokens (JWT). È fondamentale per il sistema di login, permettendo di creare token sicuri per identificare gli utenti autenticati.
* **`bcrypt`**: Una libreria per l'hashing delle password. È essenziale per la sicurezza, poiché permette di salvare le password nel database in modo criptato e non in chiaro.
* **`python-multipart`**: Necessario per supportare l'upload di form data. FastAPI lo richiede specificamente per gestire l'autenticazione OAuth2 (il login tramite form username/password).
### 4. Utilità e Configurazioni
* **`python-dotenv`**: Permette all'applicazione di leggere le variabili d'configurazione da un file `.env`. È molto utile durante lo sviluppo per gestire segreti e impostazioni locali.
* **`slowapi`**: Una libreria per implementare il "Rate Limiting". Serve a proteggere l'API da un numero eccessivo di richieste in breve tempo (ad esempio, per prevenire attacchi di forza bruta o abusi).
### 5. Dipendenze Indirette o Specifiche
* **`email-validator`**: Richiesto da Pydantic per la validazione delle email.
* **`idna`**: Supporto per nomi di dominio internazionalizzati (spesso una dipendenza di `email-validator` o librerie di rete).

View File

@@ -2,6 +2,9 @@ FROM python:3.12-slim
WORKDIR /app
# Prevent Python from buffering stdout and stderr
ENV PYTHONUNBUFFERED=1
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
@@ -20,4 +23,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# Run with uvicorn
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

332
README.md
View File

@@ -1,172 +1,214 @@
# Parking Manager
# Org-Parking
A manager-centric parking spot management application with fair assignment algorithm.
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.
## Features
## Funzionalità
- **Manager-centric model**: Managers own parking spots, not offices
- **Fair assignment algorithm**: Users with lowest parking/presence ratio get priority
- **Presence tracking**: Calendar-based presence marking (present/remote/absent)
- **Closing days**: Support for specific dates and weekly recurring closures
- **Guarantees & exclusions**: Per-user parking rules
- **Authelia/LLDAP integration**: SSO authentication with group-based roles
- **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.
## Architecture
## Architettura
```
├── app/
├── routes/ # API endpoints
│ ├── auth.py # Authentication + holidays
│ ├── users.py # User management
│ ├── managers.py # Manager rules (closing days, guarantees)
│ ├── presence.py # Presence marking
│ └── parking.py # Parking assignments
└── config.py # Application configuration
├── database/
├── models.py # SQLAlchemy ORM models
└── connection.py # Database setup
├── services/
│ ├── auth.py # JWT + password handling
│ ├── parking.py # Fair assignment algorithm
│ ├── holidays.py # Public holiday calculation
│ └── notifications.py # Email notifications (TODO: scheduler)
├── frontend/
│ ├── pages/ # HTML pages
│ ├── js/ # JavaScript modules
│ └── css/ # Stylesheets
└── main.py # FastAPI application entry
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
```
## Quick Start (Development)
## Guida Rapida
```bash
# Create virtual environment
python3 -m venv .venv
source .venv/bin/activate
### Sviluppo Locale
# Install dependencies
pip install -r requirements.txt
1. **Setup Ambiente**:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
# Run development server
python main.py
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à:
```
Access at http://localhost:8000
## Docker Deployment
```bash
# Build image
docker build -t parking-manager .
# Run with environment variables
docker run -d \
-p 8000:8000 \
-v ./data:/app/data \
-e SECRET_KEY=your-secret-key \
-e AUTHELIA_ENABLED=true \
parking-manager
Rapporto = (Giorni Parcheggiati con successo) / (Giorni di Presenza in ufficio)
```
Or use Docker Compose:
```bash
docker compose up -d
```
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `SECRET_KEY` | JWT signing key | Random (dev only) |
| `HOST` | Bind address | `0.0.0.0` |
| `PORT` | Server port | `8000` |
| `DATABASE_URL` | SQLite path | `sqlite:///data/parking.db` |
| `AUTHELIA_ENABLED` | Enable Authelia SSO | `false` |
| `ALLOWED_ORIGINS` | CORS origins | `*` |
### SMTP (Notifications - Optional)
| Variable | Description |
|----------|-------------|
| `SMTP_HOST` | SMTP server hostname |
| `SMTP_PORT` | SMTP port (default: 587) |
| `SMTP_USER` | SMTP username |
| `SMTP_PASSWORD` | SMTP password |
| `SMTP_FROM` | From email address |
## Authentication
### Standalone Mode
Built-in JWT authentication with bcrypt password hashing. Users register/login via `/login` and `/register`.
### Authelia Mode
When `AUTHELIA_ENABLED=true`, the app trusts Authelia headers:
- `Remote-User`: User email/username
- `Remote-Name`: Display name
- `Remote-Groups`: Comma-separated group list
Group mapping (follows lldap naming convention):
- `parking_admins` → admin role
- `managers` → manager role
- Others → employee role
## User Roles
| Role | Permissions |
|------|-------------|
| **admin** | Full access, manage users and managers |
| **manager** | Manage their team, set parking rules |
| **employee** | Mark own presence, view calendar |
- 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
### Authentication
- `POST /api/auth/login` - Login
- `POST /api/auth/register` - Register (standalone mode)
- `POST /api/auth/logout` - Logout
- `GET /api/auth/me` - Current user info
- `GET /api/auth/holidays/{year}` - Public holidays
Di seguito la lista delle chiamate API disponibili suddivise per modulo.
### Users
- `GET /api/users` - List users (admin)
- `POST /api/users` - Create user (admin)
- `PUT /api/users/{id}` - Update user (admin)
- `DELETE /api/users/{id}` - Delete user (admin)
- `GET /api/users/me/profile` - Own profile
- `PUT /api/users/me/settings` - Own settings
### Auth (`/api/auth`)
Gestione autenticazione e sessione.
### Managers
- `GET /api/managers` - List managers
- `GET /api/managers/{id}` - Manager details
- `PUT /api/managers/{id}/settings` - Update parking quota (admin)
- `GET/POST/DELETE /api/managers/{id}/closing-days` - Specific closures
- `GET/POST/DELETE /api/managers/{id}/weekly-closing-days` - Recurring closures
- `GET/POST/DELETE /api/managers/{id}/guarantees` - Parking guarantees
- `GET/POST/DELETE /api/managers/{id}/exclusions` - Parking exclusions
- `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.
### Presence
- `POST /api/presence/mark` - Mark presence
- `POST /api/presence/mark-bulk` - Bulk mark
- `GET /api/presence/my-presences` - Own presences
- `GET /api/presence/team` - Team calendar (manager/admin)
### Users (`/api/users`)
Gestione utenti e profili.
### Parking
- `GET /api/parking/assignments/{date}` - Day's assignments
- `GET /api/parking/my-assignments` - Own assignments
- `POST /api/parking/manual-assign` - Manual assignment
- `POST /api/parking/reassign-spot` - Reassign spot
- `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.
## Fairness Algorithm
### Offices (`/api/offices`)
Gestione uffici, regole di chiusura e quote.
Parking spots are assigned based on a fairness ratio:
- `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.
```
ratio = parking_days / presence_days
```
### Presence (`/api/presence`)
Gestione presenze giornaliere.
Users with the lowest ratio get priority. Guaranteed users are always assigned first.
- `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.
## License
### 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

@@ -7,6 +7,10 @@ import sys
import logging
from pathlib import Path
# Paths
BASE_DIR = Path(__file__).resolve().parent.parent
FRONTEND_DIR = BASE_DIR / "frontend"
# Configure logging
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
@@ -17,6 +21,19 @@ logger = logging.getLogger("org-parking")
# Database
DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db")
# Fix for local execution: if path is absolute (docker) but dir doesn't exist, fallback to local data/
if os.path.isabs(DATABASE_PATH) and not os.path.exists(os.path.dirname(DATABASE_PATH)):
# Check if we are aiming for /app/data but running locally
if str(DATABASE_PATH).startswith("/app/") or not os.access(os.path.dirname(DATABASE_PATH), os.W_OK):
logger.warning(f"Configured DATABASE_PATH '{DATABASE_PATH}' folder not found/writable. Switching to local 'data' directory.")
local_data_dir = BASE_DIR / "data"
local_data_dir.mkdir(exist_ok=True)
DATABASE_PATH = str(local_data_dir / os.path.basename(DATABASE_PATH))
logger.info(f"Using local database path: {DATABASE_PATH}")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}")
# JWT Authentication
@@ -35,7 +52,7 @@ HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000"))
# CORS
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",")
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",")
# Authelia Integration
AUTHELIA_ENABLED = os.getenv("AUTHELIA_ENABLED", "false").lower() == "true"
@@ -67,6 +84,4 @@ EMAIL_LOG_FILE = os.getenv("EMAIL_LOG_FILE", "/tmp/parking-emails.log")
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "5")) # requests per window
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds
# Paths
BASE_DIR = Path(__file__).resolve().parent.parent
FRONTEND_DIR = BASE_DIR / "frontend"

View File

@@ -9,6 +9,7 @@ from slowapi import Limiter
from slowapi.util import get_remote_address
from database.connection import get_db
from database.models import UserRole
from services.auth import (
create_user, authenticate_user, create_access_token,
get_user_by_email
@@ -25,7 +26,6 @@ class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: str
manager_id: str | None = None
class LoginRequest(BaseModel):
@@ -42,16 +42,16 @@ class UserResponse(BaseModel):
id: str
email: str
name: str | None
manager_id: str | None
role: str
manager_parking_quota: int | None = None
office_id: str | None
office_name: str | None = None
role: UserRole
week_start_day: int = 0
# Notification preferences
notify_weekly_parking: int = 1
notify_daily_parking: int = 1
notify_weekly_parking: bool = True
notify_daily_parking: bool = True
notify_daily_parking_hour: int = 8
notify_daily_parking_minute: int = 0
notify_parking_changes: int = 1
notify_parking_changes: bool = True
@router.post("/register", response_model=TokenResponse)
@@ -76,8 +76,7 @@ def register(request: Request, data: RegisterRequest, db: Session = Depends(get_
db=db,
email=data.email,
password=data.password,
name=data.name,
manager_id=data.manager_id
name=data.name
)
config.logger.info(f"New user registered: {data.email}")
@@ -126,15 +125,15 @@ def get_me(user=Depends(get_current_user)):
id=user.id,
email=user.email,
name=user.name,
manager_id=user.manager_id,
office_id=user.office_id,
office_name=user.office.name if user.office else None,
role=user.role,
manager_parking_quota=user.manager_parking_quota,
week_start_day=get_notification_default(user.week_start_day, 0),
notify_weekly_parking=get_notification_default(user.notify_weekly_parking, 1),
notify_daily_parking=get_notification_default(user.notify_daily_parking, 1),
notify_weekly_parking=get_notification_default(user.notify_weekly_parking, True),
notify_daily_parking=get_notification_default(user.notify_daily_parking, True),
notify_daily_parking_hour=get_notification_default(user.notify_daily_parking_hour, 8),
notify_daily_parking_minute=get_notification_default(user.notify_daily_parking_minute, 0),
notify_parking_changes=get_notification_default(user.notify_parking_changes, 1)
notify_parking_changes=get_notification_default(user.notify_parking_changes, True)
)

View File

@@ -5,7 +5,7 @@ Manager settings, closing days, guarantees, and exclusions
Key concept: Managers own parking spots and set rules for their managed users.
Rules are set at manager level (users have manager_id pointing to their manager).
"""
from datetime import datetime
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -26,7 +26,8 @@ router = APIRouter(prefix="/api/managers", tags=["managers"])
# Request/Response Models
class ClosingDayCreate(BaseModel):
date: str # YYYY-MM-DD
date: date # Start date
end_date: date | None = None # Optional end date (inclusive)
reason: str | None = None
@@ -36,14 +37,16 @@ class WeeklyClosingDayCreate(BaseModel):
class GuaranteeCreate(BaseModel):
user_id: str
start_date: str | None = None
end_date: str | None = None
start_date: date | None = None
end_date: date | None = None
notes: str | None = None
class ExclusionCreate(BaseModel):
user_id: str
start_date: str | None = None
end_date: str | None = None
start_date: date | None = None
end_date: date | None = None
notes: str | None = None
class ManagerSettingsUpdate(BaseModel):
@@ -124,7 +127,7 @@ def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Se
raise HTTPException(status_code=400, detail=f"Spot prefix '{data.spot_prefix}' is already used")
manager.manager_spot_prefix = data.spot_prefix
manager.updated_at = datetime.utcnow().isoformat()
manager.updated_at = datetime.utcnow()
db.commit()
return {
@@ -155,7 +158,7 @@ def get_manager_closing_days(manager_id: str, db: Session = Depends(get_db), use
days = db.query(ManagerClosingDay).filter(
ManagerClosingDay.manager_id == manager_id
).order_by(ManagerClosingDay.date).all()
return [{"id": d.id, "date": d.date, "reason": d.reason} for d in days]
return [{"id": d.id, "date": d.date, "end_date": d.end_date, "reason": d.reason} for d in days]
@router.post("/{manager_id}/closing-days")
@@ -172,10 +175,14 @@ def add_manager_closing_day(manager_id: str, data: ClosingDayCreate, db: Session
if existing:
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
if data.end_date and data.end_date < data.date:
raise HTTPException(status_code=400, detail="End date must be after start date")
closing_day = ManagerClosingDay(
id=generate_uuid(),
manager_id=manager_id,
date=data.date,
end_date=data.end_date,
reason=data.reason
)
db.add(closing_day)
@@ -270,7 +277,8 @@ def get_manager_guarantees(manager_id: str, db: Session = Depends(get_db), user=
"user_id": g.user_id,
"user_name": user_lookup.get(g.user_id),
"start_date": g.start_date,
"end_date": g.end_date
"end_date": g.end_date,
"notes": g.notes
}
for g in guarantees
]
@@ -292,13 +300,17 @@ def add_manager_guarantee(manager_id: str, data: GuaranteeCreate, db: Session =
if existing:
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
if data.start_date and data.end_date and data.end_date < data.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
guarantee = ParkingGuarantee(
id=generate_uuid(),
manager_id=manager_id,
user_id=data.user_id,
start_date=data.start_date,
end_date=data.end_date,
created_at=datetime.utcnow().isoformat()
notes=data.notes,
created_at=datetime.utcnow()
)
db.add(guarantee)
db.commit()
@@ -340,7 +352,8 @@ def get_manager_exclusions(manager_id: str, db: Session = Depends(get_db), user=
"user_id": e.user_id,
"user_name": user_lookup.get(e.user_id),
"start_date": e.start_date,
"end_date": e.end_date
"end_date": e.end_date,
"notes": e.notes
}
for e in exclusions
]
@@ -362,13 +375,17 @@ def add_manager_exclusion(manager_id: str, data: ExclusionCreate, db: Session =
if existing:
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
if data.start_date and data.end_date and data.end_date < data.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
exclusion = ParkingExclusion(
id=generate_uuid(),
manager_id=manager_id,
user_id=data.user_id,
start_date=data.start_date,
end_date=data.end_date,
created_at=datetime.utcnow().isoformat()
notes=data.notes,
created_at=datetime.utcnow()
)
db.add(exclusion)
db.commit()

500
app/routes/offices.py Normal file
View File

@@ -0,0 +1,500 @@
"""
Office Management Routes
Office settings, closing days, guarantees, and exclusions
"""
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from database.connection import get_db
from database.models import (
User, Office,
OfficeClosingDay, OfficeWeeklyClosingDay,
ParkingGuarantee, ParkingExclusion,
UserRole
)
from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user
from utils.helpers import generate_uuid
from app import config
router = APIRouter(prefix="/api/offices", tags=["offices"])
# Request/Response Models
class ValidOfficeCreate(BaseModel):
name: str
parking_quota: int = 0
class ClosingDayCreate(BaseModel):
date: date # Start date
end_date: date | None = None # Optional end date (inclusive)
reason: str | None = None
class WeeklyClosingDayCreate(BaseModel):
weekday: int # 0=Sunday, 1=Monday, ..., 6=Saturday
class GuaranteeCreate(BaseModel):
user_id: str
start_date: date | None = None
end_date: date | None = None
notes: str | None = None
class ExclusionCreate(BaseModel):
user_id: str
start_date: date | None = None
end_date: date | None = None
notes: str | None = None
class OfficeSettingsUpdate(BaseModel):
parking_quota: int | None = None
name: str | None = None
booking_window_enabled: bool | None = None
booking_window_end_hour: int | None = None
booking_window_end_minute: int | None = None
# Helper check
def check_office_access(user: User, office_id: str):
if user.role == UserRole.ADMIN:
return True
if user.role == UserRole.MANAGER and user.office_id == office_id:
return True
raise HTTPException(status_code=403, detail="Access denied to this office")
# Office listing and details
@router.get("")
def list_offices(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all offices with their user count and parking quota"""
offices = db.query(Office).all()
# Batch query user counts
counts = db.query(User.office_id, func.count(User.id)).filter(
User.office_id.isnot(None)
).group_by(User.office_id).all()
user_counts = {office_id: count for office_id, count in counts}
return [
{
"id": office.id,
"name": office.name,
"parking_quota": office.parking_quota,
"spot_prefix": office.spot_prefix,
"user_count": user_counts.get(office.id, 0)
}
for office in offices
]
def get_next_available_prefix(db: Session) -> str:
"""Find the next available office prefix (A, B, C... AA, AB...)"""
existing = db.query(Office.spot_prefix).filter(Office.spot_prefix.isnot(None)).all()
used_prefixes = {row[0] for row in existing}
# Try single letters A-Z
for i in range(26):
char = chr(65 + i)
if char not in used_prefixes:
return char
# Try double letters AA-ZZ if needed
for i in range(26):
for j in range(26):
char = chr(65 + i) + chr(65 + j)
if char not in used_prefixes:
return char
raise HTTPException(status_code=400, detail="No more office prefixes available")
@router.post("")
def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Create a new office (admin only)"""
office = Office(
id=generate_uuid(),
name=data.name,
parking_quota=data.parking_quota,
spot_prefix=get_next_available_prefix(db),
created_at=datetime.utcnow()
)
db.add(office)
db.commit()
return office
@router.get("/{office_id}")
def get_office_details(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get office details"""
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
check_office_access(user, office_id)
user_count = db.query(User).filter(User.office_id == office_id).count()
return {
"id": office.id,
"name": office.name,
"parking_quota": office.parking_quota,
"spot_prefix": office.spot_prefix,
"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
}
@router.put("/{office_id}")
def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Update office settings (admin only) - Manager can view but usually Admin sets quota"""
# Verify access - currently assume admin manages quota. If manager should too, update logic.
# User request description: "Admin manage all offices with CRUD... rimodulare posti auto".
# So Managers might not edit quota? Or maybe they can?
# Keeping it simple: require_admin for structural changes.
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
if data.name:
office.name = data.name
if data.parking_quota is not None:
if data.parking_quota < 0:
raise HTTPException(status_code=400, detail="Parking quota must be non-negative")
office.parking_quota = data.parking_quota
if data.booking_window_enabled is not None:
office.booking_window_enabled = data.booking_window_enabled
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")
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")
office.booking_window_end_minute = data.booking_window_end_minute
office.updated_at = datetime.utcnow()
db.commit()
return {
"id": office.id,
"name": office.name,
"parking_quota": office.parking_quota,
"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
}
@router.delete("/{office_id}")
def delete_office(office_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Delete an office (admin only)"""
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
db.delete(office)
db.commit()
return {"message": "Office deleted"}
@router.get("/{office_id}/users")
def get_office_users(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all users in an office"""
check_office_access(user, office_id)
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
users = db.query(User).filter(User.office_id == office_id).all()
return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role} for u in users]
# Closing days
@router.get("/{office_id}/closing-days")
def get_office_closing_days(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Get closing days for an office"""
# Any user in the office can read closing days? Or just manager?
# check_office_access(user, office_id) # Let's allow read for all authenticated (frontend might need it)
days = db.query(OfficeClosingDay).filter(
OfficeClosingDay.office_id == office_id
).order_by(OfficeClosingDay.date).all()
return [{"id": d.id, "date": d.date, "end_date": d.end_date, "reason": d.reason} for d in days]
@router.post("/{office_id}/closing-days")
def add_office_closing_day(office_id: str, data: ClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add a closing day for an office"""
check_office_access(user, office_id)
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
existing = db.query(OfficeClosingDay).filter(
OfficeClosingDay.office_id == office_id,
OfficeClosingDay.date == data.date
).first()
if existing:
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
if data.end_date and data.end_date < data.date:
raise HTTPException(status_code=400, detail="End date must be after start date")
closing_day = OfficeClosingDay(
id=generate_uuid(),
office_id=office_id,
date=data.date,
end_date=data.end_date,
reason=data.reason
)
db.add(closing_day)
db.commit()
return {"id": closing_day.id, "message": "Closing day added"}
@router.delete("/{office_id}/closing-days/{closing_day_id}")
def remove_office_closing_day(office_id: str, closing_day_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove a closing day for an office"""
check_office_access(user, office_id)
closing_day = db.query(OfficeClosingDay).filter(
OfficeClosingDay.id == closing_day_id,
OfficeClosingDay.office_id == office_id
).first()
if not closing_day:
raise HTTPException(status_code=404, detail="Closing day not found")
db.delete(closing_day)
db.commit()
return {"message": "Closing day removed"}
# Weekly closing days
@router.get("/{office_id}/weekly-closing-days")
def get_office_weekly_closing_days(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Get weekly closing days for an office"""
days = db.query(OfficeWeeklyClosingDay).filter(
OfficeWeeklyClosingDay.office_id == office_id
).all()
return [{"id": d.id, "weekday": d.weekday} for d in days]
@router.post("/{office_id}/weekly-closing-days")
def add_office_weekly_closing_day(office_id: str, data: WeeklyClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add a weekly closing day for an office"""
check_office_access(user, office_id)
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
if data.weekday < 0 or data.weekday > 6:
raise HTTPException(status_code=400, detail="Weekday must be 0-6 (0=Sunday, 6=Saturday)")
existing = db.query(OfficeWeeklyClosingDay).filter(
OfficeWeeklyClosingDay.office_id == office_id,
OfficeWeeklyClosingDay.weekday == data.weekday
).first()
if existing:
raise HTTPException(status_code=400, detail="Weekly closing day already exists for this weekday")
weekly_closing = OfficeWeeklyClosingDay(
id=generate_uuid(),
office_id=office_id,
weekday=data.weekday
)
db.add(weekly_closing)
db.commit()
return {"id": weekly_closing.id, "message": "Weekly closing day added"}
@router.delete("/{office_id}/weekly-closing-days/{weekly_id}")
def remove_office_weekly_closing_day(office_id: str, weekly_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove a weekly closing day for an office"""
check_office_access(user, office_id)
weekly_closing = db.query(OfficeWeeklyClosingDay).filter(
OfficeWeeklyClosingDay.id == weekly_id,
OfficeWeeklyClosingDay.office_id == office_id
).first()
if not weekly_closing:
raise HTTPException(status_code=404, detail="Weekly closing day not found")
db.delete(weekly_closing)
db.commit()
return {"message": "Weekly closing day removed"}
# Guarantees
@router.get("/{office_id}/guarantees")
def get_office_guarantees(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get parking guarantees for an office"""
check_office_access(user, office_id)
guarantees = db.query(ParkingGuarantee).filter(ParkingGuarantee.office_id == office_id).all()
# Batch query to get all user names at once
user_ids = [g.user_id for g in guarantees]
if user_ids:
users = db.query(User).filter(User.id.in_(user_ids)).all()
user_lookup = {u.id: u.name for u in users}
else:
user_lookup = {}
return [
{
"id": g.id,
"user_id": g.user_id,
"user_name": user_lookup.get(g.user_id),
"start_date": g.start_date,
"end_date": g.end_date,
"notes": g.notes
}
for g in guarantees
]
@router.post("/{office_id}/guarantees")
def add_office_guarantee(office_id: str, data: GuaranteeCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add parking guarantee for an office"""
check_office_access(user, office_id)
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
if not db.query(User).filter(User.id == data.user_id).first():
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(ParkingGuarantee).filter(
ParkingGuarantee.office_id == office_id,
ParkingGuarantee.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
if data.start_date and data.end_date and data.end_date < data.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
guarantee = ParkingGuarantee(
id=generate_uuid(),
office_id=office_id,
user_id=data.user_id,
start_date=data.start_date,
end_date=data.end_date,
notes=data.notes,
created_at=datetime.utcnow()
)
db.add(guarantee)
db.commit()
return {"id": guarantee.id, "message": "Guarantee added"}
@router.delete("/{office_id}/guarantees/{guarantee_id}")
def remove_office_guarantee(office_id: str, guarantee_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove parking guarantee for an office"""
check_office_access(user, office_id)
guarantee = db.query(ParkingGuarantee).filter(
ParkingGuarantee.id == guarantee_id,
ParkingGuarantee.office_id == office_id
).first()
if not guarantee:
raise HTTPException(status_code=404, detail="Guarantee not found")
db.delete(guarantee)
db.commit()
return {"message": "Guarantee removed"}
# Exclusions
@router.get("/{office_id}/exclusions")
def get_office_exclusions(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get parking exclusions for an office"""
check_office_access(user, office_id)
exclusions = db.query(ParkingExclusion).filter(ParkingExclusion.office_id == office_id).all()
# Batch query to get all user names at once
user_ids = [e.user_id for e in exclusions]
if user_ids:
users = db.query(User).filter(User.id.in_(user_ids)).all()
user_lookup = {u.id: u.name for u in users}
else:
user_lookup = {}
return [
{
"id": e.id,
"user_id": e.user_id,
"user_name": user_lookup.get(e.user_id),
"start_date": e.start_date,
"end_date": e.end_date,
"notes": e.notes
}
for e in exclusions
]
@router.post("/{office_id}/exclusions")
def add_office_exclusion(office_id: str, data: ExclusionCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add parking exclusion for an office"""
check_office_access(user, office_id)
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
if not db.query(User).filter(User.id == data.user_id).first():
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(ParkingExclusion).filter(
ParkingExclusion.office_id == office_id,
ParkingExclusion.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
if data.start_date and data.end_date and data.end_date < data.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
exclusion = ParkingExclusion(
id=generate_uuid(),
office_id=office_id,
user_id=data.user_id,
start_date=data.start_date,
end_date=data.end_date,
notes=data.notes,
created_at=datetime.utcnow()
)
db.add(exclusion)
db.commit()
return {"id": exclusion.id, "message": "Exclusion added"}
@router.delete("/{office_id}/exclusions/{exclusion_id}")
def remove_office_exclusion(office_id: str, exclusion_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove parking exclusion for an office"""
check_office_access(user, office_id)
exclusion = db.query(ParkingExclusion).filter(
ParkingExclusion.id == exclusion_id,
ParkingExclusion.office_id == office_id
).first()
if not exclusion:
raise HTTPException(status_code=404, detail="Exclusion not found")
db.delete(exclusion)
db.commit()
return {"message": "Exclusion removed"}

View File

@@ -2,21 +2,33 @@
Parking Management Routes
Parking assignments, spot management, and pool initialization
Manager-centric model:
- Managers own parking spots (defined by manager_parking_quota)
- Spots are named with manager's letter prefix (A1, A2, B1, B2...)
- Assignments reference manager_id directly
"""
"""
Parking Management Routes
Parking assignments, spot management, and pool initialization
Manager-centric model:
- Managers own parking spots (defined by manager_parking_quota)
- Spots are named with manager's letter prefix (A1, A2, B1, B2...)
- Assignments reference manager_id directly
"""
from typing import List
from datetime import datetime
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from database.connection import get_db
from database.models import DailyParkingAssignment, User
from database.models import DailyParkingAssignment, User, UserRole, Office
from utils.auth_middleware import get_current_user, require_manager_or_admin
from services.parking import initialize_parking_pool, get_spot_display_name
from services.parking import (
initialize_parking_pool, get_spot_display_name, release_user_spot,
run_batch_allocation, clear_assignments_for_office_date
)
from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned
from app import config
@@ -25,14 +37,14 @@ router = APIRouter(prefix="/api/parking", tags=["parking"])
# Request/Response Models
class InitPoolRequest(BaseModel):
date: str # YYYY-MM-DD
date: date
class ManualAssignRequest(BaseModel):
manager_id: str
office_id: str
user_id: str
spot_id: str
date: str
date: date
class ReassignSpotRequest(BaseModel):
@@ -42,50 +54,57 @@ class ReassignSpotRequest(BaseModel):
class AssignmentResponse(BaseModel):
id: str
date: str
date: date
spot_id: str
spot_display_name: str | None = None
user_id: str | None
manager_id: str
office_id: str
user_name: str | None = None
user_email: str | None = None
class RunAllocationRequest(BaseModel):
date: date
office_id: str
class ClearAssignmentsRequest(BaseModel):
date: date
office_id: str
# Routes
@router.post("/init-manager-pool")
def init_manager_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Initialize parking pool for a manager on a given date"""
try:
datetime.strptime(request.date, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
@router.post("/init-office-pool")
def init_office_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Initialize parking pool for an office on a given date"""
pool_date = request.date
quota = current_user.manager_parking_quota or 0
if quota == 0:
return {"success": True, "message": "No parking quota configured", "spots": 0}
if not current_user.office_id:
raise HTTPException(status_code=400, detail="User does not belong to an office")
office = db.query(Office).filter(Office.id == current_user.office_id).first()
if not office or not office.parking_quota:
return {"success": True, "message": "No parking quota configured", "spots": 0}
spots = initialize_parking_pool(current_user.id, quota, request.date, db)
spots = initialize_parking_pool(office.id, office.parking_quota, pool_date, db)
return {"success": True, "spots": spots}
@router.get("/assignments/{date}", response_model=List[AssignmentResponse])
def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get parking assignments for a date, optionally filtered by manager"""
try:
datetime.strptime(date, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
@router.get("/assignments/{date_val}", response_model=List[AssignmentResponse])
def get_assignments(date_val: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get parking assignments for a date, optionally filtered by office"""
query_date = date_val
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == date)
if manager_id:
query = query.filter(DailyParkingAssignment.manager_id == manager_id)
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date)
if office_id:
query = query.filter(DailyParkingAssignment.office_id == office_id)
assignments = query.all()
results = []
for assignment in assignments:
# Get display name using manager's spot prefix
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
# Get display name using office's spot prefix
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
result = AssignmentResponse(
id=assignment.id,
@@ -93,7 +112,7 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
spot_id=assignment.spot_id,
spot_display_name=spot_display_name,
user_id=assignment.user_id,
manager_id=assignment.manager_id
office_id=assignment.office_id
)
if assignment.user_id:
@@ -108,7 +127,7 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
@router.get("/my-assignments", response_model=List[AssignmentResponse])
def get_my_assignments(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
def get_my_assignments(start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get current user's parking assignments"""
query = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == current_user.id
@@ -123,7 +142,7 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
results = []
for assignment in assignments:
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
results.append(AssignmentResponse(
id=assignment.id,
@@ -131,7 +150,7 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
spot_id=assignment.spot_id,
spot_display_name=spot_display_name,
user_id=assignment.user_id,
manager_id=assignment.manager_id,
office_id=assignment.office_id,
user_name=current_user.name,
user_email=current_user.email
))
@@ -139,27 +158,55 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session
return results
return results
@router.post("/run-allocation")
def run_fair_allocation(data: RunAllocationRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Manually trigger fair allocation for a date (Test Tool)"""
# 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")
result = run_batch_allocation(data.office_id, data.date, db)
return {"message": "Allocation completed", "result": result}
@router.post("/clear-assignments")
def clear_assignments(data: ClearAssignmentsRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Clear all assignments for a date (Test Tool)"""
# Verify office access
if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id:
raise HTTPException(status_code=403, detail="Not authorized for this office")
count = clear_assignments_for_office_date(data.office_id, data.date, db)
return {"message": "Assignments cleared", "count": count}
@router.post("/manual-assign")
def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Manually assign a spot to a user"""
assign_date = data.date
# Verify user exists
user = db.query(User).filter(User.id == data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Verify manager exists and check permission
manager = db.query(User).filter(User.id == data.manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
# Verify office exists
office = db.query(Office).filter(Office.id == data.office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
# Only admin or the manager themselves can assign spots
if current_user.role != "admin" and current_user.id != data.manager_id:
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this manager")
# Only admin or the manager of that office can assign spots
is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == data.office_id)
if current_user.role != UserRole.ADMIN and not is_manager:
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this office")
# Check if spot exists and is free
spot = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == data.manager_id,
DailyParkingAssignment.date == data.date,
DailyParkingAssignment.office_id == data.office_id,
DailyParkingAssignment.date == assign_date,
DailyParkingAssignment.spot_id == data.spot_id
).first()
@@ -170,7 +217,7 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
# Check if user already has a spot for this date (from any manager)
existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.date == data.date,
DailyParkingAssignment.date == assign_date,
DailyParkingAssignment.user_id == data.user_id
).first()
@@ -180,7 +227,7 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
spot.user_id = data.user_id
db.commit()
spot_display_name = get_spot_display_name(data.spot_id, data.manager_id, db)
spot_display_name = get_spot_display_name(data.spot_id, data.office_id, db)
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name}
@@ -198,7 +245,7 @@ def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_u
raise HTTPException(status_code=403, detail="You can only release your own parking spot")
# Get spot display name for notification
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
assignment.user_id = None
db.commit()
@@ -223,9 +270,9 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
raise HTTPException(status_code=404, detail="Assignment not found")
# Check permission: admin, manager who owns the spot, or current spot holder
is_admin = current_user.role == 'admin'
is_admin = current_user.role == UserRole.ADMIN
is_spot_owner = assignment.user_id == current_user.id
is_manager = current_user.id == assignment.manager_id
is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == assignment.office_id)
if not (is_admin or is_manager or is_spot_owner):
raise HTTPException(status_code=403, detail="Not authorized to reassign this spot")
@@ -235,9 +282,17 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None
# Get spot display name for notifications
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
if data.new_user_id:
if data.new_user_id == "auto":
# "Auto assign" means releasing the spot so the system picks the next person
# release_user_spot returns True if it released it (and potentially reassigned it)
success = release_user_spot(assignment.office_id, assignment.user_id, assignment.date, db)
if not success:
raise HTTPException(status_code=400, detail="Could not auto-reassign spot")
return {"message": "Spot released for auto-assignment"}
elif data.new_user_id:
# Check new user exists
new_user = db.query(User).filter(User.id == data.new_user_id).first()
if not new_user:
@@ -275,7 +330,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
db.refresh(assignment)
# Build response
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
result = AssignmentResponse(
id=assignment.id,
@@ -283,7 +338,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
spot_id=assignment.spot_id,
spot_display_name=spot_display_name,
user_id=assignment.user_id,
manager_id=assignment.manager_id
office_id=assignment.office_id
)
if assignment.user_id:
@@ -308,16 +363,16 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
raise HTTPException(status_code=404, detail="Assignment not found")
# Check permission: admin, manager who owns the spot, or current spot holder
is_admin = current_user.role == 'admin'
is_admin = current_user.role == UserRole.ADMIN
is_spot_owner = assignment.user_id == current_user.id
is_manager = current_user.id == assignment.manager_id
is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == assignment.office_id)
if not (is_admin or is_manager or is_spot_owner):
raise HTTPException(status_code=403, detail="Not authorized")
# Get users in this manager's team (including the manager themselves)
# Get users in this office (including the manager themselves)
users = db.query(User).filter(
(User.manager_id == assignment.manager_id) | (User.id == assignment.manager_id),
User.office_id == assignment.office_id,
User.id != assignment.user_id # Exclude current holder
).all()

View File

@@ -3,13 +3,13 @@ Presence Management Routes
User presence marking and admin management
"""
from typing import List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from datetime import datetime, timedelta, date
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from database.connection import get_db
from database.models import UserPresence, User, DailyParkingAssignment
from database.models import UserPresence, User, DailyParkingAssignment, UserRole, PresenceStatus, Office
from utils.auth_middleware import get_current_user, require_manager_or_admin
from utils.helpers import generate_uuid
from services.parking import handle_presence_change, get_spot_display_name
@@ -20,38 +20,26 @@ router = APIRouter(prefix="/api/presence", tags=["presence"])
# Request/Response Models
class PresenceMarkRequest(BaseModel):
date: str # YYYY-MM-DD
status: str # present, remote, absent
date: date
status: PresenceStatus
class AdminPresenceMarkRequest(BaseModel):
user_id: str
date: str
status: str
date: date
status: PresenceStatus
class BulkPresenceRequest(BaseModel):
start_date: str
end_date: str
status: str
days: List[int] | None = None # Optional: [0,1,2,3,4] for Mon-Fri
class AdminBulkPresenceRequest(BaseModel):
user_id: str
start_date: str
end_date: str
status: str
days: List[int] | None = None
class PresenceResponse(BaseModel):
id: str
user_id: str
date: str
status: str
created_at: str | None
updated_at: str | None
date: date
status: PresenceStatus
created_at: datetime | None
updated_at: datetime | None
parking_spot_number: str | None = None
class Config:
@@ -59,51 +47,38 @@ class PresenceResponse(BaseModel):
# Helper functions
def validate_status(status: str):
if status not in ["present", "remote", "absent"]:
raise HTTPException(status_code=400, detail="Status must be: present, remote, or absent")
def parse_date(date_str: str) -> datetime:
try:
return datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
def check_manager_access(current_user: User, target_user: User, db: Session):
"""Check if current_user has access to target_user"""
if current_user.role == "admin":
if current_user.role == UserRole.ADMIN:
return True
if current_user.role == "manager":
# Manager can access users they manage
if target_user.manager_id == current_user.id:
if current_user.role == UserRole.MANAGER:
# Manager can access users in their Office
if target_user.office_id == current_user.office_id:
return True
raise HTTPException(status_code=403, detail="User is not managed by you")
raise HTTPException(status_code=403, detail="User is not in your office")
raise HTTPException(status_code=403, detail="Access denied")
def _mark_presence_for_user(
user_id: str,
date: str,
status: str,
presence_date: date,
status: PresenceStatus,
db: Session,
target_user: User
) -> UserPresence:
"""
Core presence marking logic - shared by user and admin routes.
"""
validate_status(status)
parse_date(date)
existing = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.date == date
UserPresence.date == presence_date
).first()
now = datetime.utcnow().isoformat()
now = datetime.utcnow()
old_status = existing.status if existing else None
if existing:
@@ -116,7 +91,7 @@ def _mark_presence_for_user(
presence = UserPresence(
id=generate_uuid(),
user_id=user_id,
date=date,
date=presence_date,
status=status,
created_at=now,
updated_at=now
@@ -125,114 +100,36 @@ def _mark_presence_for_user(
db.commit()
db.refresh(presence)
# Handle parking assignment
# Use manager_id if user has one, or user's own id if they are a manager
parking_manager_id = target_user.manager_id
if not parking_manager_id and target_user.role == "manager":
# Manager is part of their own team for parking purposes
parking_manager_id = target_user.id
if old_status != status and parking_manager_id:
# Handle parking assignment (if user is in an office)
if target_user.office_id and old_status != status:
try:
handle_presence_change(
user_id, date,
old_status or "absent", status,
parking_manager_id, db
user_id, presence_date,
old_status or PresenceStatus.ABSENT, status,
target_user.office_id, db
)
except Exception as e:
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}")
config.logger.warning(f"Parking handler failed for user {user_id} on {presence_date}: {e}")
return presence
def _bulk_mark_presence(
user_id: str,
start_date: str,
end_date: str,
status: str,
days: List[int] | None,
db: Session,
target_user: User
) -> List[UserPresence]:
"""
Core bulk presence marking logic - shared by user and admin routes.
"""
validate_status(status)
start = parse_date(start_date)
end = parse_date(end_date)
if end < start:
raise HTTPException(status_code=400, detail="End date must be after start date")
if (end - start).days > 90:
raise HTTPException(status_code=400, detail="Range cannot exceed 90 days")
results = []
current_date = start
now = datetime.utcnow().isoformat()
while current_date <= end:
if days is None or current_date.weekday() in days:
date_str = current_date.strftime("%Y-%m-%d")
existing = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.date == date_str
).first()
old_status = existing.status if existing else None
if existing:
existing.status = status
existing.updated_at = now
results.append(existing)
else:
presence = UserPresence(
id=generate_uuid(),
user_id=user_id,
date=date_str,
status=status,
created_at=now,
updated_at=now
)
db.add(presence)
results.append(presence)
# Handle parking for each date
# Use manager_id if user has one, or user's own id if they are a manager
parking_manager_id = target_user.manager_id
if not parking_manager_id and target_user.role == "manager":
parking_manager_id = target_user.id
if old_status != status and parking_manager_id:
try:
handle_presence_change(
user_id, date_str,
old_status or "absent", status,
parking_manager_id, db
)
except Exception as e:
config.logger.warning(f"Parking handler failed for user {user_id} on {date_str}: {e}")
current_date += timedelta(days=1)
db.commit()
return results
def _delete_presence(
user_id: str,
date: str,
presence_date: date,
db: Session,
target_user: User
) -> dict:
"""
Core presence deletion logic - shared by user and admin routes.
"""
parse_date(date)
presence = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.date == date
UserPresence.date == presence_date
).first()
if not presence:
@@ -242,20 +139,15 @@ def _delete_presence(
db.delete(presence)
db.commit()
# Use manager_id if user has one, or user's own id if they are a manager
parking_manager_id = target_user.manager_id
if not parking_manager_id and target_user.role == "manager":
parking_manager_id = target_user.id
if parking_manager_id:
if target_user.office_id:
try:
handle_presence_change(
user_id, date,
old_status, "absent",
parking_manager_id, db
user_id, presence_date,
old_status, PresenceStatus.ABSENT,
target_user.office_id, db
)
except Exception as e:
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}")
config.logger.warning(f"Parking handler failed for user {user_id} on {presence_date}: {e}")
return {"message": "Presence deleted"}
@@ -267,34 +159,26 @@ def mark_presence(data: PresenceMarkRequest, db: Session = Depends(get_db), curr
return _mark_presence_for_user(current_user.id, data.date, data.status, db, current_user)
@router.post("/mark-bulk", response_model=List[PresenceResponse])
def mark_bulk_presence(data: BulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Mark presence for a date range"""
return _bulk_mark_presence(
current_user.id, data.start_date, data.end_date,
data.status, data.days, db, current_user
)
@router.get("/my-presences", response_model=List[PresenceResponse])
def get_my_presences(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
def get_my_presences(start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get current user's presences"""
query = db.query(UserPresence).filter(UserPresence.user_id == current_user.id)
if start_date:
parse_date(start_date)
query = query.filter(UserPresence.date >= start_date)
if end_date:
parse_date(end_date)
query = query.filter(UserPresence.date <= end_date)
return query.order_by(UserPresence.date.desc()).all()
@router.delete("/{date}")
def delete_presence(date: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
@router.delete("/{date_val}")
def delete_presence(date_val: date, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Delete presence for a date"""
return _delete_presence(current_user.id, date, db, current_user)
return _delete_presence(current_user.id, date_val, db, current_user)
# Admin/Manager Routes
@@ -309,66 +193,47 @@ def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(ge
return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user)
@router.post("/admin/mark-bulk", response_model=List[PresenceResponse])
def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Bulk mark presence for any user (manager/admin)"""
target_user = db.query(User).filter(User.id == data.user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
check_manager_access(current_user, target_user, db)
return _bulk_mark_presence(
data.user_id, data.start_date, data.end_date,
data.status, data.days, db, target_user
)
@router.delete("/admin/{user_id}/{date}")
def admin_delete_presence(user_id: str, date: str, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
@router.delete("/admin/{user_id}/{date_val}")
def admin_delete_presence(user_id: str, date_val: date, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Delete presence for any user (manager/admin)"""
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
check_manager_access(current_user, target_user, db)
return _delete_presence(user_id, date, db, target_user)
return _delete_presence(user_id, date_val, db, target_user)
@router.get("/team")
def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get team presences with parking info, filtered by manager.
- Admins can see all teams
- Managers see their own team
- Employees can only see their own team (read-only view)
def get_team_presences(start_date: date, end_date: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get office presences with parking info.
- Admins can see all users (or filter by office_id)
- Managers see their own office's users
- Employees can see their own office's users (read-only view)
"""
parse_date(start_date)
parse_date(end_date)
# Get users based on permissions and manager filter
# Note: Manager is part of their own team (for parking assignment purposes)
if current_user.role == "employee":
# Employees can only see their own team (users with same manager_id + the manager)
if not current_user.manager_id:
return [] # No manager assigned, no team to show
users = db.query(User).filter(
(User.manager_id == current_user.manager_id) | (User.id == current_user.manager_id)
).all()
elif manager_id:
# Filter by specific manager (for admins/managers) - include the manager themselves
users = db.query(User).filter(
(User.manager_id == manager_id) | (User.id == manager_id)
).all()
elif current_user.role == "admin":
# Admin sees all users
users = db.query(User).all()
if current_user.role == UserRole.ADMIN:
if office_id:
users = db.query(User).filter(User.office_id == office_id).all()
else:
users = db.query(User).all()
elif current_user.office_id:
# Non-admin users see their office members
users = db.query(User).filter(User.office_id == current_user.office_id).all()
else:
# Manager sees their team + themselves
users = db.query(User).filter(
(User.manager_id == current_user.id) | (User.id == current_user.id)
).all()
# No office assigned
return []
# Batch query presences and parking for all users
# Batch query presences and parking for all selected users
user_ids = [u.id for u in users]
if not user_ids:
return []
presences = db.query(UserPresence).filter(
UserPresence.user_id.in_(user_ids),
UserPresence.date >= start_date,
@@ -389,7 +254,7 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
parking_lookup[p.user_id] = []
parking_info_lookup[p.user_id] = []
parking_lookup[p.user_id].append(p.date)
spot_display_name = get_spot_display_name(p.spot_id, p.manager_id, db)
spot_display_name = get_spot_display_name(p.spot_id, p.office_id, db)
parking_info_lookup[p.user_id].append({
"id": p.id,
"date": p.date,
@@ -397,10 +262,10 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
"spot_display_name": spot_display_name
})
# Build manager lookup for display
manager_ids = list(set(u.manager_id for u in users if u.manager_id))
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
manager_lookup = {m.id: m.name for m in managers}
# Build office lookup for display (replacing old manager_lookup)
office_ids = list(set(u.office_id for u in users if u.office_id))
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
office_lookup = {o.id: o.name for o in offices}
# Build response
result = []
@@ -410,8 +275,8 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
result.append({
"id": user.id,
"name": user.name,
"manager_id": user.manager_id,
"manager_name": manager_lookup.get(user.manager_id),
"office_id": user.office_id,
"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, [])
@@ -421,7 +286,7 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
@router.get("/admin/{user_id}")
def get_user_presences(user_id: str, start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
def get_user_presences(user_id: str, start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Get any user's presences with parking info (manager/admin)"""
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
@@ -432,24 +297,23 @@ def get_user_presences(user_id: str, start_date: str = None, end_date: str = Non
query = db.query(UserPresence).filter(UserPresence.user_id == user_id)
if start_date:
parse_date(start_date)
query = query.filter(UserPresence.date >= start_date)
if end_date:
parse_date(end_date)
query = query.filter(UserPresence.date <= end_date)
presences = query.order_by(UserPresence.date.desc()).all()
# Batch query parking assignments
date_strs = [p.date for p in presences]
dates = [p.date for p in presences]
parking_map = {}
if date_strs:
if dates:
# Note: Assignments link to user. We can find spot display name by looking up assignment -> office
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.date.in_(date_strs)
DailyParkingAssignment.date.in_(dates)
).all()
for a in assignments:
parking_map[a.date] = get_spot_display_name(a.spot_id, a.manager_id, db)
parking_map[a.date] = get_spot_display_name(a.spot_id, a.office_id, db)
# Build response
result = []

View File

@@ -8,7 +8,7 @@ from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from database.connection import get_db
from database.models import User
from database.models import User, UserRole, Office
from utils.auth_middleware import get_current_user, require_admin
from utils.helpers import (
generate_uuid, is_ldap_user, is_ldap_admin,
@@ -25,16 +25,14 @@ class UserCreate(BaseModel):
email: EmailStr
password: str
name: str | None = None
role: str = "employee"
manager_id: str | None = None
role: UserRole = UserRole.EMPLOYEE
office_id: str | None = None
class UserUpdate(BaseModel):
name: str | None = None
role: str | None = None
manager_id: str | None = None
manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None
role: UserRole | None = None
office_id: str | None = None
class ProfileUpdate(BaseModel):
@@ -44,11 +42,11 @@ class ProfileUpdate(BaseModel):
class SettingsUpdate(BaseModel):
week_start_day: int | None = None
# Notification preferences
notify_weekly_parking: int | None = None
notify_daily_parking: int | None = None
notify_weekly_parking: bool | None = None
notify_daily_parking: bool | None = None
notify_daily_parking_hour: int | None = None
notify_daily_parking_minute: int | None = None
notify_parking_changes: int | None = None
notify_parking_changes: bool | None = None
class ChangePasswordRequest(BaseModel):
@@ -60,61 +58,54 @@ class UserResponse(BaseModel):
id: str
email: str
name: str | None
role: str
manager_id: str | None = None
manager_name: str | None = None
manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None
managed_user_count: int | None = None
role: UserRole
office_id: str | None = None
office_name: str | None = None
is_ldap_user: bool = False
is_ldap_admin: bool = False
created_at: str | None
parking_ratio: float | None = None
class Config:
from_attributes = True
def user_to_response(user: User, db: Session, manager_lookup: dict = None, managed_counts: dict = None) -> dict:
def user_to_response(user: User, db: Session, office_lookup: dict = None) -> dict:
"""
Convert user to response dict with computed fields.
Args:
user: The user to convert
db: Database session
manager_lookup: Optional pre-fetched dict of manager_id -> name (for batch operations)
managed_counts: Optional pre-fetched dict of user_id -> managed_user_count (for batch operations)
"""
# Get manager name - use lookup if available, otherwise query
manager_name = None
if user.manager_id:
if manager_lookup is not None:
manager_name = manager_lookup.get(user.manager_id)
# Get office name - use lookup if available, otherwise query
office_name = None
if user.office_id:
if office_lookup is not None:
office_name = office_lookup.get(user.office_id)
else:
manager = db.query(User).filter(User.id == user.manager_id).first()
if manager:
manager_name = manager.name
office = db.query(Office).filter(Office.id == user.office_id).first()
if office:
office_name = office.name
# Count managed users if this user is a manager
managed_user_count = None
if user.role == "manager":
if managed_counts is not None:
managed_user_count = managed_counts.get(user.id, 0)
else:
managed_user_count = db.query(User).filter(User.manager_id == user.id).count()
# Calculate parking ratio (score)
parking_ratio = None
if user.office_id:
try:
# Avoid circular import by importing inside function if needed,
# or ensure services.parking doesn't import this file.
from services.parking import get_user_parking_ratio
parking_ratio = get_user_parking_ratio(user.id, user.office_id, db)
except ImportError:
pass
return {
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"manager_id": user.manager_id,
"manager_name": manager_name,
"manager_parking_quota": user.manager_parking_quota,
"manager_spot_prefix": user.manager_spot_prefix,
"managed_user_count": managed_user_count,
"office_id": user.office_id,
"office_name": office_name,
"is_ldap_user": is_ldap_user(user),
"is_ldap_admin": is_ldap_admin(user),
"created_at": user.created_at
"created_at": user.created_at.isoformat() if user.created_at else None,
"parking_ratio": parking_ratio
}
@@ -125,23 +116,12 @@ def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
users = db.query(User).all()
# Build lookups to avoid N+1 queries
# Manager lookup: id -> name
manager_ids = list(set(u.manager_id for u in users if u.manager_id))
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
manager_lookup = {m.id: m.name for m in managers}
# Office lookup: id -> name
office_ids = list(set(u.office_id for u in users if u.office_id))
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
office_lookup = {o.id: o.name for o in offices}
# Managed user counts for managers
from sqlalchemy import func
manager_user_ids = [u.id for u in users if u.role == "manager"]
if manager_user_ids:
counts = db.query(User.manager_id, func.count(User.id)).filter(
User.manager_id.in_(manager_user_ids)
).group_by(User.manager_id).all()
managed_counts = {manager_id: count for manager_id, count in counts}
else:
managed_counts = {}
return [user_to_response(u, db, manager_lookup, managed_counts) for u in users]
return [user_to_response(u, db, office_lookup) for u in users]
@router.get("/{user_id}")
@@ -162,18 +142,17 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
if db.query(User).filter(User.email == data.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role")
# Role validation handled by Pydantic Enum
# Validate password strength
password_errors = validate_password(data.password)
if password_errors:
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
if data.manager_id:
manager = db.query(User).filter(User.id == data.manager_id).first()
if not manager or manager.role != "manager":
raise HTTPException(status_code=400, detail="Invalid manager")
if data.office_id:
office = db.query(Office).filter(Office.id == data.office_id).first()
if not office:
raise HTTPException(status_code=400, detail="Invalid office")
new_user = User(
id=generate_uuid(),
@@ -181,8 +160,8 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
password_hash=hash_password(data.password),
name=data.name,
role=data.role,
manager_id=data.manager_id,
created_at=datetime.utcnow().isoformat()
office_id=data.office_id,
created_at=datetime.utcnow()
)
db.add(new_user)
@@ -211,54 +190,20 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
# Role update
if data.role is not None:
if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role")
# Can't change admin role for LDAP admins (they get admin from parking_admins group)
if target_is_ldap_admin and data.role != "admin":
if target_is_ldap_admin and data.role != UserRole.ADMIN:
raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)")
# If changing from manager to another role, check for managed users
if target.role == "manager" and data.role != "manager":
managed_count = db.query(User).filter(User.manager_id == user_id).count()
if managed_count > 0:
raise HTTPException(status_code=400, detail=f"Cannot change role: {managed_count} users are assigned to this manager")
# Clear manager-specific fields
target.manager_parking_quota = 0
target.manager_spot_prefix = None
target.role = data.role
# Manager assignment (any user including admins can be assigned to a manager)
if data.manager_id is not None:
if data.manager_id:
manager = db.query(User).filter(User.id == data.manager_id).first()
if not manager or manager.role != "manager":
raise HTTPException(status_code=400, detail="Invalid manager")
if data.manager_id == user_id:
raise HTTPException(status_code=400, detail="User cannot be their own manager")
target.manager_id = data.manager_id if data.manager_id else None
# Office assignment
if "office_id" in data.__fields_set__:
if data.office_id:
office = db.query(Office).filter(Office.id == data.office_id).first()
if not office:
raise HTTPException(status_code=400, detail="Invalid office")
target.office_id = data.office_id if data.office_id else None
# Manager-specific fields
if data.manager_parking_quota is not None:
if target.role != "manager":
raise HTTPException(status_code=400, detail="Parking quota only for managers")
target.manager_parking_quota = data.manager_parking_quota
if data.manager_spot_prefix is not None:
if target.role != "manager":
raise HTTPException(status_code=400, detail="Spot prefix only for managers")
prefix = data.manager_spot_prefix.upper() if data.manager_spot_prefix else None
if prefix and not prefix.isalpha():
raise HTTPException(status_code=400, detail="Spot prefix must be a letter")
# Check for duplicate prefix
if prefix:
existing = db.query(User).filter(
User.manager_spot_prefix == prefix,
User.id != user_id
).first()
if existing:
raise HTTPException(status_code=400, detail=f"Spot prefix '{prefix}' is already used by another manager")
target.manager_spot_prefix = prefix
target.updated_at = datetime.utcnow().isoformat()
target.updated_at = datetime.utcnow()
db.commit()
db.refresh(target)
return user_to_response(target, db)
@@ -274,12 +219,6 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend
if not target:
raise HTTPException(status_code=404, detail="User not found")
# Check if user is a manager with managed users
if target.role == "manager":
managed_count = db.query(User).filter(User.manager_id == user_id).count()
if managed_count > 0:
raise HTTPException(status_code=400, detail=f"Cannot delete: {managed_count} users are assigned to this manager")
db.delete(target)
db.commit()
return {"message": "User deleted"}
@@ -289,20 +228,20 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend
@router.get("/me/profile")
def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get current user's profile"""
# Get manager name
manager_name = None
if current_user.manager_id:
manager = db.query(User).filter(User.id == current_user.manager_id).first()
if manager:
manager_name = manager.name
# Get office name
office_name = None
if current_user.office_id:
office = db.query(Office).filter(Office.id == current_user.office_id).first()
if office:
office_name = office.name
return {
"id": current_user.id,
"email": current_user.email,
"name": current_user.name,
"role": current_user.role,
"manager_id": current_user.manager_id,
"manager_name": manager_name,
"office_id": current_user.office_id,
"office_name": office_name,
"is_ldap_user": is_ldap_user(current_user)
}
@@ -314,7 +253,7 @@ def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_u
if is_ldap_user(current_user):
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
current_user.name = data.name
current_user.updated_at = datetime.utcnow().isoformat()
current_user.updated_at = datetime.utcnow()
db.commit()
return {"message": "Profile updated"}
@@ -325,11 +264,11 @@ def get_settings(current_user=Depends(get_current_user)):
"""Get current user's settings"""
return {
"week_start_day": get_notification_default(current_user.week_start_day, 0),
"notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, 1),
"notify_daily_parking": get_notification_default(current_user.notify_daily_parking, 1),
"notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, True),
"notify_daily_parking": get_notification_default(current_user.notify_daily_parking, True),
"notify_daily_parking_hour": get_notification_default(current_user.notify_daily_parking_hour, 8),
"notify_daily_parking_minute": get_notification_default(current_user.notify_daily_parking_minute, 0),
"notify_parking_changes": get_notification_default(current_user.notify_parking_changes, 1)
"notify_parking_changes": get_notification_default(current_user.notify_parking_changes, True)
}
@@ -337,8 +276,8 @@ def get_settings(current_user=Depends(get_current_user)):
def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Update current user's settings"""
if data.week_start_day is not None:
if data.week_start_day not in [0, 1]:
raise HTTPException(status_code=400, detail="Week start must be 0 (Sunday) or 1 (Monday)")
if data.week_start_day not in [0, 6]:
raise HTTPException(status_code=400, detail="Week start must be 0 (Monday) or 6 (Sunday)")
current_user.week_start_day = data.week_start_day
# Notification preferences
@@ -361,7 +300,7 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current
if data.notify_parking_changes is not None:
current_user.notify_parking_changes = data.notify_parking_changes
current_user.updated_at = datetime.utcnow().isoformat()
current_user.updated_at = datetime.utcnow()
db.commit()
return {
"message": "Settings updated",
@@ -389,7 +328,7 @@ def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db),
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
current_user.password_hash = hash_password(data.new_password)
current_user.updated_at = datetime.utcnow().isoformat()
current_user.updated_at = datetime.utcnow()
db.commit()
config.logger.info(f"User {current_user.email} changed password")
return {"message": "Password changed"}

View File

@@ -3,8 +3,6 @@ services:
build: .
container_name: parking
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
env_file:
@@ -21,6 +19,13 @@ services:
start_period: 10s
networks:
- org-network
labels:
- "caddy=parking.lvh.me"
- "caddy.reverse_proxy={{upstreams 8000}}"
- "caddy.forward_auth=authelia:9091"
- "caddy.forward_auth.uri=/api/verify?rd=https://parking.lvh.me/"
- "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email"
networks:
org-network:

View File

@@ -1,132 +0,0 @@
"""
Create test database with sample data
Run: .venv/bin/python create_test_db.py
Manager-centric model:
- Users have a manager_id pointing to their manager
- Managers own parking spots (manager_parking_quota)
- Each manager has a spot prefix (A, B, C...) for display names
"""
import uuid
from datetime import datetime, timezone
from database.connection import engine, SessionLocal
from database.models import Base, User
from services.auth import hash_password
# Drop and recreate all tables for clean slate
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
now = datetime.now(timezone.utc).isoformat()
password_hash = hash_password("password123")
# Create users with manager-centric model
# manager_id points to the user's manager
users_data = [
{
"id": "admin",
"email": "admin@example.com",
"name": "Admin User",
"role": "admin",
"manager_id": None, # Admins don't have managers
},
{
"id": "manager1",
"email": "manager1@example.com",
"name": "Alice Manager",
"role": "manager",
"manager_id": None, # Managers don't have managers
"manager_parking_quota": 3,
"manager_spot_prefix": "A",
},
{
"id": "manager2",
"email": "manager2@example.com",
"name": "Bob Manager",
"role": "manager",
"manager_id": None,
"manager_parking_quota": 2,
"manager_spot_prefix": "B",
},
{
"id": "user1",
"email": "user1@example.com",
"name": "User One",
"role": "employee",
"manager_id": "manager1", # Managed by Alice
},
{
"id": "user2",
"email": "user2@example.com",
"name": "User Two",
"role": "employee",
"manager_id": "manager1", # Managed by Alice
},
{
"id": "user3",
"email": "user3@example.com",
"name": "User Three",
"role": "employee",
"manager_id": "manager1", # Managed by Alice
},
{
"id": "user4",
"email": "user4@example.com",
"name": "User Four",
"role": "employee",
"manager_id": "manager2", # Managed by Bob
},
{
"id": "user5",
"email": "user5@example.com",
"name": "User Five",
"role": "employee",
"manager_id": "manager2", # Managed by Bob
},
]
for data in users_data:
user = User(
id=data["id"],
email=data["email"],
password_hash=password_hash,
name=data["name"],
role=data["role"],
manager_id=data.get("manager_id"),
manager_parking_quota=data.get("manager_parking_quota", 0),
manager_spot_prefix=data.get("manager_spot_prefix"),
created_at=now
)
db.add(user)
print(f"Created user: {user.email} ({user.role})")
db.commit()
db.close()
print("\n" + "="*60)
print("Test database created successfully!")
print("="*60)
print("\nTest accounts (all use password: password123):")
print("-"*60)
print(f"{'Email':<25} {'Role':<10} {'Manager':<15}")
print("-"*60)
print(f"{'admin@example.com':<25} {'admin':<10} {'-':<15}")
print(f"{'manager1@example.com':<25} {'manager':<10} {'-':<15}")
print(f"{'manager2@example.com':<25} {'manager':<10} {'-':<15}")
print(f"{'user1@example.com':<25} {'employee':<10} {'Alice':<15}")
print(f"{'user2@example.com':<25} {'employee':<10} {'Alice':<15}")
print(f"{'user3@example.com':<25} {'employee':<10} {'Alice':<15}")
print(f"{'user4@example.com':<25} {'employee':<10} {'Bob':<15}")
print(f"{'user5@example.com':<25} {'employee':<10} {'Bob':<15}")
print("-"*60)
print("\nParking pools:")
print(" Alice (manager1): 3 spots (A1,A2,A3)")
print(" -> manages: user1, user2, user3")
print(" -> 3 users, 3 spots = 100% ratio target")
print()
print(" Bob (manager2): 2 spots (B1,B2)")
print(" -> manages: user4, user5")
print(" -> 2 users, 2 spots = 100% ratio target")

View File

@@ -2,12 +2,73 @@
SQLAlchemy ORM Models
Clean, focused data models for parking management
"""
from sqlalchemy import Column, String, Integer, Text, ForeignKey, Index
import enum
from sqlalchemy import Column, String, Integer, Text, ForeignKey, Index, Enum, Date, DateTime, Boolean
from sqlalchemy.orm import relationship, declarative_base
from datetime import datetime, date
Base = declarative_base()
class UserRole(str, enum.Enum):
ADMIN = "admin"
MANAGER = "manager"
EMPLOYEE = "employee"
class PresenceStatus(str, enum.Enum):
PRESENT = "present"
REMOTE = "remote"
ABSENT = "absent"
class NotificationType(str, enum.Enum):
PRESENCE_REMINDER = "presence_reminder"
WEEKLY_PARKING = "weekly_parking"
DAILY_PARKING = "daily_parking"
PARKING_CHANGE = "parking_change"
class WeekDay(enum.IntEnum):
# Matches Python's calendar (0=Monday)? No!
# The current DB convention in ManagerWeeklyClosingDay seems to be 0=Sunday based on comment:
# "0=Sunday, 1=Monday, ..., 6=Saturday"
# To keep consistency with existing logic comments, we'll stick to that,
# OR we can switch to standard Python (0=Monday).
# Plan said: "IntEnum matching DB convention (0=Sunday, 1=Monday, ...)"
MONDAY = 0
TUESDAY = 1
WEDNESDAY = 2
THURSDAY = 3
FRIDAY = 4
SATURDAY = 5
SUNDAY = 6
class Office(Base):
"""Organization units that have parking spots"""
__tablename__ = "offices"
id = Column(Text, primary_key=True)
name = Column(Text, nullable=False)
parking_quota = Column(Integer, default=0)
spot_prefix = Column(Text) # Letter prefix: A, B, C
# Booking Window Settings (Batch Assignment)
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
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
users = relationship("User", back_populates="office")
closing_days = relationship("OfficeClosingDay", back_populates="office", cascade="all, delete-orphan")
weekly_closing_days = relationship("OfficeWeeklyClosingDay", back_populates="office", cascade="all, delete-orphan")
class User(Base):
"""Application users"""
__tablename__ = "users"
@@ -16,34 +77,30 @@ class User(Base):
email = Column(Text, unique=True, nullable=False)
password_hash = Column(Text)
name = Column(Text)
role = Column(Text, nullable=False, default="employee") # admin, manager, employee
manager_id = Column(Text, ForeignKey("users.id")) # Who manages this user (any user can have a manager)
# Manager-specific fields (only relevant for role='manager')
manager_parking_quota = Column(Integer, default=0) # Number of spots this manager controls
manager_spot_prefix = Column(Text) # Letter prefix for spots: A, B, C, etc.
role = Column(Enum(UserRole, values_callable=lambda obj: [e.value for e in obj]), nullable=False, default=UserRole.EMPLOYEE)
office_id = Column(Text, ForeignKey("offices.id")) # Which office this user belongs to
# User preferences
week_start_day = Column(Integer, default=0) # 0=Sunday, 1=Monday, ..., 6=Saturday
week_start_day = Column(Integer, default=0) # 0=Sunday, 1=Monday, ... (Matches WeekDay logic)
# Notification preferences
notify_weekly_parking = Column(Integer, default=1) # Weekly parking summary (Friday at 12)
notify_daily_parking = Column(Integer, default=1) # Daily parking reminder
notify_weekly_parking = Column(Boolean, default=True) # Weekly parking summary (Friday at 12)
notify_daily_parking = Column(Boolean, default=True) # Daily parking reminder
notify_daily_parking_hour = Column(Integer, default=8) # Hour for daily reminder (0-23)
notify_daily_parking_minute = Column(Integer, default=0) # Minute for daily reminder (0-59)
notify_parking_changes = Column(Integer, default=1) # Immediate notification on assignment changes
notify_parking_changes = Column(Boolean, default=True) # Immediate notification on assignment changes
created_at = Column(Text)
updated_at = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
manager = relationship("User", remote_side=[id], backref="managed_users")
office = relationship("Office", back_populates="users")
presences = relationship("UserPresence", back_populates="user", cascade="all, delete-orphan")
assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id")
__table_args__ = (
Index('idx_user_email', 'email'),
Index('idx_user_manager', 'manager_id'),
Index('idx_user_office', 'office_id'),
)
@@ -53,10 +110,10 @@ class UserPresence(Base):
id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
date = Column(Text, nullable=False) # YYYY-MM-DD
status = Column(Text, nullable=False) # present, remote, absent
created_at = Column(Text)
updated_at = Column(Text)
date = Column(Date, nullable=False)
status = Column(Enum(PresenceStatus, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # present, remote, absent
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="presences")
@@ -68,97 +125,100 @@ class UserPresence(Base):
class DailyParkingAssignment(Base):
"""Parking spot assignments per day - spots belong to managers"""
"""Parking spot assignments per day - spots belong to offices"""
__tablename__ = "daily_parking_assignments"
id = Column(Text, primary_key=True)
date = Column(Text, nullable=False) # YYYY-MM-DD
spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from manager)
date = Column(Date, nullable=False)
spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from office)
user_id = Column(Text, ForeignKey("users.id", ondelete="SET NULL"))
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # Manager who owns the spot
created_at = Column(Text)
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) # Office that owns the spot
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="assignments", foreign_keys=[user_id])
manager = relationship("User", foreign_keys=[manager_id])
office = relationship("Office")
__table_args__ = (
Index('idx_assignment_manager_date', 'manager_id', 'date'),
Index('idx_assignment_office_date', 'office_id', 'date'),
Index('idx_assignment_user', 'user_id'),
Index('idx_assignment_date_spot', 'date', 'spot_id'),
)
class ManagerClosingDay(Base):
"""Specific date closing days for a manager's parking pool (holidays, special closures)"""
__tablename__ = "manager_closing_days"
class OfficeClosingDay(Base):
"""Specific date closing days for an office's parking pool (holidays, special closures)"""
__tablename__ = "office_closing_days"
id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
date = Column(Text, nullable=False) # YYYY-MM-DD
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
date = Column(Date, nullable=False)
end_date = Column(Date)
reason = Column(Text)
# Relationships
manager = relationship("User")
office = relationship("Office", back_populates="closing_days")
__table_args__ = (
Index('idx_closing_manager_date', 'manager_id', 'date', unique=True),
Index('idx_closing_office_date', 'office_id', 'date', unique=True),
)
class ManagerWeeklyClosingDay(Base):
"""Weekly recurring closing days for a manager's parking pool (e.g., Saturday and Sunday)"""
__tablename__ = "manager_weekly_closing_days"
class OfficeWeeklyClosingDay(Base):
"""Weekly recurring closing days for an office's parking pool (e.g., Saturday and Sunday)"""
__tablename__ = "office_weekly_closing_days"
id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday (Matches WeekDay Enum logic)
# Relationships
manager = relationship("User")
office = relationship("Office", back_populates="weekly_closing_days")
__table_args__ = (
Index('idx_weekly_closing_manager_day', 'manager_id', 'weekday', unique=True),
Index('idx_weekly_closing_office_day', 'office_id', 'weekday', unique=True),
)
class ParkingGuarantee(Base):
"""Users guaranteed a parking spot when present (set by manager)"""
"""Users guaranteed a parking spot when present (set by office manager)"""
__tablename__ = "parking_guarantees"
id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit)
end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit)
created_at = Column(Text)
start_date = Column(Date) # Optional (null = no start limit)
end_date = Column(Date) # Optional (null = no end limit)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
manager = relationship("User", foreign_keys=[manager_id])
office = relationship("Office")
user = relationship("User", foreign_keys=[user_id])
__table_args__ = (
Index('idx_guarantee_manager_user', 'manager_id', 'user_id', unique=True),
Index('idx_guarantee_office_user', 'office_id', 'user_id', unique=True),
)
class ParkingExclusion(Base):
"""Users excluded from parking assignment (set by manager)"""
"""Users excluded from parking assignment (set by office manager)"""
__tablename__ = "parking_exclusions"
id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit)
end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit)
created_at = Column(Text)
start_date = Column(Date) # Optional
end_date = Column(Date) # Optional
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
manager = relationship("User", foreign_keys=[manager_id])
office = relationship("Office")
user = relationship("User", foreign_keys=[user_id])
__table_args__ = (
Index('idx_exclusion_manager_user', 'manager_id', 'user_id', unique=True),
Index('idx_exclusion_office_user', 'office_id', 'user_id', unique=True),
)
@@ -168,9 +228,9 @@ class NotificationLog(Base):
id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
notification_type = Column(Text, nullable=False) # presence_reminder, weekly_parking, daily_parking, parking_change
reference_date = Column(Text) # The date/week this notification refers to (YYYY-MM-DD or YYYY-Www)
sent_at = Column(Text, nullable=False)
notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False)
reference_date = Column(Text) # The date/week this notification refers to (YYYY-MM-DD or YYYY-Www) - keeping as Text for flexibility
sent_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (
Index('idx_notification_user_type_date', 'user_id', 'notification_type', 'reference_date'),
@@ -183,11 +243,11 @@ class NotificationQueue(Base):
id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
notification_type = Column(Text, nullable=False) # parking_change
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(Text, nullable=False)
sent_at = Column(Text) # null = not sent yet
created_at = Column(DateTime, default=datetime.utcnow)
sent_at = Column(DateTime) # null = not sent yet
__table_args__ = (
Index('idx_queue_pending', 'sent_at'),

View File

@@ -1,8 +0,0 @@
# Caddy configuration snippet for parking.rocketscale.it
# Add this block to org-stack/Caddyfile after the (auth) snippet definition
# Parking Manager - Protected by Authelia
parking.rocketscale.it {
import auth
reverse_proxy parking:8000
}

View File

@@ -1,219 +0,0 @@
# Deployment Guide for parking.rocketscale.it
## Prerequisites
- org-stack running on rocky@rocketscale.it
- Git repository on git.rocketscale.it (optional, can use rsync)
## Directory Structure
Parking is deployed as a **separate directory** alongside org-stack:
```
~/
├── org-stack/ # Main stack (Caddy, Authelia, LLDAP, etc.)
│ ├── compose.yml
│ ├── Caddyfile
│ ├── authelia/
│ └── .env
└── org-parking/ # Parking app (separate)
├── compose.yml # Production compose (connects to org-stack network)
├── .env # Own .env with PARKING_SECRET_KEY
├── Dockerfile
└── ...
```
## Step 1: Deploy to Server
Option A - Using rsync (recommended for development):
```bash
# From development machine
rsync -avz --exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \
--exclude '.env' --exclude 'data/' --exclude '*.db' --exclude '.venv/' \
/path/to/org-parking/ rocky@rocketscale.it:~/org-parking/
```
Option B - Using git:
```bash
ssh rocky@rocketscale.it
cd ~
git clone git@git.rocketscale.it:rocky/parking-manager.git org-parking
```
## Step 2: Create Production compose.yml
Create `~/org-parking/compose.yml` on the server:
```yaml
services:
parking:
build: .
container_name: parking
restart: unless-stopped
volumes:
- parking_data:/app/data
environment:
- SECRET_KEY=${PARKING_SECRET_KEY}
- DATABASE_PATH=/app/data/parking.db
- AUTHELIA_ENABLED=true
- ALLOWED_ORIGINS=https://parking.rocketscale.it
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
networks:
- org-network
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
parking_data:
networks:
org-network:
external: true
name: org-stack_org-network
```
## Step 3: Create .env File
Create `~/org-parking/.env` with a secret key:
```bash
cd ~/org-parking
python3 -c "import secrets; print(f'PARKING_SECRET_KEY={secrets.token_hex(32)}')" > .env
```
**Note**: Each directory needs its own `.env` file since docker compose only reads from the current directory.
## Step 4: Add to Caddyfile
Add to `~/org-stack/Caddyfile`:
```
# Parking Manager - Protected by Authelia
parking.rocketscale.it {
import auth
reverse_proxy parking:8000
}
```
## Step 5: Add Authelia Access Control Rule
**Important**: Authelia's `access_control` must include parking.rocketscale.it or you'll get 403 Forbidden.
Edit `~/org-stack/authelia/configuration.yml` and add to the `access_control.rules` section:
```yaml
access_control:
default_policy: deny
rules:
# ... existing rules ...
# Parking Manager - require authentication
- domain: parking.rocketscale.it
policy: one_factor
```
After editing, restart Authelia:
```bash
cd ~/org-stack
docker compose restart authelia
```
## Step 6: Create LLDAP Group
In lldap (https://ldap.rocketscale.it):
1. Create group: `parking_admins`
2. Add yourself (or whoever should be admin) to this group
**Role Management:**
- `parking_admins` group → **admin** role (synced from LLDAP on each login)
- **manager** role → assigned by admin in the app UI (Manage Users page)
- **employee** role → default for all other users
The admin can promote users to manager and assign offices via the Manage Users page.
## Step 7: Build and Deploy
```bash
# Build and start parking service
cd ~/org-parking
docker compose build parking
docker compose up -d
# Reload Caddy to pick up new domain (from org-stack)
cd ~/org-stack
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
# Check logs
cd ~/org-parking
docker compose logs -f parking
```
## Step 8: Verify
1. Go to https://parking.rocketscale.it
2. You should be redirected to Authelia for login
3. After login, you should see the parking app
4. Your user should be auto-created with `admin` role (if in parking-admins group)
## Troubleshooting
### 403 Forbidden from Authelia
- Authelia's `access_control` doesn't have a rule for parking.rocketscale.it
- Add the domain to `~/org-stack/authelia/configuration.yml` (see Step 6)
- Restart Authelia: `docker compose restart authelia`
### 401 Unauthorized
- Check Authelia headers are being passed
- Check `docker compose logs authelia`
### Login redirect loop (keeps redirecting to /login)
- Frontend JS must use async auth checking for Authelia mode
- The `api.requireAuth()` must call `/api/auth/me` endpoint (not check localStorage)
- Ensure all page JS files use: `currentUser = await api.requireAuth();`
### User has wrong role
- **Admin role**: Verify user is in `parking_admins` LLDAP group (synced on each login)
- **Manager role**: Must be assigned by admin via Manage Users page (not from LLDAP)
- **Employee role**: Default for users not in `parking_admins` group
### Database errors
- Check volume mount: `docker compose exec parking ls -la /app/data`
- Check permissions: `docker compose exec parking id`
## Architecture Notes
### Authelia Integration
The app supports two authentication modes:
1. **JWT mode** (standalone): Users login via `/login`, get JWT token stored in localStorage
2. **Authelia mode** (SSO): Authelia handles login, passes headers to backend
When `AUTHELIA_ENABLED=true`:
- Backend reads user info from headers: `Remote-User`, `Remote-Email`, `Remote-Name`, `Remote-Groups`
- Users are auto-created on first login
- Roles are synced from LLDAP groups on each request
- Frontend calls `/api/auth/me` to get user info (backend reads headers)
### Role Management
Only the **admin** role is synced from LLDAP:
```python
AUTHELIA_ADMIN_GROUP = "parking_admins" # → role: admin
```
Other roles are managed within the app:
- **manager**: Assigned by admin via Manage Users page
- **employee**: Default role for all non-admin users
This separation allows:
- LLDAP to control who has admin access
- App admin to assign manager roles and office assignments without LLDAP changes

View File

@@ -1,32 +0,0 @@
# Production compose file for org-stack integration
# This will be added to ~/org-stack/compose.yml on the server
services:
parking:
build: ./parking
container_name: parking
restart: unless-stopped
volumes:
- parking_data:/app/data
environment:
- SECRET_KEY=${PARKING_SECRET_KEY}
- DATABASE_PATH=/app/data/parking.db
- AUTHELIA_ENABLED=true
- ALLOWED_ORIGINS=https://parking.rocketscale.it
# SMTP (shared with other services)
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
networks:
- org-network
depends_on:
- authelia
volumes:
parking_data:
networks:
org-network:
external: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

View File

@@ -30,7 +30,9 @@
/* ============================================================================
Reset & Base
============================================================================ */
*, *::before, *::after {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
@@ -54,7 +56,9 @@ button {
cursor: pointer;
}
input, select, textarea {
input,
select,
textarea {
font-family: inherit;
font-size: inherit;
}
@@ -431,11 +435,12 @@ input, select, textarea {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
}
.modal-small {
max-width: 360px;
max-width: 420px;
}
.modal-header {
@@ -612,16 +617,23 @@ input, select, textarea {
.calendar-day .parking-badge {
position: absolute;
bottom: 0.25rem;
left: 50%;
transform: translateX(-50%);
bottom: 6px;
left: 4px;
right: 4px;
background: #dbeafe;
color: #1e40af;
font-size: 0.6rem;
font-size: 0.8rem;
font-weight: 600;
padding: 0.1rem 0.3rem;
border-radius: 3px;
padding: 0.3rem 0;
border-radius: 6px;
border: 1px solid #93c5fd;
line-height: 1;
text-align: center;
transform: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* Status colors */
@@ -644,6 +656,28 @@ input, select, textarea {
background: white;
}
/* Closed Day */
.calendar-day.closed {
background: #e5e7eb;
color: #9ca3af;
cursor: not-allowed;
border-color: #d1d5db;
}
.calendar-day.closed:hover {
border-color: #d1d5db;
}
.calendar-day.closed .day-number {
opacity: 0.5;
}
.team-calendar td.closed {
background: #e5e7eb;
color: #9ca3af;
cursor: not-allowed;
}
/* Legend */
.legend {
display: flex;
@@ -975,11 +1009,11 @@ input, select, textarea {
border-radius: 50%;
}
.toggle-switch input:checked + .toggle-slider {
.toggle-switch input:checked+.toggle-slider {
background-color: var(--primary);
}
.toggle-switch input:checked + .toggle-slider:before {
.toggle-switch input:checked+.toggle-slider:before {
transform: translateX(22px);
}
@@ -1747,3 +1781,24 @@ input, select, textarea {
font-size: 0.75rem;
}
}
/* Toast Animations */
@keyframes slideInBottom {
from {
transform: translate(-50%, 100%);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -0,0 +1,167 @@
/**
* Admin Offices Page
* Manage offices, quotas, and prefixes
*/
let currentUser = null;
let offices = [];
document.addEventListener('DOMContentLoaded', async () => {
currentUser = await api.requireAuth();
if (!currentUser) return;
if (currentUser.role !== 'admin') {
window.location.href = '/presence';
return;
}
await loadOffices();
setupEventListeners();
});
async function loadOffices() {
const response = await api.get('/api/offices');
if (response && response.ok) {
offices = await response.json();
renderOffices();
}
}
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>';
return;
}
tbody.innerHTML = offices.map(office => {
return `
<tr>
<td>${office.name}</td>
<td><span class="badge badge-info">${office.parking_quota} posti</span></td>
<td><strong>${office.spot_prefix || '-'}</strong></td>
<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>
</td>
</tr>
`;
}).join('');
}
function openModal(title) {
document.getElementById('officeModalTitle').textContent = title;
document.getElementById('officeModal').style.display = 'flex';
}
function closeModal() {
document.getElementById('officeModal').style.display = 'none';
document.getElementById('officeForm').reset();
document.getElementById('officeId').value = '';
}
async function editOffice(officeId) {
const office = offices.find(o => o.id === officeId);
if (!office) return;
document.getElementById('officeId').value = office.id;
document.getElementById('officeName').value = office.name;
document.getElementById('officeQuota').value = office.parking_quota;
openModal('Modifica Ufficio');
}
async function deleteOffice(officeId) {
const office = offices.find(o => o.id === officeId);
if (!office) return;
if (!confirm(`Eliminare l'ufficio "${office.name}"?`)) return;
const response = await api.delete(`/api/offices/${officeId}`);
if (response && response.ok) {
utils.showMessage('Ufficio eliminato', 'success');
await loadOffices();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Impossibile eliminare l\'ufficio', 'error');
}
}
function setupEventListeners() {
// Add button
document.getElementById('addOfficeBtn').addEventListener('click', () => {
openModal('Nuovo Ufficio');
});
// Modal close
document.getElementById('closeOfficeModal').addEventListener('click', closeModal);
document.getElementById('cancelOffice').addEventListener('click', closeModal);
utils.setupModalClose('officeModal');
// Debug tracking for save button
const saveBtn = document.getElementById('saveOfficeBtn');
if (saveBtn) {
saveBtn.addEventListener('click', () => console.log('Save button clicked'));
}
// Form submit
const form = document.getElementById('officeForm');
form.addEventListener('submit', handleOfficeSubmit);
}
async function handleOfficeSubmit(e) {
e.preventDefault();
console.log('Form submitting...');
const saveBtn = document.getElementById('saveOfficeBtn');
const originalText = saveBtn.innerHTML;
saveBtn.disabled = true;
saveBtn.innerHTML = 'Salvataggio...';
const officeId = document.getElementById('officeId').value;
const data = {
name: document.getElementById('officeName').value,
parking_quota: parseInt(document.getElementById('officeQuota').value) || 0
};
console.log('Payload:', data);
try {
let response;
if (officeId) {
response = await api.put(`/api/offices/${officeId}`, data);
} else {
response = await api.post('/api/offices', data);
}
console.log('Response status:', response ? response.status : 'null');
if (response && response.ok) {
closeModal();
utils.showMessage(officeId ? 'Ufficio aggiornato' : 'Ufficio creato', 'success');
await loadOffices();
} else {
let errorMessage = 'Errore operazione';
try {
const error = await response.json();
errorMessage = error.detail || errorMessage;
} catch (e) {
console.error('Error parsing JSON error:', e);
errorMessage = 'Errore server imprevisto (' + (response ? response.status : 'network') + ')';
}
utils.showMessage(errorMessage, 'error');
}
} catch (error) {
console.error('Form submit exception:', error);
utils.showMessage('Errore di connessione: ' + error.message, 'error');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = originalText;
}
}
// Global functions
window.editOffice = editOffice;
window.deleteOffice = deleteOffice;

View File

@@ -1,11 +1,12 @@
/**
* Admin Users Page
* Manage users with LDAP-aware editing
* Manage users with LDAP-aware editing and Office assignment
*/
let currentUser = null;
let users = [];
let managers = [];
let offices = [];
let currentSort = { column: 'name', direction: 'asc' };
document.addEventListener('DOMContentLoaded', async () => {
currentUser = await api.requireAuth();
@@ -16,15 +17,15 @@ document.addEventListener('DOMContentLoaded', async () => {
return;
}
await loadManagers();
await loadOffices();
await loadUsers();
setupEventListeners();
});
async function loadManagers() {
const response = await api.get('/api/managers');
async function loadOffices() {
const response = await api.get('/api/offices');
if (response && response.ok) {
managers = await response.json();
offices = await response.json();
}
}
@@ -46,30 +47,60 @@ function renderUsers(filter = '') {
(u.name || '').toLowerCase().includes(filterLower) ||
(u.email || '').toLowerCase().includes(filterLower) ||
(u.role || '').toLowerCase().includes(filterLower) ||
(u.manager_name || '').toLowerCase().includes(filterLower)
(u.office_name || '').toLowerCase().includes(filterLower)
);
}
// Sort
filtered.sort((a, b) => {
let valA = a[currentSort.column];
let valB = b[currentSort.column];
// Handle nulls for ratio
if (currentSort.column === 'parking_ratio') {
valA = valA !== null ? valA : 999; // Null ratio (new users) -> low priority? No, new users have ratio 0.
// Actually get_user_parking_ratio returns 0.0 for new users.
// If office_id is missing, it's None. Treat as high val to push to bottom?
valA = (valA === undefined || valA === null) ? 999 : valA;
valB = (valB === undefined || valB === null) ? 999 : valB;
} else {
valA = (valA || '').toString().toLowerCase();
valB = (valB || '').toString().toLowerCase();
}
if (valA < valB) return currentSort.direction === 'asc' ? -1 : 1;
if (valA > valB) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
// Update icons
document.querySelectorAll('th.sortable .sort-icon').forEach(icon => icon.textContent = '');
const activeTh = document.querySelector(`th[data-sort="${currentSort.column}"]`);
if (activeTh) {
const icon = activeTh.querySelector('.sort-icon');
if (icon) icon.textContent = currentSort.direction === 'asc' ? ' ▲' : ' ▼';
}
if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center">No users found</td></tr>';
tbody.innerHTML = '<tr><td colspan="5" class="text-center">Nessun utente trovato</td></tr>';
return;
}
tbody.innerHTML = filtered.map(user => {
const ldapBadge = user.is_ldap_user ? '<span class="badge badge-info">LDAP</span>' : '';
const managerInfo = user.role === 'manager'
? `<span class="badge badge-success">${user.managed_user_count || 0} users</span>`
: (user.manager_name || '-');
const officeInfo = user.office_name || '-';
return `
<tr>
<td>${user.name || '-'} ${ldapBadge}</td>
<td>${user.email}</td>
<td><span class="badge badge-${getRoleBadgeClass(user.role)}">${user.role}</span></td>
<td>${managerInfo}</td>
<td>${officeInfo}</td>
<td>${user.parking_ratio !== null ? user.parking_ratio.toFixed(2) : '-'}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="editUser('${user.id}')">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')" ${user.id === currentUser.id ? 'disabled' : ''}>Delete</button>
<button class="btn btn-sm btn-secondary" onclick="editUser('${user.id}')">Modifica</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')" ${user.id === currentUser.id ? 'disabled' : ''}>Elimina</button>
</td>
</tr>
`;
@@ -93,20 +124,16 @@ async function editUser(userId) {
document.getElementById('editName').value = user.name || '';
document.getElementById('editEmail').value = user.email;
document.getElementById('editRole').value = user.role;
document.getElementById('editQuota').value = user.manager_parking_quota || 0;
document.getElementById('editPrefix').value = user.manager_spot_prefix || '';
// Populate manager dropdown
const managerSelect = document.getElementById('editManager');
managerSelect.innerHTML = '<option value="">No manager</option>';
managers.forEach(m => {
if (m.id !== userId) { // Can't be own manager
const option = document.createElement('option');
option.value = m.id;
option.textContent = m.name;
if (m.id === user.manager_id) option.selected = true;
managerSelect.appendChild(option);
}
// Populate office dropdown
const officeSelect = document.getElementById('editOffice');
officeSelect.innerHTML = '<option value="">Nessun ufficio</option>';
offices.forEach(o => {
const option = document.createElement('option');
option.value = o.id;
option.textContent = o.name;
if (o.id === user.office_id) option.selected = true;
officeSelect.appendChild(option);
});
// Handle LDAP restrictions
@@ -126,13 +153,7 @@ async function editUser(userId) {
roleSelect.disabled = isLdapAdmin;
document.getElementById('roleHelp').style.display = isLdapAdmin ? 'block' : 'none';
// Manager group - show for all users (admins can also be assigned to a manager)
document.getElementById('managerGroup').style.display = 'block';
// Manager fields - show only for managers
document.getElementById('managerFields').style.display = user.role === 'manager' ? 'block' : 'none';
document.getElementById('userModalTitle').textContent = 'Edit User';
document.getElementById('userModalTitle').textContent = 'Modifica Utente';
document.getElementById('userModal').style.display = 'flex';
}
@@ -140,15 +161,15 @@ async function deleteUser(userId) {
const user = users.find(u => u.id === userId);
if (!user) return;
if (!confirm(`Delete user "${user.name || user.email}"?`)) return;
if (!confirm(`Eliminare l'utente "${user.name || user.email}"?`)) return;
const response = await api.delete(`/api/users/${userId}`);
if (response && response.ok) {
utils.showMessage('User deleted', 'success');
utils.showMessage('Utente eliminato', 'success');
await loadUsers();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to delete user', 'error');
utils.showMessage(error.detail || 'Impossibile eliminare l\'utente', 'error');
}
}
@@ -158,13 +179,6 @@ function setupEventListeners() {
renderUsers(e.target.value);
});
// Role change - toggle manager fields (manager group always visible since any user can have a manager)
document.getElementById('editRole').addEventListener('change', (e) => {
const role = e.target.value;
document.getElementById('managerFields').style.display = role === 'manager' ? 'block' : 'none';
// Manager group stays visible - any user (including admins) can have a manager assigned
});
// Modal close
document.getElementById('closeUserModal').addEventListener('click', () => {
document.getElementById('userModal').style.display = 'none';
@@ -183,7 +197,7 @@ function setupEventListeners() {
const data = {
role: role,
manager_id: document.getElementById('editManager').value || null
office_id: document.getElementById('editOffice').value || null
};
// Only include name if not disabled (LDAP users can't change name)
@@ -192,23 +206,31 @@ function setupEventListeners() {
data.name = nameInput.value;
}
// Manager-specific fields
if (role === 'manager') {
data.manager_parking_quota = parseInt(document.getElementById('editQuota').value) || 0;
data.manager_spot_prefix = document.getElementById('editPrefix').value || null;
}
const response = await api.put(`/api/users/${userId}`, data);
if (response && response.ok) {
document.getElementById('userModal').style.display = 'none';
utils.showMessage('User updated', 'success');
await loadManagers(); // Reload in case role changed
utils.showMessage('Utente aggiornato', 'success');
await loadUsers();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to update user', 'error');
utils.showMessage(error.detail || 'Impossibile aggiornare l\'utente', 'error');
}
});
// Sort headers
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const column = th.dataset.sort;
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
}
renderUsers(document.getElementById('searchInput').value);
});
});
}
// Make functions available globally for onclick handlers

View File

@@ -158,7 +158,7 @@ const api = {
}
const error = await response.json();
return { success: false, error: error.detail || 'Login failed' };
return { success: false, error: error.detail || 'Login fallito' };
},
/**
@@ -178,7 +178,7 @@ const api = {
}
const error = await response.json();
return { success: false, error: error.detail || 'Registration failed' };
return { success: false, error: error.detail || 'Registrazione fallita' };
},
/**

188
frontend/js/modal-logic.js Normal file
View File

@@ -0,0 +1,188 @@
/**
* Shared logic for the Day/Presence Modal
* Handles UI interactions, assignments, and integrated reassign form.
*/
const ModalLogic = {
// Configuration holding callbacks
config: {
onMarkPresence: async (status, date, userId) => { },
onClearPresence: async (date, userId) => { },
onReleaseParking: async (assignmentId) => { },
onReassignParking: async (assignmentId, newUserId) => { },
onReload: async () => { } // Callback to reload calendar data
},
// State
currentAssignmentId: null,
currentDate: null,
currentUserId: null,
init(config) {
this.config = { ...this.config, ...config };
this.setupEventListeners();
},
setupEventListeners() {
// Close buttons
document.getElementById('closeDayModal')?.addEventListener('click', () => {
document.getElementById('dayModal').style.display = 'none';
});
// Status buttons
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (this.currentDate) {
this.config.onMarkPresence(btn.dataset.status, this.currentDate, this.currentUserId);
}
});
});
// Actions
document.getElementById('clearDayBtn')?.addEventListener('click', () => {
if (this.currentDate) {
this.config.onClearPresence(this.currentDate, this.currentUserId);
}
});
document.getElementById('releaseParkingBtn')?.addEventListener('click', () => {
if (this.currentAssignmentId) {
this.config.onReleaseParking(this.currentAssignmentId);
}
});
document.getElementById('reassignParkingBtn')?.addEventListener('click', () => {
this.showReassignForm();
});
// Reassign Form
document.getElementById('cancelReassign')?.addEventListener('click', () => {
this.hideReassignForm();
});
document.getElementById('confirmReassign')?.addEventListener('click', () => {
const select = document.getElementById('reassignUser');
this.config.onReassignParking(this.currentAssignmentId, select.value);
});
// Close on click outside
window.addEventListener('click', (e) => {
const modal = document.getElementById('dayModal');
if (e.target === modal) {
modal.style.display = 'none';
}
});
},
openModal(data) {
const { dateStr, userName, presence, parking, userId, isReadOnly } = data;
this.currentDate = dateStr;
this.currentUserId = userId; // Optional, for team view
this.currentAssignmentId = parking ? parking.id : null;
const modal = document.getElementById('dayModal');
const title = document.getElementById('dayModalTitle');
const userLabel = document.getElementById('dayModalUser');
title.textContent = utils.formatDateDisplay(dateStr);
// Show/Hide User Name (for Team Calendar)
if (userName && userLabel) {
userLabel.textContent = userName;
userLabel.style.display = 'block';
} else if (userLabel) {
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';
} else {
clearBtn.style.display = 'none';
}
// Parking Section & Reset Form
this.hideReassignForm(); // Reset view to actions
const parkingSection = document.getElementById('parkingSection');
const parkingInfo = document.getElementById('parkingInfo');
if (parking) {
parkingSection.style.display = 'block';
const spotName = parking.spot_display_name || parking.spot_id;
parkingInfo.innerHTML = `<strong>Parcheggio:</strong> Posto ${spotName}`;
} else {
parkingSection.style.display = 'none';
}
modal.style.display = 'flex';
},
async showReassignForm() {
if (!this.currentAssignmentId) return;
const actionsDiv = document.getElementById('parkingActions');
const formDiv = document.getElementById('reassignForm');
actionsDiv.style.display = 'none';
formDiv.style.display = 'flex';
const select = document.getElementById('reassignUser');
select.innerHTML = '<option value="">Caricamento utenti...</option>';
// Load eligible users (Global API function assumed available)
try {
const response = await api.get(`/api/parking/eligible-users/${this.currentAssignmentId}`);
if (!response || !response.ok) {
const error = await response.json();
alert(error.detail || 'Impossibile caricare gli utenti idonei');
this.hideReassignForm();
return;
}
const users = await response.json();
select.innerHTML = '<option value="">Seleziona utente...</option>';
select.innerHTML += '<option value="auto">Assegna automaticamente</option>';
if (users.length === 0) {
const option = document.createElement('option');
option.disabled = true;
option.textContent = "Nessun altro utente disponibile";
select.appendChild(option);
} else {
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
const officeInfo = user.office_name ? ` (${user.office_name})` : '';
option.textContent = user.name + officeInfo;
select.appendChild(option);
});
}
} catch (e) {
console.error(e);
alert('Errore di rete');
this.hideReassignForm();
}
},
hideReassignForm() {
document.getElementById('reassignForm').style.display = 'none';
document.getElementById('parkingActions').style.display = 'flex';
},
closeModal() {
document.getElementById('dayModal').style.display = 'none';
}
};

View File

@@ -38,14 +38,20 @@ const ICONS = {
<rect x="13.5" y="11" width="1.5" height="1.5" fill="currentColor"></rect>
<rect x="9" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
<rect x="13.5" y="16" width="1.5" height="1.5" fill="currentColor"></rect>
</svg>`,
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>`
};
const NAV_ITEMS = [
{ href: '/presence', icon: 'calendar', label: 'My Presence' },
{ href: '/team-calendar', icon: 'users', label: 'Team Calendar' },
{ href: '/team-rules', icon: 'rules', label: 'Team Rules', roles: ['admin', 'manager'] },
{ href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] }
{ href: '/presence', icon: 'calendar', label: 'La mia presenza' },
{ 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'] }
];
function getIcon(name) {
@@ -108,7 +114,7 @@ function setupMobileMenu() {
const menuToggle = document.createElement('button');
menuToggle.className = 'menu-toggle';
menuToggle.innerHTML = MENU_ICON;
menuToggle.setAttribute('aria-label', 'Toggle menu');
menuToggle.setAttribute('aria-label', 'Apri/Chiudi menu');
pageHeader.insertBefore(menuToggle, pageHeader.firstChild);
// Add overlay

View File

@@ -0,0 +1,226 @@
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
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 = current.toISOString().split('T')[0];
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');
while (current <= end) {
const dateStr = current.toISOString().split('T')[0];
try {
const res = await api.post('/api/parking/clear-assignments', {
date: dateStr,
office_id: currentOffice.id
});
totalRemoved += (res.count || 0);
} catch (e) {
console.error(`Error clearing ${dateStr}`, e);
}
current.setDate(current.getDate() + 1);
}
utils.showMessage(`Operazione eseguita. ${totalRemoved} assegnazioni rimosse in totale.`, 'warning');
});
}

View File

@@ -8,14 +8,31 @@ let currentDate = new Date();
let presenceData = {};
let parkingData = {};
let currentAssignmentId = null;
let weeklyClosingDays = [];
let specificClosingDays = [];
let statusDate = new Date();
let statusViewMode = 'daily';
document.addEventListener('DOMContentLoaded', async () => {
currentUser = await api.requireAuth();
if (!currentUser) return;
await Promise.all([loadPresences(), loadParkingAssignments()]);
await Promise.all([loadPresences(), loadParkingAssignments(), loadClosingDays()]);
// Initialize Modal Logic
ModalLogic.init({
onMarkPresence: handleMarkPresence,
onClearPresence: handleClearPresence,
onReleaseParking: handleReleaseParking,
onReassignParking: handleReassignParking
});
renderCalendar();
setupEventListeners();
// Initialize Parking Status
initParkingStatus();
setupStatusListeners();
});
async function loadPresences() {
@@ -56,10 +73,32 @@ async function loadParkingAssignments() {
}
}
async function loadClosingDays() {
if (!currentUser.office_id) return;
try {
const [weeklyRes, specificRes] = await Promise.all([
api.get(`/api/offices/${currentUser.office_id}/weekly-closing-days`),
api.get(`/api/offices/${currentUser.office_id}/closing-days`)
]);
if (weeklyRes && weeklyRes.ok) {
const days = await weeklyRes.json();
weeklyClosingDays = days.map(d => d.weekday);
}
if (specificRes && specificRes.ok) {
specificClosingDays = await specificRes.json();
}
} catch (e) {
console.error('Error loading closing days:', e);
}
}
function renderCalendar() {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const weekStartDay = currentUser.week_start_day || 0; // 0=Sunday, 1=Monday
const weekStartDay = currentUser.week_start_day || 1; // 0=Sunday, 1=Monday (default to Monday)
// Update month header
document.getElementById('currentMonth').textContent = `${utils.getMonthName(month)} ${year}`;
@@ -78,7 +117,7 @@ function renderCalendar() {
grid.innerHTML = '';
// Day headers - reorder based on week start day
const allDayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const allDayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
const dayNames = [];
for (let i = 0; i < 7; i++) {
dayNames.push(allDayNames[(weekStartDay + i) % 7]);
@@ -120,7 +159,25 @@ function renderCalendar() {
if (isHoliday) cell.classList.add('holiday');
if (isToday) cell.classList.add('today');
if (presence) {
// Check closing days
// Note: JS getDay(): 0=Sunday, 1=Monday...
// DB WeekDay: 0=Sunday, etc. (They match)
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;
// Reset times for strict date comparison
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return date >= start && date <= end;
});
const isClosed = isWeeklyClosed || isSpecificClosed;
if (isClosed) {
cell.classList.add('closed');
cell.title = "Ufficio Chiuso";
} else if (presence) {
cell.classList.add(`status-${presence.status}`);
}
@@ -134,140 +191,60 @@ function renderCalendar() {
${parkingBadge}
`;
cell.addEventListener('click', () => openDayModal(dateStr, presence, parking));
if (!isClosed) {
cell.addEventListener('click', () => openDayModal(dateStr, presence, parking));
}
grid.appendChild(cell);
}
}
function openDayModal(dateStr, presence, parking) {
const modal = document.getElementById('dayModal');
const title = document.getElementById('dayModalTitle');
title.textContent = utils.formatDateDisplay(dateStr);
// Highlight current status
document.querySelectorAll('.status-btn').forEach(btn => {
const status = btn.dataset.status;
if (presence && presence.status === status) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
ModalLogic.openModal({
dateStr,
presence,
parking
});
// Update parking section
const parkingSection = document.getElementById('parkingSection');
const parkingInfo = document.getElementById('parkingInfo');
const releaseBtn = document.getElementById('releaseParkingBtn');
if (parking) {
parkingSection.style.display = 'block';
const spotName = parking.spot_display_name || parking.spot_id;
parkingInfo.innerHTML = `<strong>Parking:</strong> Spot ${spotName}`;
releaseBtn.dataset.assignmentId = parking.id;
document.getElementById('reassignParkingBtn').dataset.assignmentId = parking.id;
currentAssignmentId = parking.id;
} else {
parkingSection.style.display = 'none';
}
modal.dataset.date = dateStr;
modal.style.display = 'flex';
}
async function markPresence(status) {
const modal = document.getElementById('dayModal');
const date = modal.dataset.date;
async function handleMarkPresence(status, date) {
const response = await api.post('/api/presence/mark', { date, status });
if (response && response.ok) {
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
modal.style.display = 'none';
ModalLogic.closeModal();
} else {
const error = await response.json();
alert(error.detail || 'Failed to mark presence');
alert(error.detail || 'Impossibile segnare la presenza');
}
}
async function clearPresence() {
const modal = document.getElementById('dayModal');
const date = modal.dataset.date;
if (!confirm('Clear presence for this date?')) return;
async function handleClearPresence(date) {
const response = await api.delete(`/api/presence/${date}`);
if (response && response.ok) {
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
modal.style.display = 'none';
ModalLogic.closeModal();
}
}
async function releaseParking() {
const modal = document.getElementById('dayModal');
const releaseBtn = document.getElementById('releaseParkingBtn');
const assignmentId = releaseBtn.dataset.assignmentId;
if (!assignmentId) return;
if (!confirm('Release your parking spot for this date?')) return;
async function handleReleaseParking(assignmentId) {
if (!confirm('Rilasciare il parcheggio per questa data?')) return;
const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`);
if (response && response.ok) {
await loadParkingAssignments();
renderCalendar();
modal.style.display = 'none';
ModalLogic.closeModal();
} else {
const error = await response.json();
alert(error.detail || 'Failed to release parking spot');
alert(error.detail || 'Impossibile rilasciare il parcheggio');
}
}
async function openReassignModal() {
const assignmentId = currentAssignmentId;
if (!assignmentId) return;
// Load eligible users
const response = await api.get(`/api/parking/eligible-users/${assignmentId}`);
if (!response || !response.ok) {
const error = await response.json();
alert(error.detail || 'Failed to load eligible users');
return;
}
const users = await response.json();
const select = document.getElementById('reassignUser');
select.innerHTML = '<option value="">Select user...</option>';
if (users.length === 0) {
select.innerHTML = '<option value="">No eligible users available</option>';
} else {
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = user.name;
select.appendChild(option);
});
}
// Get spot info from parking data
const parking = Object.values(parkingData).find(p => p.id === assignmentId);
if (parking) {
const spotName = parking.spot_display_name || parking.spot_id;
document.getElementById('reassignSpotInfo').textContent = `Spot ${spotName}`;
}
document.getElementById('dayModal').style.display = 'none';
document.getElementById('reassignModal').style.display = 'flex';
}
async function confirmReassign() {
const assignmentId = currentAssignmentId;
const newUserId = document.getElementById('reassignUser').value;
async function handleReassignParking(assignmentId, newUserId) {
// Basic validation handled by select; confirm
if (!assignmentId || !newUserId) {
alert('Please select a user');
alert('Seleziona un utente');
return;
}
@@ -279,13 +256,15 @@ async function confirmReassign() {
if (response && response.ok) {
await loadParkingAssignments();
renderCalendar();
document.getElementById('reassignModal').style.display = 'none';
ModalLogic.closeModal();
} else {
const error = await response.json();
alert(error.detail || 'Failed to reassign parking spot');
alert(error.detail || 'Impossibile riassegnare il parcheggio');
}
}
function setupEventListeners() {
// Month navigation
document.getElementById('prevMonth').addEventListener('click', async () => {
@@ -300,69 +279,255 @@ function setupEventListeners() {
renderCalendar();
});
// Day modal
document.getElementById('closeDayModal').addEventListener('click', () => {
document.getElementById('dayModal').style.display = 'none';
// Quick Entry Logic
const quickEntryModal = document.getElementById('quickEntryModal');
const quickEntryBtn = document.getElementById('quickEntryBtn');
const closeQuickEntryBtn = document.getElementById('closeQuickEntryModal');
const cancelQuickEntryBtn = document.getElementById('cancelQuickEntry');
const quickEntryForm = document.getElementById('quickEntryForm');
if (quickEntryBtn) {
quickEntryBtn.addEventListener('click', () => {
// Default dates: tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
document.getElementById('qeStartDate').valueAsDate = tomorrow;
document.getElementById('qeEndDate').valueAsDate = tomorrow;
document.getElementById('qeStatus').value = '';
// Clear selections
document.querySelectorAll('.qe-status-btn').forEach(btn => btn.classList.remove('active'));
quickEntryModal.style.display = 'flex';
});
}
if (closeQuickEntryBtn) closeQuickEntryBtn.addEventListener('click', () => quickEntryModal.style.display = 'none');
if (cancelQuickEntryBtn) cancelQuickEntryBtn.addEventListener('click', () => quickEntryModal.style.display = 'none');
// Status selection in QE
document.querySelectorAll('.qe-status-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.qe-status-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('qeStatus').value = btn.dataset.status;
});
});
document.querySelectorAll('.status-btn').forEach(btn => {
btn.addEventListener('click', () => markPresence(btn.dataset.status));
});
if (quickEntryForm) {
quickEntryForm.addEventListener('submit', async (e) => {
e.preventDefault();
document.getElementById('clearDayBtn').addEventListener('click', clearPresence);
document.getElementById('releaseParkingBtn').addEventListener('click', releaseParking);
document.getElementById('reassignParkingBtn').addEventListener('click', openReassignModal);
const startStr = document.getElementById('qeStartDate').value;
const endStr = document.getElementById('qeEndDate').value;
const status = document.getElementById('qeStatus').value;
utils.setupModalClose('dayModal');
if (!status) return utils.showMessage('Seleziona uno stato', 'error');
if (!startStr || !endStr) return utils.showMessage('Seleziona le date', 'error');
// Reassign modal
document.getElementById('closeReassignModal').addEventListener('click', () => {
document.getElementById('reassignModal').style.display = 'none';
});
document.getElementById('cancelReassign').addEventListener('click', () => {
document.getElementById('reassignModal').style.display = 'none';
});
document.getElementById('confirmReassign').addEventListener('click', confirmReassign);
utils.setupModalClose('reassignModal');
const startDate = new Date(startStr);
const endDate = new Date(endStr);
// Bulk mark
document.getElementById('bulkMarkBtn').addEventListener('click', () => {
document.getElementById('bulkMarkModal').style.display = 'flex';
});
if (endDate < startDate) return utils.showMessage('La data di fine non può essere precedente alla data di inizio', 'error');
document.getElementById('closeBulkModal').addEventListener('click', () => {
document.getElementById('bulkMarkModal').style.display = 'none';
});
quickEntryModal.style.display = 'none';
utils.showMessage('Inserimento in corso...', 'warning');
document.getElementById('cancelBulk').addEventListener('click', () => {
document.getElementById('bulkMarkModal').style.display = 'none';
});
const promises = [];
let current = new Date(startDate);
utils.setupModalClose('bulkMarkModal');
while (current <= endDate) {
const dStr = current.toISOString().split('T')[0];
if (status === 'clear') {
promises.push(api.delete(`/api/presence/${dStr}`));
} else {
promises.push(api.post('/api/presence/mark', { date: dStr, status: status }));
}
current.setDate(current.getDate() + 1);
}
document.getElementById('bulkMarkForm').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await Promise.all(promises);
utils.showMessage('Inserimento completato!', 'success');
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
} catch (err) {
console.error(err);
utils.showMessage('Errore durante l\'inserimento. Alcuni giorni potrebbero non essere stati aggiornati.', 'error');
}
});
}
}
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const status = document.getElementById('bulkStatus').value;
const weekdaysOnly = document.getElementById('weekdaysOnly').checked;
// ----------------------------------------------------------------------------
// Parking Status Logic
// ----------------------------------------------------------------------------
const data = { start_date: startDate, end_date: endDate, status };
if (weekdaysOnly) {
data.days = [1, 2, 3, 4, 5]; // Mon-Fri (JS weekday)
}
function initParkingStatus() {
updateStatusHeader();
loadDailyStatus();
const response = await api.post('/api/presence/mark-bulk', data);
// Update office name if available
if (currentUser && currentUser.office_name) {
const nameDisplay = document.getElementById('statusOfficeName');
if (nameDisplay) nameDisplay.textContent = currentUser.office_name;
const headerDisplay = document.getElementById('currentOfficeDisplay');
if (headerDisplay) headerDisplay.textContent = currentUser.office_name;
} else {
const nameDisplay = document.getElementById('statusOfficeName');
if (nameDisplay) nameDisplay.textContent = 'Tuo Ufficio';
const headerDisplay = document.getElementById('currentOfficeDisplay');
if (headerDisplay) headerDisplay.textContent = 'Tuo Ufficio';
}
}
function updateStatusHeader() {
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
const dateStr = statusDate.toLocaleDateString('it-IT', options);
const capitalizedDate = dateStr.charAt(0).toUpperCase() + dateStr.slice(1);
const statusDateDisplay = document.getElementById('statusDateDisplay');
if (statusDateDisplay) statusDateDisplay.textContent = capitalizedDate;
const pickerDateDisplay = document.getElementById('pickerDateDisplay');
if (pickerDateDisplay) pickerDateDisplay.textContent = utils.formatDate(statusDate);
const summaryDateDisplay = document.getElementById('summaryDateDisplay');
if (summaryDateDisplay) summaryDateDisplay.textContent = dateStr;
const picker = document.getElementById('statusDatePicker');
if (picker) {
const yyyy = statusDate.getFullYear();
const mm = String(statusDate.getMonth() + 1).padStart(2, '0');
const dd = String(statusDate.getDate()).padStart(2, '0');
picker.value = `${yyyy}-${mm}-${dd}`;
}
}
async function loadDailyStatus() {
if (!currentUser || !currentUser.office_id) return;
const dateStr = utils.formatDate(statusDate);
const officeId = currentUser.office_id;
const grid = document.getElementById('spotsGrid');
// 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>';
try {
const response = await api.get(`/api/parking/assignments/${dateStr}?office_id=${officeId}`);
if (response && response.ok) {
const results = await response.json();
alert(`Marked ${results.length} dates`);
document.getElementById('bulkMarkModal').style.display = 'none';
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
const assignments = await response.json();
renderParkingStatus(assignments);
} else {
const error = await response.json();
alert(error.detail || 'Failed to bulk mark');
if (grid) grid.innerHTML = '<div style="width:100%; text-align:center; padding:1rem;">Impossibile caricare i dati.</div>';
}
} catch (e) {
console.error("Error loading parking status", e);
if (grid) grid.innerHTML = '<div style="width:100%; text-align:center; padding:1rem;">Errore di caricamento.</div>';
}
}
function renderParkingStatus(assignments) {
const grid = document.getElementById('spotsGrid');
if (!grid) return;
grid.innerHTML = '';
if (!assignments || assignments.length === 0) {
grid.innerHTML = '<div style="width:100%; text-align:center; padding:1rem;">Nessun posto configurato o disponibile.</div>';
const badge = document.getElementById('spotsCountBadge');
if (badge) badge.textContent = `Liberi: 0/0`;
return;
}
// Sort
assignments.sort((a, b) => {
const nameA = a.spot_display_name || a.spot_id;
const nameB = b.spot_display_name || b.spot_id;
return nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: 'base' });
});
let total = assignments.length;
let free = 0;
assignments.forEach(a => {
const isFree = !a.user_id;
if (isFree) free++;
const spotName = a.spot_display_name || a.spot_id;
const statusText = isFree ? 'Libero' : (a.user_name || 'Occupato');
// 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 el = document.createElement('div');
el.className = 'spot-card';
el.style.cssText = `
border: 1px solid ${borderColor};
background: ${bgColor};
border-radius: 8px;
padding: 1rem;
width: 140px;
min-width: 120px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
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="color: ${textColor}; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;" title="${statusText}">
${statusText}
</div>
`;
grid.appendChild(el);
});
const badge = document.getElementById('spotsCountBadge');
if (badge) badge.textContent = `Liberi: ${free}/${total}`;
}
function setupStatusListeners() {
const prevDay = document.getElementById('statusPrevDay');
if (prevDay) prevDay.addEventListener('click', () => {
statusDate.setDate(statusDate.getDate() - 1);
updateStatusHeader();
loadDailyStatus();
});
const nextDay = document.getElementById('statusNextDay');
if (nextDay) nextDay.addEventListener('click', () => {
statusDate.setDate(statusDate.getDate() + 1);
updateStatusHeader();
loadDailyStatus();
});
const datePicker = document.getElementById('statusDatePicker');
if (datePicker) datePicker.addEventListener('change', (e) => {
if (e.target.value) {
statusDate = new Date(e.target.value);
updateStatusHeader();
loadDailyStatus();
}
});
}

View File

@@ -1,72 +1,118 @@
/**
* Team Calendar Page
* Shows presence and parking for all team members
* Filtered by manager (manager-centric model)
* Filtered by office (office-centric model)
*/
let currentUser = null;
let currentStartDate = null;
let viewMode = 'week'; // 'week' or 'month'
let managers = [];
let offices = [];
let teamData = [];
let parkingDataLookup = {};
let parkingAssignmentLookup = {};
let selectedUserId = null;
let selectedDate = null;
let currentAssignmentId = null;
let officeClosingRules = {}; // { officeId: { weekly: [], specific: [] } }
document.addEventListener('DOMContentLoaded', async () => {
currentUser = await api.requireAuth();
if (!currentUser) return;
// Initialize start date based on week start preference
const weekStartDay = currentUser.week_start_day || 0;
const weekStartDay = currentUser.week_start_day || 1;
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
await loadManagers();
await loadOffices();
await loadTeamData();
await loadTeamData();
// Initialize Modal Logic
ModalLogic.init({
onMarkPresence: handleMarkPresence,
onClearPresence: handleClearPresence,
onReleaseParking: handleReleaseParking,
onReassignParking: handleReassignParking
});
renderCalendar();
setupEventListeners();
});
async function loadManagers() {
const response = await api.get('/api/managers');
if (response && response.ok) {
managers = await response.json();
const select = document.getElementById('managerFilter');
function updateOfficeDisplay() {
const display = document.getElementById('currentOfficeNameDisplay');
if (!display) return;
// Filter managers based on user role
let filteredManagers = managers;
const select = document.getElementById('officeFilter');
// If user is employee, show their office name directly
if (currentUser.role === 'employee') {
display.textContent = currentUser.office_name || "Mio Ufficio";
return;
}
// For admin/manager, show selected
if (select && select.value) {
// Find name in options
const option = select.options[select.selectedIndex];
if (option) {
// Remove the count (xx utenti) part if desired, or keep it.
// User requested "nome del'ufficio", let's keep it simple.
// Option text is "Name (Count users)"
// let text = option.textContent.split('(')[0].trim();
display.textContent = option.textContent;
} else {
display.textContent = "Tutti gli Uffici";
}
} else {
display.textContent = "Tutti gli Uffici";
}
}
async function loadOffices() {
const select = document.getElementById('officeFilter');
// Only Admins and Managers can list offices
// Employees will just see their own office logic handled in loadTeamData
// Only Admins can see the office selector
if (currentUser.role !== 'admin') {
select.style.display = 'none';
// Employees stop here, Managers continue to allow auto-selection logic below
if (currentUser.role === 'employee') return;
}
const response = await api.get('/api/offices');
if (response && response.ok) {
offices = await response.json();
let filteredOffices = offices;
if (currentUser.role === 'manager') {
// Manager only sees themselves
filteredManagers = managers.filter(m => m.id === currentUser.id);
} else if (currentUser.role === 'employee') {
// Employee only sees their own manager
if (currentUser.manager_id) {
filteredManagers = managers.filter(m => m.id === currentUser.manager_id);
// 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 {
filteredManagers = [];
filteredOffices = [];
}
}
filteredManagers.forEach(manager => {
filteredOffices.forEach(office => {
const option = document.createElement('option');
option.value = manager.id;
const userCount = manager.managed_user_count || 0;
option.textContent = `${manager.name} (${userCount} users)`;
option.value = office.id;
option.textContent = `${office.name} (${office.user_count || 0} utenti)`;
select.appendChild(option);
});
// Auto-select for managers and employees (they only see their team)
if (filteredManagers.length === 1) {
select.value = filteredManagers[0].id;
}
// Hide manager filter for employees (they can only see their team)
if (currentUser.role === 'employee') {
select.style.display = 'none';
// Auto-select for managers
if (currentUser.role === 'manager' && filteredOffices.length === 1) {
select.value = filteredOffices[0].id;
}
}
// Initial update of office display
updateOfficeDisplay();
}
function getDateRange() {
@@ -85,15 +131,16 @@ function getDateRange() {
}
async function loadTeamData() {
await loadClosingData();
const { startDate, endDate } = getDateRange();
const startStr = utils.formatDate(startDate);
const endStr = utils.formatDate(endDate);
let url = `/api/presence/team?start_date=${startStr}&end_date=${endStr}`;
const managerFilter = document.getElementById('managerFilter').value;
if (managerFilter) {
url += `&manager_id=${managerFilter}`;
const officeFilter = document.getElementById('officeFilter').value;
if (officeFilter) {
url += `&office_id=${officeFilter}`;
}
const response = await api.get(url);
@@ -114,6 +161,68 @@ async function loadTeamData() {
}
}
async function loadClosingData() {
officeClosingRules = {};
let officeIdsToLoad = [];
const selectedOfficeId = document.getElementById('officeFilter').value;
if (selectedOfficeId) {
officeIdsToLoad = [selectedOfficeId];
} else if (currentUser.role === 'employee' || (currentUser.role === 'manager' && currentUser.office_id)) {
officeIdsToLoad = [currentUser.office_id];
} else if (offices.length > 0) {
// Admin viewing all or Manager with access to list
officeIdsToLoad = offices.map(o => o.id);
}
if (officeIdsToLoad.length === 0) return;
// Fetch in parallel
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`)
]);
officeClosingRules[oid] = { weekly: [], specific: [] };
if (weeklyRes && weeklyRes.ok) {
const days = await weeklyRes.json();
officeClosingRules[oid].weekly = days.map(d => d.weekday);
}
if (specificRes && specificRes.ok) {
officeClosingRules[oid].specific = await specificRes.json();
// OPTIMIZATION: Pre-calculate all specific closed dates into a Set
const closedSet = new Set();
officeClosingRules[oid].specific.forEach(range => {
let start = new Date(range.date);
let end = range.end_date ? new Date(range.end_date) : new Date(range.date);
// Normalize to noon to avoid timezone issues when stepping
start.setHours(12, 0, 0, 0);
end.setHours(12, 0, 0, 0);
let current = new Date(start);
while (current <= end) {
closedSet.add(utils.formatDate(current));
current.setDate(current.getDate() + 1);
}
});
officeClosingRules[oid].closedDatesSet = closedSet;
}
} catch (e) {
console.error(`Error loading closing days for office ${oid}:`, e);
}
});
await Promise.all(promises);
}
function renderCalendar() {
const header = document.getElementById('calendarHeader');
const body = document.getElementById('calendarBody');
@@ -132,8 +241,8 @@ function renderCalendar() {
const dayCount = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
// Build header row
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
let headerHtml = '<th>Name</th><th>Manager</th>';
const dayNames = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
let headerHtml = '<th>Nome</th><th>Ufficio</th>';
for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate);
@@ -141,13 +250,19 @@ function renderCalendar() {
const dayOfWeek = date.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isHoliday = utils.isItalianHoliday(date);
const isToday = date.toDateString() === new Date().toDateString();
const isToday = utils.formatDate(date) === utils.formatDate(new Date());
let classes = [];
if (isWeekend) classes.push('weekend');
if (isHoliday) classes.push('holiday');
if (isToday) classes.push('today');
if (isToday) classes.push('today');
// Header doesn't show closed status in multi-office view
// unless we want to check if ALL are closed?
// For now, simpler to leave header clean.
headerHtml += `<th class="${classes.join(' ')}">
<div>${dayNames[dayOfWeek].charAt(0)}</div>
<div class="day-number">${date.getDate()}</div>
@@ -157,7 +272,7 @@ function renderCalendar() {
// Build body rows
if (teamData.length === 0) {
body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">No team members found</td></tr>`;
body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">Nessun membro del team trovato</td></tr>`;
return;
}
@@ -165,7 +280,7 @@ function renderCalendar() {
teamData.forEach(member => {
bodyHtml += `<tr>
<td class="member-name">${member.name || 'Unknown'}</td>
<td class="member-manager">${member.manager_name || '-'}</td>`;
<td class="member-manager">${member.office_name || '-'}</td>`;
for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate);
@@ -179,7 +294,7 @@ function renderCalendar() {
const parkingKey = `${member.id}_${dateStr}`;
const parkingSpot = parkingDataLookup[parkingKey];
const hasParking = member.parking_dates && member.parking_dates.includes(dateStr);
const isToday = date.toDateString() === new Date().toDateString();
const isToday = dateStr === utils.formatDate(new Date());
let cellClasses = ['calendar-cell'];
if (isWeekend) cellClasses.push('weekend');
@@ -187,6 +302,47 @@ function renderCalendar() {
if (isToday) cellClasses.push('today');
if (presence) cellClasses.push(`status-${presence.status}`);
if (isToday) cellClasses.push('today');
if (presence) cellClasses.push(`status-${presence.status}`);
// Optimized closing day check
// Pre-calculate loop-invariant sets outside if not already done, but here we do it per-cell because of date dependency?
// BETTER: We should pre-calculate a "closedMap" for the viewed range for each office?
// OR: Just optimize the inner check.
// Optimization: Create a lookup string for the current date once
// (Already have dateStr)
const memberRules = officeClosingRules[member.office_id];
let isClosed = false;
if (memberRules) {
// Check weekly
if (memberRules.weekly.includes(dayOfWeek)) {
isClosed = true;
} else if (memberRules.specific && memberRules.specific.length > 0) {
// Check specific
// Optimization: Use the string date lookup if we had a Set, but we have ranges.
// We can optimize by converting ranges to Sets ONCE when loading data,
// OR just stick to this check if N is small.
// Given the "optimization" task, let's just make sure we don't do new Date() inside.
// The `specific` array contains objects with `date` and `end_date` strings.
// We can compare strings directly if format is YYYY-MM-DD and we are careful.
// Optimization: check if dateStr is in a Set of closed dates for this office?
// Let's implement the Set lookup logic in `loadClosingData` or `renderCalendar` start.
// For now, let's assume `memberRules.closedDatesSet` exists.
if (memberRules.closedDatesSet && memberRules.closedDatesSet.has(dateStr)) {
isClosed = true;
}
}
}
if (isClosed) {
cellClasses.push('closed');
}
// Show parking badge instead of just 'P'
let parkingBadge = '';
if (hasParking) {
@@ -194,7 +350,7 @@ function renderCalendar() {
parkingBadge = `<span class="parking-badge-sm">${spotName}</span>`;
}
bodyHtml += `<td class="${cellClasses.join(' ')}" data-user-id="${member.id}" data-date="${dateStr}" data-user-name="${member.name || ''}">${parkingBadge}</td>`;
bodyHtml += `<td class="${cellClasses.join(' ')}" data-user-id="${member.id}" data-date="${dateStr}" data-user-name="${member.name || ''}" ${isClosed ? 'data-closed="true"' : ''}>${parkingBadge}</td>`;
}
bodyHtml += '</tr>';
@@ -205,12 +361,14 @@ function renderCalendar() {
if (currentUser.role === 'admin' || currentUser.role === 'manager') {
body.querySelectorAll('.calendar-cell').forEach(cell => {
cell.style.cursor = 'pointer';
cell.addEventListener('click', () => {
const userId = cell.dataset.userId;
const date = cell.dataset.date;
const userName = cell.dataset.userName;
openDayModal(userId, date, userName);
});
if (cell.dataset.closed !== 'true') {
cell.addEventListener('click', () => {
const userId = cell.dataset.userId;
const date = cell.dataset.date;
const userName = cell.dataset.userName;
openDayModal(userId, date, userName);
});
}
});
}
}
@@ -219,129 +377,107 @@ function openDayModal(userId, dateStr, userName) {
selectedUserId = userId;
selectedDate = dateStr;
const modal = document.getElementById('dayModal');
document.getElementById('dayModalTitle').textContent = utils.formatDateDisplay(dateStr);
document.getElementById('dayModalUser').textContent = userName;
// Find current status and parking
const member = teamData.find(m => m.id === userId);
const presence = member?.presences.find(p => p.date === dateStr);
const parkingKey = `${userId}_${dateStr}`;
const parkingSpot = parkingDataLookup[parkingKey];
const assignmentId = parkingAssignmentLookup[parkingKey];
currentAssignmentId = assignmentId; // Ensure this is set for modal logic
// Highlight current 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');
}
const parkingObj = assignmentId ? {
id: assignmentId,
spot_display_name: parkingSpot,
spot_id: parkingSpot
} : null;
ModalLogic.openModal({
dateStr,
userName,
presence,
parking: parkingObj,
userId
});
// Update parking section
const parkingSection = document.getElementById('parkingSection');
const parkingInfo = document.getElementById('parkingInfo');
if (parkingSpot) {
parkingSection.style.display = 'block';
parkingInfo.innerHTML = `<strong>Parking:</strong> Spot ${parkingSpot}`;
currentAssignmentId = assignmentId;
} else {
parkingSection.style.display = 'none';
currentAssignmentId = null;
}
modal.style.display = 'flex';
}
async function markPresence(status) {
if (!selectedUserId || !selectedDate) return;
async function handleMarkPresence(status, date, userId) {
// userId passed from ModalLogic if provided, or use selectedUserId
const targetUserId = userId || selectedUserId;
if (!targetUserId) return;
const response = await api.post('/api/presence/admin/mark', {
user_id: selectedUserId,
date: selectedDate,
user_id: targetUserId,
date: date,
status: status
});
if (response && response.ok) {
document.getElementById('dayModal').style.display = 'none';
ModalLogic.closeModal();
await loadTeamData();
renderCalendar();
} else {
const error = await response.json();
alert(error.detail || 'Failed to mark presence');
alert(error.detail || 'Impossibile segnare la presenza');
}
}
async function clearPresence() {
if (!selectedUserId || !selectedDate) return;
if (!confirm('Clear presence for this date?')) return;
async function handleClearPresence(date, userId) {
const targetUserId = userId || selectedUserId;
if (!targetUserId) return;
const response = await api.delete(`/api/presence/admin/${selectedUserId}/${selectedDate}`);
// confirm is not needed here if ModalLogic doesn't mandate it, but keeping logic
// ModalLogic buttons usually trigger this directly.
const response = await api.delete(`/api/presence/admin/${targetUserId}/${date}`);
if (response && response.ok) {
document.getElementById('dayModal').style.display = 'none';
ModalLogic.closeModal();
await loadTeamData();
renderCalendar();
}
}
async function openReassignModal() {
if (!currentAssignmentId) return;
async function handleReleaseParking(assignmentId) {
if (!confirm('Rilasciare il parcheggio per questa data?')) return;
// Load eligible users
const response = await api.get(`/api/parking/eligible-users/${currentAssignmentId}`);
if (!response || !response.ok) {
const error = await response.json();
alert(error.detail || 'Failed to load eligible users');
return;
}
// Note: Admin endpoint for releasing ANY spot vs "my spot"
// Since we are admin/manager here, we might need a general release endpoint or use reassign with null?
// The current 'release_my_spot' is only for self.
// 'reassign_spot' with null user_id is the way for admins.
const users = await response.json();
const select = document.getElementById('reassignUser');
select.innerHTML = '<option value="">Select user...</option>';
const response = await api.post('/api/parking/reassign-spot', {
assignment_id: assignmentId,
new_user_id: null // Release
});
if (users.length === 0) {
select.innerHTML = '<option value="">No eligible users available</option>';
if (response && response.ok) {
ModalLogic.closeModal();
await loadTeamData();
renderCalendar();
} else {
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = user.name;
select.appendChild(option);
});
const error = await response.json();
alert(error.detail || 'Impossibile rilasciare il parcheggio');
}
// Get spot info
const parkingKey = `${selectedUserId}_${selectedDate}`;
const spotName = parkingDataLookup[parkingKey] || 'Unknown';
document.getElementById('reassignSpotInfo').textContent = `Spot ${spotName}`;
document.getElementById('dayModal').style.display = 'none';
document.getElementById('reassignModal').style.display = 'flex';
}
async function confirmReassign() {
const newUserId = document.getElementById('reassignUser').value;
if (!currentAssignmentId || !newUserId) {
alert('Please select a user');
async function handleReassignParking(assignmentId, newUserId) {
if (!assignmentId || !newUserId) {
alert('Seleziona un utente');
return;
}
const response = await api.post('/api/parking/reassign-spot', {
assignment_id: currentAssignmentId,
assignment_id: assignmentId,
new_user_id: newUserId
});
if (response && response.ok) {
await loadTeamData();
renderCalendar();
document.getElementById('reassignModal').style.display = 'none';
ModalLogic.closeModal();
} else {
const error = await response.json();
alert(error.detail || 'Failed to reassign parking spot');
alert(error.detail || 'Impossibile riassegnare il parcheggio');
}
}
@@ -375,40 +511,20 @@ function setupEventListeners() {
currentStartDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1);
} else {
// Set to current week start
const weekStartDay = currentUser.week_start_day || 0;
const weekStartDay = currentUser.week_start_day || 1;
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
}
await loadTeamData();
renderCalendar();
});
// Manager filter
document.getElementById('managerFilter').addEventListener('change', async () => {
// Office filter
document.getElementById('officeFilter').addEventListener('change', async () => {
updateOfficeDisplay(); // Update label on change
await loadTeamData();
renderCalendar();
});
// Day modal
document.getElementById('closeDayModal').addEventListener('click', () => {
document.getElementById('dayModal').style.display = 'none';
});
document.querySelectorAll('#dayModal .status-btn').forEach(btn => {
btn.addEventListener('click', () => markPresence(btn.dataset.status));
});
document.getElementById('clearDayBtn').addEventListener('click', clearPresence);
document.getElementById('reassignParkingBtn').addEventListener('click', openReassignModal);
utils.setupModalClose('dayModal');
// Reassign modal
document.getElementById('closeReassignModal').addEventListener('click', () => {
document.getElementById('reassignModal').style.display = 'none';
});
document.getElementById('cancelReassign').addEventListener('click', () => {
document.getElementById('reassignModal').style.display = 'none';
});
document.getElementById('confirmReassign').addEventListener('click', confirmReassign);
utils.setupModalClose('reassignModal');
}

View File

@@ -1,370 +1,383 @@
/**
* Team Rules Page
* Manage closing days, parking guarantees, and exclusions
*
* Rules are set at manager level for their parking pool.
* Manage closing days, guarantees, and exclusions
* Office-centric model
*/
let currentUser = null;
let selectedManagerId = null;
let managerUsers = [];
let offices = [];
let currentOfficeId = null;
let officeUsers = [];
let currentWeeklyClosingDays = [];
document.addEventListener('DOMContentLoaded', async () => {
currentUser = await api.requireAuth();
if (!currentUser) return;
// Only managers and admins can access
if (currentUser.role === 'employee') {
// Only admins and managers can access this page
if (currentUser.role !== 'admin' && currentUser.role !== 'manager') {
window.location.href = '/presence';
return;
}
await loadManagers();
await loadOffices();
setupEventListeners();
});
async function loadManagers() {
const response = await api.get('/api/managers');
if (response && response.ok) {
const managers = await response.json();
const select = document.getElementById('managerSelect');
async function loadOffices() {
const select = document.getElementById('officeSelect');
const card = document.getElementById('officeSelectionCard');
// Filter to managers this user can see
let filteredManagers = managers;
// Only Admins can see the office selector
if (currentUser.role !== 'admin') {
if (card) card.style.display = 'none';
}
const response = await api.get('/api/offices');
if (response && response.ok) {
offices = await response.json();
let filteredOffices = offices;
if (currentUser.role === 'manager') {
// Manager only sees themselves
filteredManagers = managers.filter(m => m.id === currentUser.id);
// Manager only sees their own office
if (currentUser.office_id) {
filteredOffices = offices.filter(o => o.id === currentUser.office_id);
} else {
filteredOffices = [];
}
}
// Show managers in dropdown
let totalManagers = 0;
let firstManagerId = null;
filteredManagers.forEach(manager => {
filteredOffices.forEach(office => {
const option = document.createElement('option');
option.value = manager.id;
// Show manager name with user count and parking quota
const userCount = manager.managed_user_count || 0;
const quota = manager.parking_quota || 0;
option.textContent = `${manager.name} (${userCount} users, ${quota} spots)`;
option.value = office.id;
option.textContent = office.name;
select.appendChild(option);
totalManagers++;
if (!firstManagerId) firstManagerId = manager.id;
});
// Auto-select if only one manager
if (totalManagers === 1 && firstManagerId) {
select.value = firstManagerId;
await selectManager(firstManagerId);
// Auto-select for managers
if (currentUser.role === 'manager' && filteredOffices.length === 1) {
select.value = filteredOffices[0].id;
loadOfficeRules(filteredOffices[0].id);
}
}
}
async function selectManager(managerId) {
selectedManagerId = managerId;
if (!managerId) {
async function loadOfficeRules(officeId) {
if (!officeId) {
document.getElementById('rulesContent').style.display = 'none';
document.getElementById('noManagerMessage').style.display = 'block';
document.getElementById('noOfficeMessage').style.display = 'block';
return;
}
currentOfficeId = officeId;
document.getElementById('rulesContent').style.display = 'block';
document.getElementById('noManagerMessage').style.display = 'none';
document.getElementById('noOfficeMessage').style.display = 'none';
// Load users for this office (for dropdowns)
await loadOfficeUsers(officeId);
await Promise.all([
loadWeeklyClosingDays(),
loadClosingDays(),
loadGuarantees(),
loadExclusions(),
loadManagerUsers()
loadWeeklyClosingDays(officeId),
loadClosingDays(officeId),
loadGuarantees(officeId),
loadExclusions(officeId)
]);
}
async function loadWeeklyClosingDays() {
const response = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`);
async function loadOfficeUsers(officeId) {
const response = await api.get(`/api/offices/${officeId}/users`);
if (response && response.ok) {
officeUsers = await response.json();
}
}
// Weekly Closing Days
async function loadWeeklyClosingDays(officeId) {
const response = await api.get(`/api/offices/${officeId}/weekly-closing-days`);
if (response && response.ok) {
const days = await response.json();
const weekdays = days.map(d => d.weekday);
currentWeeklyClosingDays = days;
const activeWeekdays = days.map(d => d.weekday);
// Update checkboxes
document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => {
const weekday = parseInt(cb.dataset.weekday);
cb.checked = weekdays.includes(weekday);
cb.checked = activeWeekdays.includes(weekday);
});
}
}
async function loadManagerUsers() {
const response = await api.get(`/api/managers/${selectedManagerId}/users`);
if (response && response.ok) {
managerUsers = await response.json();
updateUserSelects();
async function saveWeeklyClosingDays() {
const btn = document.getElementById('saveWeeklyClosingDaysBtn');
if (!btn) return;
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Salvataggio...';
try {
const promises = [];
const checkboxes = document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]');
for (const cb of checkboxes) {
const weekday = parseInt(cb.dataset.weekday);
const isChecked = cb.checked;
const existingEntry = currentWeeklyClosingDays.find(d => d.weekday === weekday);
if (isChecked && !existingEntry) {
// Add
promises.push(api.post(`/api/offices/${currentOfficeId}/weekly-closing-days`, { weekday }));
} else if (!isChecked && existingEntry) {
// Remove
promises.push(api.delete(`/api/offices/${currentOfficeId}/weekly-closing-days/${existingEntry.id}`));
}
}
await Promise.all(promises);
utils.showMessage('Giorni di chiusura aggiornati', 'success');
await loadWeeklyClosingDays(currentOfficeId);
} catch (error) {
console.error(error);
utils.showMessage('Errore durante il salvataggio', 'error');
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
function updateUserSelects() {
['guaranteeUser', 'exclusionUser'].forEach(selectId => {
const select = document.getElementById(selectId);
select.innerHTML = '<option value="">Select user...</option>';
managerUsers.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = user.name;
select.appendChild(option);
});
});
}
async function loadClosingDays() {
const response = await api.get(`/api/managers/${selectedManagerId}/closing-days`);
// Closing Days
async function loadClosingDays(officeId) {
const response = await api.get(`/api/offices/${officeId}/closing-days`);
const container = document.getElementById('closingDaysList');
if (response && response.ok) {
const days = await response.json();
if (days.length === 0) {
container.innerHTML = '';
container.innerHTML = '<p class="text-muted">Nessun giorno di chiusura specifico.</p>';
return;
}
container.innerHTML = days.map(day => `
<div class="rule-item">
<div class="rule-info">
<span class="rule-date">${utils.formatDateDisplay(day.date)}</span>
${day.reason ? `<span class="rule-reason">${day.reason}</span>` : ''}
<strong>${utils.formatDateDisplay(day.date)}${day.end_date ? ' - ' + utils.formatDateDisplay(day.end_date) : ''}</strong>
${day.reason ? `<span class="rule-note">${day.reason}</span>` : ''}
</div>
<button class="btn-icon btn-danger" onclick="deleteClosingDay('${day.id}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
&times;
</button>
</div>
`).join('');
}
}
function formatDateRange(startDate, endDate) {
if (!startDate && !endDate) return '';
if (startDate && !endDate) return `From ${utils.formatDateDisplay(startDate)}`;
if (!startDate && endDate) return `Until ${utils.formatDateDisplay(endDate)}`;
return `${utils.formatDateDisplay(startDate)} - ${utils.formatDateDisplay(endDate)}`;
async function addClosingDay(data) {
const response = await api.post(`/api/offices/${currentOfficeId}/closing-days`, data);
if (response && response.ok) {
await loadClosingDays(currentOfficeId);
document.getElementById('closingDayModal').style.display = 'none';
document.getElementById('closingDayForm').reset();
} else {
const error = await response.json();
alert(error.detail || 'Impossibile aggiungere il giorno di chiusura');
}
}
async function loadGuarantees() {
const response = await api.get(`/api/managers/${selectedManagerId}/guarantees`);
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) {
await loadClosingDays(currentOfficeId);
}
}
// Guarantees
async function loadGuarantees(officeId) {
const response = await api.get(`/api/offices/${officeId}/guarantees`);
const container = document.getElementById('guaranteesList');
if (response && response.ok) {
const guarantees = await response.json();
if (guarantees.length === 0) {
container.innerHTML = '';
container.innerHTML = '<p class="text-muted">Nessuna garanzia di parcheggio attiva.</p>';
return;
}
container.innerHTML = guarantees.map(g => {
const dateRange = formatDateRange(g.start_date, g.end_date);
return `
container.innerHTML = guarantees.map(g => `
<div class="rule-item">
<div class="rule-info">
<span class="rule-name">${g.user_name}</span>
${dateRange ? `<span class="rule-date-range">${dateRange}</span>` : ''}
<strong>${g.user_name || 'Utente sconosciuto'}</strong>
<span class="rule-dates">
${g.start_date ? 'Dal ' + utils.formatDateDisplay(g.start_date) : 'Da sempre'}
${g.end_date ? ' al ' + utils.formatDateDisplay(g.end_date) : ''}
</span>
${g.notes ? `<span class="rule-note">${g.notes}</span>` : ''}
</div>
<button class="btn-icon btn-danger" onclick="deleteGuarantee('${g.id}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
&times;
</button>
</div>
`}).join('');
`).join('');
}
}
async function loadExclusions() {
const response = await api.get(`/api/managers/${selectedManagerId}/exclusions`);
const container = document.getElementById('exclusionsList');
async function addGuarantee(data) {
const response = await api.post(`/api/offices/${currentOfficeId}/guarantees`, data);
if (response && response.ok) {
const exclusions = await response.json();
if (exclusions.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = exclusions.map(e => {
const dateRange = formatDateRange(e.start_date, e.end_date);
return `
<div class="rule-item">
<div class="rule-info">
<span class="rule-name">${e.user_name}</span>
${dateRange ? `<span class="rule-date-range">${dateRange}</span>` : ''}
</div>
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
`}).join('');
}
}
// Delete functions
async function deleteClosingDay(id) {
if (!confirm('Delete this closing day?')) return;
const response = await api.delete(`/api/managers/${selectedManagerId}/closing-days/${id}`);
if (response && response.ok) {
await loadClosingDays();
await loadGuarantees(currentOfficeId);
document.getElementById('guaranteeModal').style.display = 'none';
document.getElementById('guaranteeForm').reset();
} else {
const error = await response.json();
alert(error.detail || 'Impossibile aggiungere la garanzia');
}
}
async function deleteGuarantee(id) {
if (!confirm('Remove this parking guarantee?')) return;
const response = await api.delete(`/api/managers/${selectedManagerId}/guarantees/${id}`);
if (!confirm('Eliminare questa garanzia?')) return;
const response = await api.delete(`/api/offices/${currentOfficeId}/guarantees/${id}`);
if (response && response.ok) {
await loadGuarantees();
await loadGuarantees(currentOfficeId);
}
}
// Exclusions
async function loadExclusions(officeId) {
const response = await api.get(`/api/offices/${officeId}/exclusions`);
const container = document.getElementById('exclusionsList');
if (response && response.ok) {
const exclusions = await response.json();
if (exclusions.length === 0) {
container.innerHTML = '<p class="text-muted">Nessuna esclusione attiva.</p>';
return;
}
container.innerHTML = exclusions.map(e => `
<div class="rule-item">
<div class="rule-info">
<strong>${e.user_name || 'Utente sconosciuto'}</strong>
<span class="rule-dates">
${e.start_date ? 'Dal ' + utils.formatDateDisplay(e.start_date) : 'Da sempre'}
${e.end_date ? ' al ' + utils.formatDateDisplay(e.end_date) : ''}
</span>
${e.notes ? `<span class="rule-note">${e.notes}</span>` : ''}
</div>
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')">
&times;
</button>
</div>
`).join('');
}
}
async function addExclusion(data) {
const response = await api.post(`/api/offices/${currentOfficeId}/exclusions`, data);
if (response && response.ok) {
await loadExclusions(currentOfficeId);
document.getElementById('exclusionModal').style.display = 'none';
document.getElementById('exclusionForm').reset();
} else {
const error = await response.json();
alert(error.detail || 'Impossibile aggiungere l\'esclusione');
}
}
async function deleteExclusion(id) {
if (!confirm('Remove this parking exclusion?')) return;
const response = await api.delete(`/api/managers/${selectedManagerId}/exclusions/${id}`);
if (!confirm('Eliminare questa esclusione?')) return;
const response = await api.delete(`/api/offices/${currentOfficeId}/exclusions/${id}`);
if (response && response.ok) {
await loadExclusions();
await loadExclusions(currentOfficeId);
}
}
function setupEventListeners() {
// Manager selection
document.getElementById('managerSelect').addEventListener('change', (e) => {
selectManager(e.target.value);
});
function populateUserSelects() {
const selects = ['guaranteeUser', 'exclusionUser'];
selects.forEach(id => {
const select = document.getElementById(id);
const currentVal = select.value;
select.innerHTML = '<option value="">Seleziona utente...</option>';
// Weekly closing day checkboxes
document.querySelectorAll('#weeklyClosingDays input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', async (e) => {
const weekday = parseInt(e.target.dataset.weekday);
if (e.target.checked) {
// Add weekly closing day
const response = await api.post(`/api/managers/${selectedManagerId}/weekly-closing-days`, { weekday });
if (!response || !response.ok) {
e.target.checked = false;
const error = await response.json();
alert(error.detail || 'Failed to add weekly closing day');
}
} else {
// Remove weekly closing day - need to find the ID first
const getResponse = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`);
if (getResponse && getResponse.ok) {
const days = await getResponse.json();
const day = days.find(d => d.weekday === weekday);
if (day) {
const deleteResponse = await api.delete(`/api/managers/${selectedManagerId}/weekly-closing-days/${day.id}`);
if (!deleteResponse || !deleteResponse.ok) {
e.target.checked = true;
}
}
}
}
officeUsers.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = user.name;
select.appendChild(option);
});
});
// Modal openers
document.getElementById('addClosingDayBtn').addEventListener('click', () => {
document.getElementById('closingDayForm').reset();
document.getElementById('closingDayModal').style.display = 'flex';
if (currentVal) select.value = currentVal;
});
document.getElementById('addGuaranteeBtn').addEventListener('click', () => {
document.getElementById('guaranteeForm').reset();
document.getElementById('guaranteeModal').style.display = 'flex';
});
document.getElementById('addExclusionBtn').addEventListener('click', () => {
document.getElementById('exclusionForm').reset();
document.getElementById('exclusionModal').style.display = 'flex';
});
// Modal closers
['closeClosingDayModal', 'cancelClosingDay'].forEach(id => {
document.getElementById(id).addEventListener('click', () => {
document.getElementById('closingDayModal').style.display = 'none';
});
});
['closeGuaranteeModal', 'cancelGuarantee'].forEach(id => {
document.getElementById(id).addEventListener('click', () => {
document.getElementById('guaranteeModal').style.display = 'none';
});
});
['closeExclusionModal', 'cancelExclusion'].forEach(id => {
document.getElementById(id).addEventListener('click', () => {
document.getElementById('exclusionModal').style.display = 'none';
});
});
// Form submissions
document.getElementById('closingDayForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
date: document.getElementById('closingDate').value,
reason: document.getElementById('closingReason').value || null
};
const response = await api.post(`/api/managers/${selectedManagerId}/closing-days`, data);
if (response && response.ok) {
document.getElementById('closingDayModal').style.display = 'none';
await loadClosingDays();
} else {
const error = await response.json();
alert(error.detail || 'Failed to add closing day');
}
});
document.getElementById('guaranteeForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
user_id: document.getElementById('guaranteeUser').value,
start_date: document.getElementById('guaranteeStartDate').value || null,
end_date: document.getElementById('guaranteeEndDate').value || null
};
const response = await api.post(`/api/managers/${selectedManagerId}/guarantees`, data);
if (response && response.ok) {
document.getElementById('guaranteeModal').style.display = 'none';
await loadGuarantees();
} else {
const error = await response.json();
alert(error.detail || 'Failed to add guarantee');
}
});
document.getElementById('exclusionForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
user_id: document.getElementById('exclusionUser').value,
start_date: document.getElementById('exclusionStartDate').value || null,
end_date: document.getElementById('exclusionEndDate').value || null
};
const response = await api.post(`/api/managers/${selectedManagerId}/exclusions`, data);
if (response && response.ok) {
document.getElementById('exclusionModal').style.display = 'none';
await loadExclusions();
} else {
const error = await response.json();
alert(error.detail || 'Failed to add exclusion');
}
});
// Modal background clicks
utils.setupModalClose('closingDayModal');
utils.setupModalClose('guaranteeModal');
utils.setupModalClose('exclusionModal');
}
// Make delete functions globally accessible
function setupEventListeners() {
// Office select
document.getElementById('officeSelect').addEventListener('change', (e) => {
loadOfficeRules(e.target.value);
});
// Save Weekly closing days
const saveBtn = document.getElementById('saveWeeklyClosingDaysBtn');
if (saveBtn) {
saveBtn.addEventListener('click', saveWeeklyClosingDays);
}
// Modals
const modals = [
{ id: 'closingDayModal', btn: 'addClosingDayBtn', close: 'closeClosingDayModal', cancel: 'cancelClosingDay' },
{ id: 'guaranteeModal', btn: 'addGuaranteeBtn', close: 'closeGuaranteeModal', cancel: 'cancelGuarantee' },
{ id: 'exclusionModal', btn: 'addExclusionBtn', close: 'closeExclusionModal', cancel: 'cancelExclusion' }
];
modals.forEach(m => {
document.getElementById(m.btn).addEventListener('click', () => {
if (m.id !== 'closingDayModal') populateUserSelects();
document.getElementById(m.id).style.display = 'flex';
});
document.getElementById(m.close).addEventListener('click', () => {
document.getElementById(m.id).style.display = 'none';
});
document.getElementById(m.cancel).addEventListener('click', () => {
document.getElementById(m.id).style.display = 'none';
});
utils.setupModalClose(m.id);
});
// Forms
document.getElementById('closingDayForm').addEventListener('submit', (e) => {
e.preventDefault();
addClosingDay({
date: document.getElementById('closingDate').value,
end_date: document.getElementById('closingEndDate').value || null,
reason: document.getElementById('closingReason').value || null
});
});
document.getElementById('guaranteeForm').addEventListener('submit', (e) => {
e.preventDefault();
addGuarantee({
user_id: document.getElementById('guaranteeUser').value,
start_date: document.getElementById('guaranteeStartDate').value || null,
end_date: document.getElementById('guaranteeEndDate').value || null,
notes: document.getElementById('guaranteeNotes').value || null
});
});
document.getElementById('exclusionForm').addEventListener('submit', (e) => {
e.preventDefault();
addExclusion({
user_id: document.getElementById('exclusionUser').value,
start_date: document.getElementById('exclusionStartDate').value || null,
end_date: document.getElementById('exclusionEndDate').value || null,
notes: document.getElementById('exclusionNotes').value || null
});
});
}
// Global functions
window.deleteClosingDay = deleteClosingDay;
window.deleteGuarantee = deleteGuarantee;
window.deleteExclusion = deleteExclusion;

View File

@@ -97,7 +97,7 @@ function formatDate(date) {
*/
function formatDateDisplay(dateStr) {
const date = new Date(dateStr + 'T12:00:00');
return date.toLocaleDateString('en-US', {
return date.toLocaleDateString('it-IT', {
weekday: 'short',
month: 'short',
day: 'numeric'
@@ -109,8 +109,8 @@ function formatDateDisplay(dateStr) {
*/
function getMonthName(month) {
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno',
'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'
];
return months[month];
}
@@ -119,7 +119,7 @@ function getMonthName(month) {
* Get day name
*/
function getDayName(dayIndex) {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const days = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
return days[dayIndex];
}
@@ -133,7 +133,7 @@ function getDaysInMonth(year, month) {
/**
* Get start of week for a date
*/
function getWeekStart(date, weekStartDay = 0) {
function getWeekStart(date, weekStartDay = 1) {
const d = new Date(date);
const day = d.getDay();
const diff = (day - weekStartDay + 7) % 7;
@@ -146,7 +146,7 @@ function getWeekStart(date, weekStartDay = 0) {
* Format date as short display (e.g., "Nov 26")
*/
function formatDateShort(date) {
return date.toLocaleDateString('en-US', {
return date.toLocaleDateString('it-IT', {
month: 'short',
day: 'numeric'
});
@@ -163,12 +163,14 @@ function showMessage(message, type = 'success', duration = 3000) {
toastContainer.id = 'toastContainer';
toastContainer.style.cssText = `
position: fixed;
top: 1rem;
right: 1rem;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 1rem;
align-items: center;
`;
document.body.appendChild(toastContainer);
}
@@ -176,17 +178,21 @@ function showMessage(message, type = 'success', duration = 3000) {
const toast = document.createElement('div');
toast.className = `message ${type}`;
toast.style.cssText = `
padding: 0.75rem 1rem;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
animation: slideIn 0.2s ease;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
animation: slideInBottom 0.3s ease;
font-size: 1.1rem;
font-weight: 500;
min-width: 300px;
text-align: center;
`;
toast.textContent = message;
toastContainer.appendChild(toast);
if (duration > 0) {
setTimeout(() => {
toast.style.animation = 'slideOut 0.2s ease';
toast.style.animation = 'fadeOut 0.3s ease';
setTimeout(() => toast.remove(), 200);
}, duration);
}
@@ -196,14 +202,7 @@ function showMessage(message, type = 'success', duration = 3000) {
* Close modal when clicking outside
*/
function setupModalClose(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target.id === modalId) {
modal.style.display = 'none';
}
});
}
// Behavior disabled: clicking outside does not close modal
}
// Export utilities

View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gestione Uffici - 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>Gestione Uffici</h2>
<div class="header-actions">
</div>
</header>
<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>
</div>
<div class="data-table-container">
<table class="data-table" id="officesTable">
<thead>
<tr>
<th>Nome</th>
<th>Quota Posti</th>
<th>Prefisso</th>
<th>Utenti</th>
<th>Azioni</th>
</tr>
</thead>
<tbody id="officesBody"></tbody>
</table>
</div>
</div>
</div>
</main>
<!-- Office Modal -->
<div class="modal" id="officeModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="officeModalTitle">Nuovo Ufficio</h3>
<button class="modal-close" id="closeOfficeModal">&times;</button>
</div>
<div class="modal-body">
<form id="officeForm">
<input type="hidden" id="officeId">
<div class="form-group">
<label for="officeName">Nome Ufficio</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>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelOffice">Annulla</button>
<button type="submit" class="btn btn-dark" id="saveOfficeBtn">Salva</button>
</div>
</form>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/admin-offices.js"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,31 +8,33 @@
<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>Parking Manager</h1>
<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">
<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">Loading...</div>
<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">Profile</a>
<a href="/settings" class="dropdown-item">Settings</a>
<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">Logout</button>
<button class="dropdown-item" id="logoutButton">Esci</button>
</div>
</div>
</div>
@@ -39,23 +42,33 @@
<main class="main-content">
<header class="page-header">
<h2>Manage Users</h2>
<h2>Gestione Utenti</h2>
<div class="header-actions">
<input type="text" id="searchInput" class="form-input" placeholder="Search users...">
</div>
</header>
<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 Utenti</h3>
<input type="text" id="searchInput" class="form-input" placeholder="Cerca utenti..."
style="max-width: 300px;">
</div>
<div class="data-table-container">
<table class="data-table" id="usersTable">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Manager</th>
<th>Actions</th>
<th class="sortable" data-sort="name" style="cursor: pointer;">Nome <span
class="sort-icon"></span></th>
<th class="sortable" data-sort="email" style="cursor: pointer;">Email <span
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
class="sort-icon"></span></th>
<th class="sortable" data-sort="parking_ratio" style="cursor: pointer;">Punteggio <span
class="sort-icon"></span></th>
<th>Azioni</th>
</tr>
</thead>
<tbody id="usersBody"></tbody>
@@ -69,7 +82,7 @@
<div class="modal" id="userModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="userModalTitle">Edit User</h3>
<h3 id="userModalTitle">Modifica Utente</h3>
<button class="modal-close" id="closeUserModal">&times;</button>
</div>
<div class="modal-body">
@@ -78,54 +91,42 @@
<!-- LDAP notice -->
<div id="ldapNotice" class="form-notice" style="display: none;">
<small>This user is managed by LDAP. Some fields cannot be edited.</small>
<small>Questo utente è gestito da LDAP. Alcuni campi non possono essere modificati.</small>
</div>
<div class="form-group">
<label for="editName">Name</label>
<label for="editName">Nome</label>
<input type="text" id="editName" required>
<small id="nameHelp" class="text-muted" style="display: none;">Managed by LDAP</small>
<small id="nameHelp" class="text-muted" style="display: none;">Gestito da LDAP</small>
</div>
<div class="form-group">
<label for="editEmail">Email</label>
<input type="email" id="editEmail" disabled>
</div>
<div class="form-group">
<label for="editRole">Role</label>
<label for="editRole">Ruolo</label>
<select id="editRole" required>
<option value="employee">Employee</option>
<option value="employee">Dipendente</option>
<option value="manager">Manager</option>
<option value="admin">Admin</option>
</select>
<small id="roleHelp" class="text-muted" style="display: none;">Admin role is managed by LDAP group</small>
<small id="roleHelp" class="text-muted" style="display: none;">Il ruolo admin è gestito dal
gruppo LDAP</small>
</div>
<div class="form-group" id="managerGroup">
<label for="editManager">Manager</label>
<select id="editManager">
<option value="">No manager</option>
<div class="form-group" id="officeGroup">
<label for="editOffice">Ufficio</label>
<select id="editOffice">
<option value="">Nessun ufficio</option>
</select>
<small class="text-muted">Who manages this user</small>
<small class="text-muted">Ufficio di appartenenza</small>
</div>
<!-- Manager-specific fields -->
<div id="managerFields" style="display: none;">
<hr>
<h4>Manager Settings</h4>
<div class="form-group">
<label for="editQuota">Parking Quota</label>
<input type="number" id="editQuota" min="0" value="0">
<small class="text-muted">Number of parking spots this manager controls</small>
</div>
<div class="form-group">
<label for="editPrefix">Spot Prefix</label>
<input type="text" id="editPrefix" maxlength="2" placeholder="A, B, C...">
<small class="text-muted">Letter prefix for parking spots (e.g., A for spots A1, A2...)</small>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelUser">Cancel</button>
<button type="submit" class="btn btn-dark">Save</button>
<button type="button" class="btn btn-secondary" id="cancelUser">Annulla</button>
<button type="submit" class="btn btn-dark">Salva</button>
</div>
</form>
</div>
@@ -137,4 +138,5 @@
<script src="/js/nav.js"></script>
<script src="/js/admin-users.js"></script>
</body>
</html>
</html>

View File

@@ -11,13 +11,13 @@
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<h1>Parking Manager</h1>
<p>Manage team presence and parking assignments</p>
<h1>Gestione Parcheggi</h1>
<p>Gestisci la presenza del team e le assegnazioni dei parcheggi</p>
</div>
<div id="authButtons" style="display: flex; flex-direction: column; gap: 1rem;">
<!-- Buttons will be populated by JavaScript based on auth mode -->
<div class="loading">Loading...</div>
<div class="loading">Caricamento...</div>
</div>
</div>
</div>
@@ -56,30 +56,30 @@
if (config.login_url) {
// Redirect to Authelia login with return URL
const returnUrl = encodeURIComponent(window.location.origin + '/presence');
buttons += `<a href="${config.login_url}?rd=${returnUrl}" class="btn btn-dark btn-full">Sign In</a>`;
buttons += `<a href="${config.login_url}?rd=${returnUrl}" class="btn btn-dark btn-full">Accedi</a>`;
} else {
// No login URL configured - just try to access the app (Authelia will intercept)
buttons += `<a href="/presence" class="btn btn-dark btn-full">Sign In</a>`;
buttons += `<a href="/presence" class="btn btn-dark btn-full">Accedi</a>`;
}
if (config.registration_url) {
buttons += `<a href="${config.registration_url}" class="btn btn-secondary btn-full" target="_blank">Create Account</a>`;
buttons += `<p class="auth-footer" style="margin-top: 0.5rem; text-align: center; font-size: 0.875rem; color: #666;">Registration requires admin approval</p>`;
buttons += `<a href="${config.registration_url}" class="btn btn-secondary btn-full" target="_blank">Crea Account</a>`;
buttons += `<p class="auth-footer" style="margin-top: 0.5rem; text-align: center; font-size: 0.875rem; color: #666;">La registrazione richiede l'approvazione dell'amministratore</p>`;
}
buttonsDiv.innerHTML = buttons;
} else {
// Standalone mode: Local login and registration
buttonsDiv.innerHTML = `
<a href="/login" class="btn btn-dark btn-full">Sign In</a>
<a href="/register" class="btn btn-secondary btn-full">Create Account</a>
<a href="/login" class="btn btn-dark btn-full">Accedi</a>
<a href="/register" class="btn btn-secondary btn-full">Crea Account</a>
`;
}
} catch (e) {
// Fallback to standalone mode
buttonsDiv.innerHTML = `
<a href="/login" class="btn btn-dark btn-full">Sign In</a>
<a href="/register" class="btn btn-secondary btn-full">Create Account</a>
<a href="/login" class="btn btn-dark btn-full">Accedi</a>
<a href="/register" class="btn btn-secondary btn-full">Crea Account</a>
`;
}
}

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,12 +8,13 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<h1>Welcome Back</h1>
<p>Sign in to your account</p>
<h1>Bentornato</h1>
<p>Accedi al tuo account</p>
</div>
<div id="errorMessage"></div>
@@ -26,11 +28,11 @@
<label for="password">Password</label>
<input type="password" id="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-dark btn-full">Sign In</button>
<button type="submit" class="btn btn-dark btn-full">Accedi</button>
</form>
<div class="auth-footer">
Don't have an account? <a href="/register">Sign up</a>
Non hai un account? <a href="/register">Registrati</a>
</div>
</div>
</div>
@@ -80,4 +82,5 @@
});
</script>
</body>
</html>
</html>

View File

@@ -0,0 +1,155 @@
<!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>
</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

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,31 +8,33 @@
<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>Parking Manager</h1>
<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">
<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">Loading...</div>
<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">Profile</a>
<a href="/settings" class="dropdown-item">Settings</a>
<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">Logout</button>
<button class="dropdown-item" id="logoutButton">Esci</button>
</div>
</div>
</div>
@@ -39,26 +42,36 @@
<main class="main-content">
<header class="page-header">
<h2>My Presence</h2>
<h2>Dashboard</h2>
<div class="header-actions">
<button class="btn btn-secondary" id="bulkMarkBtn">Bulk Mark</button>
</div>
</header>
<div class="content-wrapper">
<div class="card presence-card">
<div style="margin-bottom: 1.5rem;">
<h3>Calendario</h3>
</div>
<div class="calendar-header">
<button class="btn-icon" id="prevMonth">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<h3 id="currentMonth">Loading...</h3>
<h3 id="currentMonth">Caricamento...</h3>
<button class="btn-icon" id="nextMonth">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button class="btn btn-dark btn-sm" id="quickEntryBtn" style="font-size: 0.85rem;">
Inserimento Veloce
</button>
</div>
</div>
<div class="calendar-grid" id="calendarGrid"></div>
@@ -66,121 +79,211 @@
<div class="legend">
<div class="legend-item">
<div class="legend-color status-present"></div>
<span>Present (Office)</span>
<span>In sede</span>
</div>
<div class="legend-item">
<div class="legend-color status-remote"></div>
<span>Remote</span>
<span>Remoto</span>
</div>
<div class="legend-item">
<div class="legend-color status-absent"></div>
<span>Absent</span>
<span>Assente</span>
</div>
</div>
</div>
<!-- Parking Status Section -->
<div class="card" id="parkingStatusCard" style="margin-top: 2rem;">
<div style="margin-bottom: 1.5rem;">
<h3>Stato Parcheggio</h3>
</div>
<!-- Daily View Controls -->
<div id="dailyViewControls">
<!-- 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);">
<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"
stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div style="position: relative; text-align: center; min-width: 200px;">
<div id="statusDateDisplay"
style="font-weight: 600; font-size: 1rem; text-transform: capitalize;"></div>
<input type="date" id="statusDatePicker"
style="position: absolute; inset: 0; opacity: 0; cursor: pointer;">
</div>
<button class="btn-icon" id="statusNextDay"
style="border: none; width: 32px; height: 32px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
</div>
<!-- Office Header (No Logo) -->
<!-- Office Header (No Logo) -->
<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>
</div>
<span class="badge"
style="background: #eff6ff; color: #1d4ed8; border: 1px solid #dbeafe; padding: 0.35rem 0.75rem; font-size: 0.85rem;">
Liberi: <span id="spotsCountBadge">0/0</span>
</span>
</div>
<!-- Spots Grid -->
<div id="spotsGrid" style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 0;">
<!-- Spots injected here -->
<div style="width: 100%; text-align: center; color: var(--text-secondary); padding: 2rem;">
Caricamento posti...
</div>
</div>
</div>
</div>
<div class="card parking-map-card" style="margin-top: 2rem;">
<h3>Mappa Parcheggio</h3>
<img src="/assets/parking-map.png" alt="Mappa Parcheggio"
style="width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--border);">
</div>
</div>
</main>
<!-- Day Modal -->
<div class="modal" id="dayModal" style="display: none;">
<!-- Quick Entry Modal -->
<div class="modal" id="quickEntryModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3 id="dayModalTitle">Mark Presence</h3>
<button class="modal-close" id="closeDayModal">&times;</button>
<h3>Inserimento Veloce</h3>
<button class="modal-close" id="closeQuickEntryModal">&times;</button>
</div>
<div class="modal-body">
<div class="status-buttons">
<button class="status-btn" data-status="present">
<div class="status-icon status-present"></div>
<span>Present</span>
</button>
<button class="status-btn" data-status="remote">
<div class="status-icon status-remote"></div>
<span>Remote</span>
</button>
<button class="status-btn" data-status="absent">
<div class="status-icon status-absent"></div>
<span>Absent</span>
</button>
</div>
<div id="parkingSection" style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" style="flex: 1;" id="reassignParkingBtn">Reassign</button>
<button class="btn btn-secondary" style="flex: 1;" id="releaseParkingBtn">Release</button>
<form id="quickEntryForm">
<div class="form-group">
<label>Range di Date</label>
<div style="display: flex; gap: 1rem;">
<div style="flex: 1;">
<small>Da:</small>
<input type="date" id="qeStartDate" class="form-control" required>
</div>
<div style="flex: 1;">
<small>A (incluso):</small>
<input type="date" id="qeEndDate" class="form-control" required>
</div>
</div>
</div>
</div>
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Clear Presence</button>
</div>
</div>
</div>
<!-- Reassign Parking Modal -->
<div class="modal" id="reassignModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Reassign Parking Spot</h3>
<button class="modal-close" id="closeReassignModal">&times;</button>
</div>
<div class="modal-body">
<p id="reassignSpotInfo" style="margin-bottom: 1rem; font-size: 0.9rem;"></p>
<div class="form-group">
<label for="reassignUser">Assign to</label>
<select id="reassignUser" required>
<option value="">Select user...</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelReassign">Cancel</button>
<button type="button" class="btn btn-dark" id="confirmReassign">Reassign</button>
</div>
</div>
</div>
</div>
<div class="form-group">
<label>Stato da applicare</label>
<div class="status-buttons">
<button type="button" class="status-btn qe-status-btn" data-status="present">
<div class="status-icon status-present"></div>
<span>In sede</span>
</button>
<button type="button" class="status-btn qe-status-btn" data-status="remote">
<div class="status-icon status-remote"></div>
<span>Remoto</span>
</button>
<button type="button" class="status-btn qe-status-btn" data-status="absent">
<div class="status-icon status-absent"></div>
<span>Assente</span>
</button>
<button type="button" class="status-btn qe-status-btn" data-status="clear">
<div class="status-icon"
style="border: 2px solid #ef4444; background: #fee2e2; display: flex; align-items: center; justify-content: center;">
<span style="color: #ef4444; font-weight: bold; font-size: 1.2rem;">&times;</span>
</div>
<span>Rimuovi</span>
</button>
</div>
<input type="hidden" id="qeStatus" required>
</div>
<!-- Bulk Mark Modal -->
<div class="modal" id="bulkMarkModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Bulk Mark Presence</h3>
<button class="modal-close" id="closeBulkModal">&times;</button>
</div>
<div class="modal-body">
<form id="bulkMarkForm">
<div class="form-group">
<label for="startDate">Start Date</label>
<input type="date" id="startDate" required>
</div>
<div class="form-group">
<label for="endDate">End Date</label>
<input type="date" id="endDate" required>
</div>
<div class="form-group">
<label for="bulkStatus">Status</label>
<select id="bulkStatus" required>
<option value="present">Present (Office)</option>
<option value="remote">Remote</option>
<option value="absent">Absent</option>
</select>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="weekdaysOnly">
<span>Weekdays only (Mon-Fri)</span>
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelBulk">Cancel</button>
<button type="submit" class="btn btn-dark">Mark Dates</button>
<button type="button" class="btn btn-secondary" id="cancelQuickEntry">Annulla</button>
<button type="submit" class="btn btn-dark">Applica</button>
</div>
</form>
</div>
</div>
</div>
<!-- Day Modal -->
<div class="modal" id="dayModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3 id="dayModalTitle">Segna presenza</h3>
<button class="modal-close" id="closeDayModal">&times;</button>
</div>
<div class="modal-body">
<p id="dayModalUser" style="display:none; margin-bottom: 1rem; font-weight: 500;"></p>
<div class="status-buttons">
<button class="status-btn" data-status="present">
<div class="status-icon status-present"></div>
<span>In sede</span>
</button>
<button class="status-btn" data-status="remote">
<div class="status-icon status-remote"></div>
<span>Remoto</span>
</button>
<button class="status-btn" data-status="absent">
<div class="status-icon status-absent"></div>
<span>Assente</span>
</button>
</div>
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella
presenza</button>
<div id="parkingSection"
style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
<div id="parkingActions" style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" style="flex: 1;" id="reassignParkingBtn">Cedi posto</button>
<button class="btn btn-secondary" style="flex: 1;" id="releaseParkingBtn">Lascia libero</button>
</div>
<div id="reassignForm"
style="display: none; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem;">
<div class="form-group" style="margin-bottom: 0.5rem;">
<label for="reassignUser" style="font-size: 0.9rem;">Assegna a</label>
<select id="reassignUser" class="form-control" style="width: 100%;">
<option value="">Seleziona utente...</option>
</select>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-secondary" style="flex: 1;"
id="cancelReassign">Annulla</button>
<button type="button" class="btn btn-dark" style="flex: 1;"
id="confirmReassign">Riassegna</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/modal-logic.js"></script>
<script src="/js/presence.js"></script>
</body>
</html>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,31 +8,33 @@
<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>Parking Manager</h1>
<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">
<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">Loading...</div>
<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">Profile</a>
<a href="/settings" class="dropdown-item">Settings</a>
<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">Logout</button>
<button class="dropdown-item" id="logoutButton">Esci</button>
</div>
</div>
</div>
@@ -39,43 +42,44 @@
<main class="main-content">
<header class="page-header">
<h2>Profile</h2>
<h2>Profilo</h2>
</header>
<div class="content-wrapper">
<div class="card">
<div class="card-header">
<h3>Personal Information</h3>
<h3>Informazioni Personali</h3>
</div>
<div class="card-body">
<!-- LDAP Notice -->
<div id="ldapNotice" class="form-notice" style="display: none; margin-bottom: 1rem;">
<small>Your account is managed by LDAP. Some information cannot be changed here.</small>
<small>Il tuo account è gestito da LDAP. Alcune informazioni non possono essere modificate
qui.</small>
</div>
<form id="profileForm">
<div class="form-group">
<label for="name">Full Name</label>
<label for="name">Nome Completo</label>
<input type="text" id="name" required>
<small id="nameHelp" class="text-muted" style="display: none;">Managed by LDAP</small>
<small id="nameHelp" class="text-muted" style="display: none;">Gestito da LDAP</small>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" disabled>
<small class="text-muted">Email cannot be changed</small>
<small class="text-muted">L'email non può essere modificata</small>
</div>
<div class="form-group">
<label for="role">Role</label>
<label for="role">Ruolo</label>
<input type="text" id="role" disabled>
<small class="text-muted">Role is assigned by your administrator</small>
<small class="text-muted">Il ruolo è assegnato dal tuo amministratore</small>
</div>
<div class="form-group">
<label for="manager">Manager</label>
<input type="text" id="manager" disabled>
<small class="text-muted">Your manager is assigned by the administrator</small>
<small class="text-muted">Il tuo manager è assegnato dall'amministratore</small>
</div>
<div class="form-actions" id="profileActions">
<button type="submit" class="btn btn-dark">Save Changes</button>
<button type="submit" class="btn btn-dark">Salva Modifiche</button>
</div>
</form>
</div>
@@ -84,25 +88,25 @@
<!-- Password section - hidden for LDAP users -->
<div class="card" id="passwordCard">
<div class="card-header">
<h3>Change Password</h3>
<h3>Cambia Password</h3>
</div>
<div class="card-body">
<form id="passwordForm">
<div class="form-group">
<label for="currentPassword">Current Password</label>
<label for="currentPassword">Password Attuale</label>
<input type="password" id="currentPassword" required>
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<label for="newPassword">Nuova Password</label>
<input type="password" id="newPassword" required minlength="8">
<small>Minimum 8 characters</small>
<small>Minimo 8 caratteri</small>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm New Password</label>
<label for="confirmPassword">Conferma Nuova Password</label>
<input type="password" id="confirmPassword" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-dark">Change Password</button>
<button type="submit" class="btn btn-dark">Cambia Password</button>
</div>
</form>
</div>
@@ -135,7 +139,7 @@
document.getElementById('name').value = profile.name || '';
document.getElementById('email').value = profile.email;
document.getElementById('role').value = profile.role;
document.getElementById('manager').value = profile.manager_name || 'None';
document.getElementById('manager').value = profile.manager_name || 'Nessuno';
// LDAP mode adjustments
if (isLdapUser) {
@@ -154,7 +158,7 @@
e.preventDefault();
if (isLdapUser) {
utils.showMessage('Profile is managed by LDAP', 'error');
utils.showMessage('Il profilo è gestito da LDAP', 'error');
return;
}
@@ -164,13 +168,13 @@
const response = await api.put('/api/users/me/profile', data);
if (response && response.ok) {
utils.showMessage('Profile updated successfully', 'success');
utils.showMessage('Profilo aggiornato con successo', 'success');
// Update nav display
const nameEl = document.getElementById('userName');
if (nameEl) nameEl.textContent = data.name;
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to update profile', 'error');
utils.showMessage(error.detail || 'Impossibile aggiornare il profilo', 'error');
}
});
@@ -182,7 +186,7 @@
const confirmPassword = document.getElementById('confirmPassword').value;
if (newPassword !== confirmPassword) {
utils.showMessage('Passwords do not match', 'error');
utils.showMessage('Le password non corrispondono', 'error');
return;
}
@@ -193,14 +197,15 @@
const response = await api.post('/api/users/me/change-password', data);
if (response && response.ok) {
utils.showMessage('Password changed successfully', 'success');
utils.showMessage('Password cambiata con successo', 'success');
document.getElementById('passwordForm').reset();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to change password', 'error');
utils.showMessage(error.detail || 'Impossibile cambiare la password', 'error');
}
});
}
</script>
</body>
</html>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,19 +8,20 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<h1>Create Account</h1>
<p>Sign up for a new account</p>
<h1>Crea Account</h1>
<p>Registrati per un nuovo account</p>
</div>
<div id="errorMessage"></div>
<form id="registerForm">
<div class="form-group">
<label for="name">Full Name</label>
<label for="name">Nome Completo</label>
<input type="text" id="name" required autocomplete="name">
</div>
<div class="form-group">
@@ -29,13 +31,13 @@
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" required autocomplete="new-password" minlength="8">
<small>Minimum 8 characters</small>
<small>Minimo 8 caratteri</small>
</div>
<button type="submit" class="btn btn-dark btn-full">Create Account</button>
<button type="submit" class="btn btn-dark btn-full">Crea Account</button>
</form>
<div class="auth-footer">
Already have an account? <a href="/login">Sign in</a>
Hai già un account? <a href="/login">Accedi</a>
</div>
</div>
</div>
@@ -85,4 +87,5 @@
});
</script>
</body>
</html>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,31 +8,33 @@
<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>Parking Manager</h1>
<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">
<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">Loading...</div>
<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">Profile</a>
<a href="/settings" class="dropdown-item">Settings</a>
<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">Logout</button>
<button class="dropdown-item" id="logoutButton">Esci</button>
</div>
</div>
</div>
@@ -39,58 +42,41 @@
<main class="main-content">
<header class="page-header">
<h2>Settings</h2>
<h2>Impostazioni</h2>
</header>
<div class="content-wrapper">
<div class="card">
<div class="card-header">
<h3>Preferences</h3>
</div>
<div class="card-body">
<form id="settingsForm">
<div class="form-group">
<label for="weekStartDay">Week Starts On</label>
<select id="weekStartDay">
<option value="0">Sunday</option>
<option value="1">Monday</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-dark">Save Settings</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Parking Notifications</h3>
<h3>Notifiche Parcheggio</h3>
</div>
<div class="card-body">
<form id="notificationForm">
<div class="form-group">
<label class="toggle-label">
<span>Weekly Summary</span>
<span>Riepilogo Settimanale</span>
<label class="toggle-switch">
<input type="checkbox" id="notifyWeeklyParking">
<span class="toggle-slider"></span>
</label>
</label>
<small class="text-muted">Receive weekly parking assignments summary every Friday at 12:00</small>
<small class="text-muted">Ricevi il riepilogo settimanale delle assegnazioni parcheggio ogni
Venerdì alle 12:00</small>
</div>
<div class="form-group">
<label class="toggle-label">
<span>Daily Reminder</span>
<span>Promemoria Giornaliero</span>
<label class="toggle-switch">
<input type="checkbox" id="notifyDailyParking">
<span class="toggle-slider"></span>
</label>
</label>
<small class="text-muted">Receive daily parking reminder on working days</small>
<small class="text-muted">Ricevi promemoria giornaliero nei giorni lavorativi</small>
</div>
<div class="form-group" id="dailyTimeGroup" style="margin-left: 1rem;">
<label>Reminder Time</label>
<label>Orario Promemoria</label>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<select id="notifyDailyHour" style="width: 80px;">
<!-- Hours populated by JS -->
@@ -106,16 +92,17 @@
</div>
<div class="form-group">
<label class="toggle-label">
<span>Assignment Changes</span>
<span>Cambiamenti Assegnazione</span>
<label class="toggle-switch">
<input type="checkbox" id="notifyParkingChanges">
<span class="toggle-slider"></span>
</label>
</label>
<small class="text-muted">Receive immediate notifications when your parking assignment changes</small>
<small class="text-muted">Ricevi notifiche immediate quando la tua assegnazione del
parcheggio cambia</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-dark">Save Notifications</button>
<button type="submit" class="btn btn-dark">Salva Notifiche</button>
</div>
</form>
</div>
@@ -151,7 +138,7 @@
}
function populateForm() {
document.getElementById('weekStartDay').value = currentUser.week_start_day || 0;
// Notification settings
// Notification settings
document.getElementById('notifyWeeklyParking').checked = currentUser.notify_weekly_parking !== 0;
@@ -170,22 +157,7 @@
function setupEventListeners() {
// Settings form
document.getElementById('settingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
week_start_day: parseInt(document.getElementById('weekStartDay').value)
};
const response = await api.put('/api/users/me/settings', data);
if (response && response.ok) {
utils.showMessage('Settings saved successfully', 'success');
currentUser = await api.getCurrentUser();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to save settings', 'error');
}
});
// Notification form
document.getElementById('notificationForm').addEventListener('submit', async (e) => {
@@ -201,11 +173,11 @@
const response = await api.put('/api/users/me/settings', data);
if (response && response.ok) {
utils.showMessage('Notification settings saved', 'success');
utils.showMessage('Impostazioni notifiche salvate', 'success');
currentUser = await api.getCurrentUser();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to save notifications', 'error');
utils.showMessage(error.detail || 'Impossibile salvare le notifiche', 'error');
}
});
@@ -214,4 +186,5 @@
}
</script>
</body>
</html>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,31 +8,33 @@
<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>Parking Manager</h1>
<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">
<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">Loading...</div>
<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">Profile</a>
<a href="/settings" class="dropdown-item">Settings</a>
<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">Logout</button>
<button class="dropdown-item" id="logoutButton">Esci</button>
</div>
</div>
</div>
@@ -39,36 +42,46 @@
<main class="main-content">
<header class="page-header">
<h2>Team Calendar</h2>
<h2>Calendario del Team</h2>
<div class="header-actions">
<select id="viewToggle" class="form-select" style="min-width: 100px;">
<option value="week">Week</option>
<option value="month">Month</option>
</select>
<select id="managerFilter" class="form-select">
<option value="">All Managers</option>
</select>
</div>
</header>
<div class="content-wrapper">
<div class="card">
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem;">
<select id="viewToggle" class="form-select" style="min-width: 150px;">
<option value="week">Settimana</option>
<option value="month">Mese</option>
</select>
<select id="officeFilter" class="form-select" style="min-width: 200px;">
<option value="">Tutti gli Uffici</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>
</div>
<div class="calendar-header">
<button class="btn-icon" id="prevWeek">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<h3 id="currentWeek">Loading...</h3>
<h3 id="currentWeek">Caricamento...</h3>
<button class="btn-icon" id="nextWeek">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<div class="team-calendar-container">
<table class="team-calendar-table" id="teamCalendarTable">
<table class="team-calendar team-calendar-table" id="teamCalendarTable">
<thead>
<tr id="calendarHeader"></tr>
</thead>
@@ -79,71 +92,71 @@
<div class="legend">
<div class="legend-item">
<div class="legend-color status-present"></div>
<span>Present</span>
<span>In sede</span>
</div>
<div class="legend-item">
<div class="legend-color status-remote"></div>
<span>Remote</span>
<span>Remoto</span>
</div>
<div class="legend-item">
<div class="legend-color status-absent"></div>
<span>Absent</span>
<span>Assente</span>
</div>
</div>
</div>
</div>
</main>
<!-- Day Status Modal -->
<!-- Day Status Modal (Shared Structure) -->
<div class="modal" id="dayModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3 id="dayModalTitle">Mark Presence</h3>
<h3 id="dayModalTitle">Segna Presenza</h3>
<button class="modal-close" id="closeDayModal">&times;</button>
</div>
<div class="modal-body">
<p id="dayModalUser" style="margin-bottom: 1rem; font-weight: 500;"></p>
<p id="dayModalUser" style="display:none; margin-bottom: 1rem; font-weight: 500;"></p>
<div class="status-buttons">
<button class="status-btn" data-status="present">
<div class="status-icon status-present"></div>
<span>Present</span>
<span>In sede</span>
</button>
<button class="status-btn" data-status="remote">
<div class="status-icon status-remote"></div>
<span>Remote</span>
<span>Remoto</span>
</button>
<button class="status-btn" data-status="absent">
<div class="status-icon status-absent"></div>
<span>Absent</span>
<span>Assente</span>
</button>
</div>
<div id="parkingSection" style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
<button class="btn btn-secondary btn-full" id="reassignParkingBtn">Reassign Spot</button>
</div>
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Clear Presence</button>
</div>
</div>
</div>
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella
Presenza</button>
<!-- Reassign Parking Modal -->
<div class="modal" id="reassignModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Reassign Parking Spot</h3>
<button class="modal-close" id="closeReassignModal">&times;</button>
</div>
<div class="modal-body">
<p id="reassignSpotInfo" style="margin-bottom: 1rem; font-size: 0.9rem;"></p>
<div class="form-group">
<label for="reassignUser">Assign to</label>
<select id="reassignUser" required>
<option value="">Select user...</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelReassign">Cancel</button>
<button type="button" class="btn btn-dark" id="confirmReassign">Reassign</button>
<div id="parkingSection"
style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
<div id="parkingInfo" style="margin-bottom: 0.75rem; font-size: 0.9rem;"></div>
<div id="parkingActions" style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" style="flex: 1;" id="reassignParkingBtn">Cedi posto</button>
<button class="btn btn-secondary" style="flex: 1;" id="releaseParkingBtn">Lascia libero</button>
</div>
<div id="reassignForm"
style="display: none; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem;">
<div class="form-group" style="margin-bottom: 0.5rem;">
<label for="reassignUser" style="font-size: 0.9rem;">Assegna a</label>
<select id="reassignUser" class="form-control" style="width: 100%;">
<option value="">Seleziona utente...</option>
</select>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-secondary" style="flex: 1;"
id="cancelReassign">Annulla</button>
<button type="button" class="btn btn-dark" style="flex: 1;"
id="confirmReassign">Riassegna</button>
</div>
</div>
</div>
</div>
</div>
@@ -152,6 +165,8 @@
<script src="/js/api.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/modal-logic.js"></script>
<script src="/js/team-calendar.js"></script>
</body>
</html>
</html>

View File

@@ -1,37 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Team Rules - Parking Manager</title>
<title>Regole Parcheggio - 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>Parking Manager</h1>
<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">
<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">Loading...</div>
<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">Profile</a>
<a href="/settings" class="dropdown-item">Settings</a>
<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">Logout</button>
<button class="dropdown-item" id="logoutButton">Esci</button>
</div>
</div>
</div>
@@ -39,100 +42,120 @@
<main class="main-content">
<header class="page-header">
<h2>Team Rules</h2>
<h2>Regole Parcheggio</h2>
<div class="header-actions">
<select id="managerSelect" class="form-select">
<option value="">Select Manager</option>
</select>
</div>
</header>
<div class="content-wrapper" id="rulesContent" style="display: none;">
<!-- Weekly Closing Days -->
<div class="card">
<div class="card-header">
<h3>Weekly Closing Days</h3>
<div class="content-wrapper">
<!-- Office Selection Card -->
<div class="card" id="officeSelectionCard" style="margin-bottom: 1.5rem;">
<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 class="card-body">
<p class="text-muted" style="margin-bottom: 1rem;">Days of the week parking is unavailable</p>
<div class="weekday-checkboxes" id="weeklyClosingDays">
<label><input type="checkbox" data-weekday="0"> Sunday</label>
<label><input type="checkbox" data-weekday="1"> Monday</label>
<label><input type="checkbox" data-weekday="2"> Tuesday</label>
<label><input type="checkbox" data-weekday="3"> Wednesday</label>
<label><input type="checkbox" data-weekday="4"> Thursday</label>
<label><input type="checkbox" data-weekday="5"> Friday</label>
<label><input type="checkbox" data-weekday="6"> Saturday</label>
</div>
<div id="rulesContent" style="display: none;">
<!-- Weekly Closing Days -->
<div class="card">
<div class="card-header">
<h3>Giorni di Chiusura Settimanale</h3>
<button class="btn btn-primary btn-sm" id="saveWeeklyClosingDaysBtn">Salva</button>
</div>
<div class="card-body">
<p class="text-muted" style="margin-bottom: 1rem;">Giorni della settimana in cui il parcheggio
non è
disponibile</p>
<div class="weekday-checkboxes" id="weeklyClosingDays">
<label><input type="checkbox" data-weekday="1"> Lunedì</label>
<label><input type="checkbox" data-weekday="2"> Martedì</label>
<label><input type="checkbox" data-weekday="3"> Mercoledì</label>
<label><input type="checkbox" data-weekday="4"> Giovedì</label>
<label><input type="checkbox" data-weekday="5"> Venerdì</label>
<label><input type="checkbox" data-weekday="6"> Sabato</label>
<label><input type="checkbox" data-weekday="0"> Domenica</label>
</div>
</div>
</div>
</div>
<!-- Specific Closing Days -->
<div class="card">
<div class="card-header">
<h3>Specific Closing Days</h3>
<button class="btn btn-secondary btn-sm" id="addClosingDayBtn">Add</button>
<!-- Specific Closing Days -->
<div class="card">
<div class="card-header">
<h3>Giorni di Chiusura Specifici</h3>
<button class="btn btn-secondary btn-sm" id="addClosingDayBtn">Aggiungi</button>
</div>
<div class="card-body">
<p class="text-muted">Date specifiche in cui il parcheggio non è disponibile (festività, ecc.)
</p>
<div id="closingDaysList" class="rules-list"></div>
</div>
</div>
<div class="card-body">
<p class="text-muted">Specific dates when parking is unavailable (holidays, etc.)</p>
<div id="closingDaysList" class="rules-list"></div>
</div>
</div>
<!-- Parking Guarantees -->
<div class="card">
<div class="card-header">
<h3>Parking Guarantees</h3>
<button class="btn btn-secondary btn-sm" id="addGuaranteeBtn">Add</button>
<!-- Parking Guarantees -->
<div class="card">
<div class="card-header">
<h3>Garanzie di Parcheggio</h3>
<button class="btn btn-secondary btn-sm" id="addGuaranteeBtn">Aggiungi</button>
</div>
<div class="card-body">
<p class="text-muted">Utenti a cui è garantito un posto auto quando sono presenti</p>
<div id="guaranteesList" class="rules-list"></div>
</div>
</div>
<div class="card-body">
<p class="text-muted">Users guaranteed a parking spot when present</p>
<div id="guaranteesList" class="rules-list"></div>
</div>
</div>
<!-- Parking Exclusions -->
<div class="card">
<div class="card-header">
<h3>Parking Exclusions</h3>
<button class="btn btn-secondary btn-sm" id="addExclusionBtn">Add</button>
</div>
<div class="card-body">
<p class="text-muted">Users excluded from parking assignment</p>
<div id="exclusionsList" class="rules-list"></div>
<!-- Parking Exclusions -->
<div class="card">
<div class="card-header">
<h3>Esclusioni Parcheggio</h3>
<button class="btn btn-secondary btn-sm" id="addExclusionBtn">Aggiungi</button>
</div>
<div class="card-body">
<p class="text-muted">Utenti esclusi dall'assegnazione del parcheggio</p>
<div id="exclusionsList" class="rules-list"></div>
</div>
</div>
</div>
</div>
<div class="content-wrapper" id="noManagerMessage">
<div id="noOfficeMessage">
<div class="card">
<div class="card-body text-center">
<p>Select a manager to manage their parking rules</p>
<p>Seleziona un ufficio sopra per gestirne le regole di parcheggio</p>
</div>
</div>
</div>
</div>
</main>
<!-- Add Closing Day Modal -->
<div class="modal" id="closingDayModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Add Closing Day</h3>
<h3>Aggiungi Giorno di Chiusura</h3>
<button class="modal-close" id="closeClosingDayModal">&times;</button>
</div>
<div class="modal-body">
<form id="closingDayForm">
<div class="form-group">
<label for="closingDate">Date</label>
<label for="closingDate">Data Inizio</label>
<input type="date" id="closingDate" required>
</div>
<div class="form-group">
<label for="closingReason">Reason (optional)</label>
<input type="text" id="closingReason" placeholder="e.g., Company holiday">
<label for="closingEndDate">Data Fine (opzionale)</label>
<input type="date" id="closingEndDate">
<small>Lascia vuoto per singolo giorno</small>
</div>
<div class="form-group">
<label for="closingReason">Motivo (opzionale)</label>
<input type="text" id="closingReason" placeholder="es. Ferie aziendali">
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelClosingDay">Cancel</button>
<button type="submit" class="btn btn-dark">Add</button>
<button type="button" class="btn btn-secondary" id="cancelClosingDay">Annulla</button>
<button type="submit" class="btn btn-dark">Aggiungi</button>
</div>
</form>
</div>
@@ -143,30 +166,33 @@
<div class="modal" id="guaranteeModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Add Parking Guarantee</h3>
<h3>Aggiungi Garanzia Parcheggio</h3>
<button class="modal-close" id="closeGuaranteeModal">&times;</button>
</div>
<div class="modal-body">
<form id="guaranteeForm">
<div class="form-group">
<label for="guaranteeUser">User</label>
<label for="guaranteeUser">Utente</label>
<select id="guaranteeUser" required>
<option value="">Select user...</option>
<option value="">Seleziona utente...</option>
</select>
</div>
<div class="form-group">
<label for="guaranteeStartDate">Start Date (optional)</label>
<label for="guaranteeStartDate">Data Inizio (opzionale)</label>
<input type="date" id="guaranteeStartDate">
<small>Leave empty for no start limit</small>
<small>Lascia vuoto per nessun limite inziale</small>
</div>
<div class="form-group">
<label for="guaranteeEndDate">End Date (optional)</label>
<input type="date" id="guaranteeEndDate">
<small>Leave empty for no end limit</small>
<small>Lascia vuoto per nessun limite finale</small>
</div>
<div class="form-group">
<label for="guaranteeNotes">Note (opzionale)</label>
<textarea id="guaranteeNotes" class="form-control" rows="2"></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelGuarantee">Cancel</button>
<button type="submit" class="btn btn-dark">Add</button>
<button type="button" class="btn btn-secondary" id="cancelGuarantee">Annulla</button>
<button type="submit" class="btn btn-dark">Aggiungi</button>
</div>
</form>
</div>
@@ -177,30 +203,33 @@
<div class="modal" id="exclusionModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Add Parking Exclusion</h3>
<h3>Aggiungi Esclusione Parcheggio</h3>
<button class="modal-close" id="closeExclusionModal">&times;</button>
</div>
<div class="modal-body">
<form id="exclusionForm">
<div class="form-group">
<label for="exclusionUser">User</label>
<label for="exclusionUser">Utente</label>
<select id="exclusionUser" required>
<option value="">Select user...</option>
<option value="">Seleziona utente...</option>
</select>
</div>
<div class="form-group">
<label for="exclusionStartDate">Start Date (optional)</label>
<label for="exclusionStartDate">Data Inizio (opzionale)</label>
<input type="date" id="exclusionStartDate">
<small>Leave empty for no start limit</small>
<small>Lascia vuoto per nessun limite iniziale</small>
</div>
<div class="form-group">
<label for="exclusionEndDate">End Date (optional)</label>
<input type="date" id="exclusionEndDate">
<small>Leave empty for no end limit</small>
<small>Lascia vuoto per nessun limite finale</small>
</div>
<div class="form-group">
<label for="exclusionNotes">Note (opzionale)</label>
<textarea id="exclusionNotes" class="form-control" rows="2"></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelExclusion">Cancel</button>
<button type="submit" class="btn btn-dark">Add</button>
<button type="button" class="btn btn-secondary" id="cancelExclusion">Annulla</button>
<button type="submit" class="btn btn-dark">Aggiungi</button>
</div>
</form>
</div>
@@ -212,4 +241,5 @@
<script src="/js/nav.js"></script>
<script src="/js/team-rules.js"></script>
</body>
</html>
</html>

66
main.py
View File

@@ -1,3 +1,6 @@
from dotenv import load_dotenv
load_dotenv() # Carica le variabili dal file .env
"""
Parking Manager Application
FastAPI + SQLite + Vanilla JS
@@ -10,11 +13,12 @@ from contextlib import asynccontextmanager
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from datetime import datetime
from app import config
from app.routes.auth import router as auth_router
from app.routes.users import router as users_router
from app.routes.managers import router as managers_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
@@ -26,11 +30,50 @@ limiter = Limiter(key_func=get_remote_address)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize database on startup"""
config.logger.info("Starting Parking Manager application")
def log(msg):
config.logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}")
log("Starting Parking Manager application")
# Caddy Integration Logs
log("--- Caddy Integration & Handshake ---")
# Step 1: Auth / Forward Auth
log("1. Checking Caddy Forward Auth Configuration...")
status = "ENABLED (Authelia)" if config.AUTHELIA_ENABLED else "DISABLED (Internal Auth)"
log(f" - Auth Mode: {status}")
if config.AUTHELIA_ENABLED:
log(" - Configuring Trusted Headers from Caddy:")
log(f" * User: {config.AUTHELIA_HEADER_USER}")
log(f" * Name: {config.AUTHELIA_HEADER_NAME}")
log(f" * Email: {config.AUTHELIA_HEADER_EMAIL}")
log(f" * Groups: {config.AUTHELIA_HEADER_GROUPS}")
else:
log(" - No trusted headers configured (Standalone mode)")
# Step 2: CORS / Origins
log("2. Configuring Caddy CORS / Origins...")
for origin in config.ALLOWED_ORIGINS:
log(f" - Trusted Origin: {origin}")
init_db()
config.logger.info("Database initialized")
log("3. Database Connection: ESTABLISHED")
# Step 3: Network / Reachability
local_url = f"http://{config.HOST}:{config.PORT}"
# Try to find a public URL from allowed origins (excluding localhost/ips)
public_candidates = [o for o in config.ALLOWED_ORIGINS if "localhost" not in o and "127.0.0.1" not in o and not o.startswith("*")]
reachable_url = public_candidates[0] if public_candidates else local_url.replace("0.0.0.0", "localhost")
log("4. Finalizing Caddy Handshake...")
log(f" - Listening on: {config.HOST}:{config.PORT}")
log("--- Handshake Complete ---")
log(f"feedback: App reachable via Caddy at {reachable_url}")
yield
config.logger.info("Shutting down Parking Manager application")
log("Shutting down Parking Manager application")
app = FastAPI(title="Parking Manager", version="1.0.0", lifespan=lifespan)
@@ -51,13 +94,14 @@ app.add_middleware(
# API Routes
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(managers_router)
app.include_router(offices_router)
app.include_router(presence_router)
app.include_router(parking_router)
# Static Files
app.mount("/css", StaticFiles(directory=str(config.FRONTEND_DIR / "css")), name="css")
app.mount("/js", StaticFiles(directory=str(config.FRONTEND_DIR / "js")), name="js")
app.mount("/assets", StaticFiles(directory=str(config.FRONTEND_DIR / "assets")), name="assets")
# Page Routes
@@ -109,6 +153,12 @@ async def admin_users_page():
return FileResponse(config.FRONTEND_DIR / "pages" / "admin-users.html")
@app.get("/admin/offices")
async def admin_offices_page():
"""Admin Offices page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "admin-offices.html")
@app.get("/profile")
async def profile_page():
"""Profile page"""
@@ -121,6 +171,12 @@ 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")
async def favicon():
"""Favicon"""

View File

@@ -1,7 +1,12 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
pydantic[email]==2.10.3
sqlalchemy==2.0.36
python-jose[cryptography]==3.3.0
bcrypt==4.2.1
slowapi==0.1.9
fastapi==0.109.2
uvicorn[standard]==0.27.1
sqlalchemy==2.0.27
pydantic[email]==2.6.1
pydantic-settings==2.2.1
python-dotenv==1.0.1
python-jose[cryptography]==3.3.0
bcrypt==4.1.2
slowapi==0.1.9
python-multipart==0.0.9
idna<4,>=2.5
email-validator>=2.1.0.post1

View File

@@ -1,32 +0,0 @@
#!/usr/bin/env python3
"""
Notification Scheduler Script
Run this script via cron every 5 minutes:
*/5 * * * * cd /path/to/app && .venv/bin/python run_notifications.py
This will:
- Send presence reminders on Thursday at 12:00 (repeat daily until compiled)
- Send weekly parking summaries on Friday at 12:00
- Send daily parking reminders at user-configured times
- Process queued parking change notifications
"""
import sys
from database.connection import SessionLocal
from services.notifications import run_scheduled_notifications
def main():
db = SessionLocal()
try:
print("Running scheduled notifications...")
run_scheduled_notifications(db)
print("Done.")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -61,17 +61,16 @@ def authenticate_user(db: Session, email: str, password: str) -> User | None:
return user
def create_user(db: Session, email: str, password: str, name: str, manager_id: str = None, role: str = "employee") -> User:
def create_user(db: Session, email: str, password: str, name: str, role: str = "employee") -> User:
"""Create a new user"""
user = User(
id=str(uuid.uuid4()),
email=email,
password_hash=hash_password(password),
name=name,
manager_id=manager_id,
role=role,
created_at=datetime.utcnow().isoformat(),
updated_at=datetime.utcnow().isoformat()
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(user)
db.commit()

View File

@@ -6,11 +6,12 @@ Follows org-stack pattern: direct SMTP send with file fallback when disabled.
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from datetime import datetime, timedelta, date
from typing import TYPE_CHECKING, List
from app import config
from utils.helpers import generate_uuid
from database.models import NotificationType
if TYPE_CHECKING:
from sqlalchemy.orm import Session
@@ -72,35 +73,34 @@ def send_email(to_email: str, subject: str, body_html: str, body_text: str = Non
return False
def get_week_dates(reference_date: datetime) -> list[datetime]:
def get_week_dates(reference_date: date) -> list[date]:
"""Get Monday-Sunday dates for the week containing reference_date"""
monday = reference_date - timedelta(days=reference_date.weekday())
return [monday + timedelta(days=i) for i in range(7)]
def get_next_week_dates(reference_date: datetime) -> list[datetime]:
def get_next_week_dates(reference_date: date) -> list[date]:
"""Get Monday-Sunday dates for the week after reference_date"""
days_until_next_monday = 7 - reference_date.weekday()
next_monday = reference_date + timedelta(days=days_until_next_monday)
return [next_monday + timedelta(days=i) for i in range(7)]
def get_week_reference(date: datetime) -> str:
def get_week_reference(date_obj: date) -> str:
"""Get ISO week reference string (e.g., 2024-W48)"""
return date.strftime("%Y-W%W")
return date_obj.strftime("%Y-W%W")
# =============================================================================
# Notification sending functions
# =============================================================================
def notify_parking_assigned(user: "User", date: str, spot_name: str):
def notify_parking_assigned(user: "User", assignment_date: date, spot_name: str):
"""Send notification when parking spot is assigned"""
if not user.notify_parking_changes:
return
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
day_name = assignment_date.strftime("%A, %B %d")
subject = f"Parking spot assigned for {day_name}"
body_html = f"""
@@ -117,13 +117,12 @@ def notify_parking_assigned(user: "User", date: str, spot_name: str):
send_email(user.email, subject, body_html)
def notify_parking_released(user: "User", date: str, spot_name: str):
def notify_parking_released(user: "User", assignment_date: date, spot_name: str):
"""Send notification when parking spot is released"""
if not user.notify_parking_changes:
return
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
day_name = assignment_date.strftime("%A, %B %d")
subject = f"Parking spot released for {day_name}"
body_html = f"""
@@ -139,13 +138,12 @@ def notify_parking_released(user: "User", date: str, spot_name: str):
send_email(user.email, subject, body_html)
def notify_parking_reassigned(user: "User", date: str, spot_name: str, new_user_name: str):
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
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
day_name = assignment_date.strftime("%A, %B %d")
subject = f"Parking spot reassigned for {day_name}"
body_html = f"""
@@ -161,29 +159,29 @@ def notify_parking_reassigned(user: "User", date: str, spot_name: str, new_user_
send_email(user.email, subject, body_html)
def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "Session") -> bool:
def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Session") -> bool:
"""Send presence compilation reminder for next week"""
from database.models import UserPresence, NotificationLog
week_ref = get_week_reference(next_week_dates[0])
# Check if already sent today for this week
today = datetime.now().strftime("%Y-%m-%d")
today = datetime.now().date()
existing = db.query(NotificationLog).filter(
NotificationLog.user_id == user.id,
NotificationLog.notification_type == "presence_reminder",
NotificationLog.notification_type == NotificationType.PRESENCE_REMINDER,
NotificationLog.reference_date == week_ref,
NotificationLog.sent_at >= today
NotificationLog.sent_at >= datetime.combine(today, datetime.min.time())
).first()
if existing:
return False
# Check if week is compiled (at least 5 days marked)
date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates]
# DB stores dates as Date objects now
presences = db.query(UserPresence).filter(
UserPresence.user_id == user.id,
UserPresence.date.in_(date_strs)
UserPresence.date.in_(next_week_dates)
).all()
if len(presences) >= 5:
@@ -211,9 +209,9 @@ def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "S
log = NotificationLog(
id=generate_uuid(),
user_id=user.id,
notification_type="presence_reminder",
notification_type=NotificationType.PRESENCE_REMINDER,
reference_date=week_ref,
sent_at=datetime.now().isoformat()
sent_at=datetime.utcnow()
)
db.add(log)
db.commit()
@@ -222,7 +220,7 @@ def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "S
return False
def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], db: "Session") -> bool:
def send_weekly_parking_summary(user: "User", next_week_dates: List[date], db: "Session") -> bool:
"""Send weekly parking assignment summary for next week (Friday at 12)"""
from database.models import DailyParkingAssignment, NotificationLog
from services.parking import get_spot_display_name
@@ -235,7 +233,7 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
# Check if already sent for this week
existing = db.query(NotificationLog).filter(
NotificationLog.user_id == user.id,
NotificationLog.notification_type == "weekly_parking",
NotificationLog.notification_type == NotificationType.WEEKLY_PARKING,
NotificationLog.reference_date == week_ref
).first()
@@ -243,10 +241,9 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
return False
# Get parking assignments for next week
date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates]
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user.id,
DailyParkingAssignment.date.in_(date_strs)
DailyParkingAssignment.date.in_(next_week_dates)
).all()
if not assignments:
@@ -254,11 +251,11 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
# Build assignment list
assignment_lines = []
# a.date is now a date object
for a in sorted(assignments, key=lambda x: x.date):
date_obj = datetime.strptime(a.date, "%Y-%m-%d")
day_name = date_obj.strftime("%A")
spot_name = get_spot_display_name(a.spot_id, a.manager_id, db)
assignment_lines.append(f"<li>{day_name}, {date_obj.strftime('%B %d')}: Spot {spot_name}</li>")
day_name = a.date.strftime("%A")
spot_name = get_spot_display_name(a.spot_id, a.office_id, db)
assignment_lines.append(f"<li>{day_name}, {a.date.strftime('%B %d')}: Spot {spot_name}</li>")
start_date = next_week_dates[0].strftime("%B %d")
end_date = next_week_dates[-1].strftime("%B %d, %Y")
@@ -283,9 +280,9 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
log = NotificationLog(
id=generate_uuid(),
user_id=user.id,
notification_type="weekly_parking",
notification_type=NotificationType.WEEKLY_PARKING,
reference_date=week_ref,
sent_at=datetime.now().isoformat()
sent_at=datetime.utcnow()
)
db.add(log)
db.commit()
@@ -294,7 +291,7 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
return False
def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") -> bool:
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 services.parking import get_spot_display_name
@@ -302,12 +299,13 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
if not user.notify_daily_parking:
return False
date_str = date.strftime("%Y-%m-%d")
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 == "daily_parking",
NotificationLog.notification_type == NotificationType.DAILY_PARKING,
NotificationLog.reference_date == date_str
).first()
@@ -317,14 +315,14 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
# Get parking assignment for this date
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user.id,
DailyParkingAssignment.date == date_str
DailyParkingAssignment.date == assignment_date
).first()
if not assignment:
return False
spot_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
day_name = date.strftime("%A, %B %d")
spot_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
day_name = date_obj.strftime("%A, %B %d")
subject = f"Parking reminder for {day_name}"
body_html = f"""
@@ -343,9 +341,9 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
log = NotificationLog(
id=generate_uuid(),
user_id=user.id,
notification_type="daily_parking",
notification_type=NotificationType.DAILY_PARKING,
reference_date=date_str,
sent_at=datetime.now().isoformat()
sent_at=datetime.utcnow()
)
db.add(log)
db.commit()
@@ -369,18 +367,19 @@ def run_scheduled_notifications(db: "Session"):
current_hour = now.hour
current_minute = now.minute
current_weekday = now.weekday() # 0=Monday, 6=Sunday
today_date = now.date()
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(now)
next_week = get_next_week_dates(today_date)
send_presence_reminder(user, next_week, db)
# Friday at 12: Weekly parking summary
if current_weekday == 4 and current_hour == 12 and current_minute < 5:
next_week = get_next_week_dates(now)
next_week = get_next_week_dates(today_date)
send_weekly_parking_summary(user, next_week, db)
# Daily parking reminder at user's preferred time (working days only)

View File

@@ -1,49 +1,48 @@
"""
Parking Assignment Service
Manager-centric parking spot management with fairness algorithm
Office-centric parking spot management with fairness algorithm
Key concepts:
- Managers own parking spots (defined by manager_parking_quota)
- Each manager has a spot prefix (A, B, C...) for display names
- Spots are named like A1, A2, B1, B2 based on manager prefix
- Offices own parking spots (defined by Office.parking_quota)
- Each office has a spot prefix (A, B, C...) for display names
- Spots are named like A1, A2, B1, B2 based on office prefix
- Fairness: users with lowest parking_days/presence_days ratio get priority
"""
from datetime import datetime, timezone
from datetime import datetime, date, timezone, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import or_
from database.models import (
DailyParkingAssignment, User, UserPresence,
ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay
DailyParkingAssignment, User, UserPresence, Office,
ParkingGuarantee, ParkingExclusion, OfficeClosingDay, OfficeWeeklyClosingDay,
UserRole, PresenceStatus
)
from utils.helpers import generate_uuid
from app import config
def get_spot_prefix(manager: User, db: Session) -> str:
"""Get the spot prefix for a manager (from manager_spot_prefix or auto-assign)"""
if manager.manager_spot_prefix:
return manager.manager_spot_prefix
def get_spot_prefix(office: Office, db: Session) -> str:
"""Get the spot prefix for an office (from office.spot_prefix or auto-assign)"""
if office.spot_prefix:
return office.spot_prefix
# Auto-assign based on alphabetical order of managers without prefix
managers = db.query(User).filter(
User.role == "manager",
User.manager_spot_prefix == None
).order_by(User.name).all()
# Auto-assign based on alphabetical order of offices without prefix
offices = db.query(Office).filter(
Office.spot_prefix == None
).order_by(Office.name).all()
# Find existing prefixes
existing_prefixes = set(
m.manager_spot_prefix for m in db.query(User).filter(
User.role == "manager",
User.manager_spot_prefix != None
o.spot_prefix for o in db.query(Office).filter(
Office.spot_prefix != None
).all()
)
# Find first available letter
manager_index = next((i for i, m in enumerate(managers) if m.id == manager.id), 0)
office_index = next((i for i, o in enumerate(offices) if o.id == office.id), 0)
letter = 'A'
count = 0
while letter in existing_prefixes or count < manager_index:
while letter in existing_prefixes or count < office_index:
if letter not in existing_prefixes:
count += 1
letter = chr(ord(letter) + 1)
@@ -54,55 +53,58 @@ def get_spot_prefix(manager: User, db: Session) -> str:
return letter
def get_spot_display_name(spot_id: str, manager_id: str, db: Session) -> str:
def get_spot_display_name(spot_id: str, office_id: str, db: Session) -> str:
"""Get display name for a spot (e.g., 'A3' instead of 'spot-3')"""
manager = db.query(User).filter(User.id == manager_id).first()
if not manager:
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
return spot_id
prefix = get_spot_prefix(manager, db)
prefix = get_spot_prefix(office, db)
spot_number = spot_id.replace("spot-", "")
return f"{prefix}{spot_number}"
def is_closing_day(manager_id: str, date: str, db: Session) -> bool:
def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
"""
Check if date is a closing day for this manager.
Check if date is a closing day for this office.
Checks both specific closing days and weekly recurring closing days.
"""
# Check specific closing day
specific = db.query(ManagerClosingDay).filter(
ManagerClosingDay.manager_id == manager_id,
ManagerClosingDay.date == date
# Check specific closing day (single day or range)
specific = db.query(OfficeClosingDay).filter(
OfficeClosingDay.office_id == office_id,
or_(
OfficeClosingDay.date == check_date,
(OfficeClosingDay.end_date != None) & (OfficeClosingDay.date <= check_date) & (OfficeClosingDay.end_date >= check_date)
)
).first()
if specific:
return True
# Check weekly closing day
date_obj = datetime.strptime(date, "%Y-%m-%d")
weekday = date_obj.weekday() # 0=Monday in Python
# Convert to our format: 0=Sunday, 1=Monday, ..., 6=Saturday
weekday_sunday_start = (weekday + 1) % 7
# Python: 0=Monday, 6=Sunday
# DB/API: 0=Sunday, 1=Monday... (Legacy convention)
python_weekday = check_date.weekday()
db_weekday = (python_weekday + 1) % 7
weekly = db.query(ManagerWeeklyClosingDay).filter(
ManagerWeeklyClosingDay.manager_id == manager_id,
ManagerWeeklyClosingDay.weekday == weekday_sunday_start
weekly = db.query(OfficeWeeklyClosingDay).filter(
OfficeWeeklyClosingDay.office_id == office_id,
OfficeWeeklyClosingDay.weekday == db_weekday
).first()
return weekly is not None
def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session) -> int:
"""Initialize empty parking spots for a manager's pool on a given date.
def initialize_parking_pool(office_id: str, quota: int, pool_date: date, db: Session) -> int:
"""Initialize empty parking spots for an office's pool on a given date.
Returns 0 if it's a closing day (no parking available).
"""
# Don't create pool on closing days
if is_closing_day(manager_id, date, db):
if is_closing_day(office_id, pool_date, db):
return 0
existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == manager_id,
DailyParkingAssignment.date == date
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date
).count()
if existing > 0:
@@ -111,20 +113,20 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
for i in range(1, quota + 1):
spot = DailyParkingAssignment(
id=generate_uuid(),
date=date,
date=pool_date,
spot_id=f"spot-{i}",
user_id=None,
manager_id=manager_id,
created_at=datetime.now(timezone.utc).isoformat()
office_id=office_id,
created_at=datetime.now(timezone.utc)
)
db.add(spot)
db.commit()
config.logger.debug(f"Initialized {quota} parking spots for manager {manager_id} on {date}")
config.logger.debug(f"Initialized {quota} parking spots for office {office_id} on {pool_date}")
return quota
def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
"""
Calculate user's parking ratio: parking_days / presence_days
Lower ratio = higher priority for next parking spot
@@ -132,7 +134,7 @@ def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
# Count days user was present
presence_days = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.status == "present"
UserPresence.status == PresenceStatus.PRESENT
).count()
if presence_days == 0:
@@ -141,16 +143,16 @@ def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
# Count days user got parking
parking_days = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.manager_id == manager_id
DailyParkingAssignment.office_id == office_id
).count()
return parking_days / presence_days
def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> bool:
def is_user_excluded(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
"""Check if user is excluded from parking for this date"""
exclusion = db.query(ParkingExclusion).filter(
ParkingExclusion.manager_id == manager_id,
ParkingExclusion.office_id == office_id,
ParkingExclusion.user_id == user_id
).first()
@@ -158,18 +160,18 @@ def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> b
return False
# Check date range
if exclusion.start_date and date < exclusion.start_date:
if exclusion.start_date and check_date < exclusion.start_date:
return False
if exclusion.end_date and date > exclusion.end_date:
if exclusion.end_date and check_date > exclusion.end_date:
return False
return True
def has_guarantee(user_id: str, manager_id: str, date: str, db: Session) -> bool:
def has_guarantee(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
"""Check if user has a parking guarantee for this date"""
guarantee = db.query(ParkingGuarantee).filter(
ParkingGuarantee.manager_id == manager_id,
ParkingGuarantee.office_id == office_id,
ParkingGuarantee.user_id == user_id
).first()
@@ -177,28 +179,25 @@ def has_guarantee(user_id: str, manager_id: str, date: str, db: Session) -> bool
return False
# Check date range
if guarantee.start_date and date < guarantee.start_date:
if guarantee.start_date and check_date < guarantee.start_date:
return False
if guarantee.end_date and date > guarantee.end_date:
if guarantee.end_date and check_date > guarantee.end_date:
return False
return True
def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[dict]:
def get_users_wanting_parking(office_id: str, pool_date: date, db: Session) -> list[dict]:
"""
Get all users who want parking for this date, sorted by fairness priority.
Returns list of {user_id, has_guarantee, ratio}
Note: Manager is part of their own team and can get parking from their pool.
"""
# Get users who marked "present" for this date:
# - Users managed by this manager (User.manager_id == manager_id)
# - The manager themselves (User.id == manager_id)
# - Users belonging to this office
present_users = db.query(UserPresence).join(User).filter(
UserPresence.date == date,
UserPresence.status == "present",
or_(User.manager_id == manager_id, User.id == manager_id)
UserPresence.date == pool_date,
UserPresence.status == PresenceStatus.PRESENT,
User.office_id == office_id
).all()
candidates = []
@@ -206,12 +205,12 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
user_id = presence.user_id
# Skip excluded users
if is_user_excluded(user_id, manager_id, date, db):
if is_user_excluded(user_id, office_id, pool_date, db):
continue
# Skip users who already have a spot
existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.date == date,
DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id == user_id
).first()
if existing:
@@ -219,8 +218,8 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
candidates.append({
"user_id": user_id,
"has_guarantee": has_guarantee(user_id, manager_id, date, db),
"ratio": get_user_parking_ratio(user_id, manager_id, db)
"has_guarantee": has_guarantee(user_id, office_id, pool_date, db),
"ratio": get_user_parking_ratio(user_id, office_id, db)
})
# Sort: guaranteed users first, then by ratio (lowest first for fairness)
@@ -229,30 +228,30 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
return candidates
def assign_parking_fairly(manager_id: str, date: str, db: Session) -> dict:
def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
"""
Assign parking spots fairly based on parking ratio.
Called after presence is set for a date.
Returns {assigned: [...], waitlist: [...]}
"""
manager = db.query(User).filter(User.id == manager_id).first()
if not manager or not manager.manager_parking_quota:
office = db.query(Office).filter(Office.id == office_id).first()
if not office or not office.parking_quota:
return {"assigned": [], "waitlist": []}
# No parking on closing days
if is_closing_day(manager_id, date, db):
if is_closing_day(office_id, pool_date, db):
return {"assigned": [], "waitlist": [], "closed": True}
# Initialize pool
initialize_parking_pool(manager_id, manager.manager_parking_quota, date, db)
initialize_parking_pool(office_id, office.parking_quota, pool_date, db)
# Get candidates sorted by fairness
candidates = get_users_wanting_parking(manager_id, date, db)
candidates = get_users_wanting_parking(office_id, pool_date, db)
# Get available spots
free_spots = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == manager_id,
DailyParkingAssignment.date == date,
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id == None
).all()
@@ -272,11 +271,11 @@ def assign_parking_fairly(manager_id: str, date: str, db: Session) -> dict:
return {"assigned": assigned, "waitlist": waitlist}
def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) -> bool:
def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session) -> bool:
"""Release a user's parking spot and reassign to next in fairness queue"""
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == manager_id,
DailyParkingAssignment.date == date,
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id == user_id
).first()
@@ -288,7 +287,7 @@ def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) ->
db.commit()
# Try to assign to next user in fairness queue
candidates = get_users_wanting_parking(manager_id, date, db)
candidates = get_users_wanting_parking(office_id, pool_date, db)
if candidates:
assignment.user_id = candidates[0]["user_id"]
db.commit()
@@ -296,29 +295,74 @@ def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) ->
return True
def handle_presence_change(user_id: str, date: str, old_status: str, new_status: str, manager_id: str, db: Session):
def handle_presence_change(user_id: str, change_date: date, old_status: PresenceStatus, new_status: PresenceStatus, office_id: str, db: Session):
"""
Handle presence status change and update parking accordingly.
Uses fairness algorithm for assignment.
manager_id is the user's manager (from User.manager_id).
"""
# Don't process past dates
target_date = datetime.strptime(date, "%Y-%m-%d").date()
if target_date < datetime.now().date():
if change_date < datetime.utcnow().date():
return
# Get manager
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager or not manager.manager_parking_quota:
# Get office (must be valid)
office = db.query(Office).filter(Office.id == office_id).first()
if not office or not office.parking_quota:
return
# Initialize pool if needed
initialize_parking_pool(manager.id, manager.manager_parking_quota, date, db)
initialize_parking_pool(office.id, office.parking_quota, change_date, db)
if old_status == "present" and new_status in ["remote", "absent"]:
if old_status == PresenceStatus.PRESENT and new_status in [PresenceStatus.REMOTE, PresenceStatus.ABSENT]:
# User no longer coming - release their spot (will auto-reassign)
release_user_spot(manager.id, user_id, date, db)
release_user_spot(office.id, user_id, change_date, db)
elif new_status == "present":
# User coming in - run fair assignment for this date
assign_parking_fairly(manager.id, 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:
"""
Clear all parking assignments for an office on a specific date.
Returns number of cleared spots.
"""
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id != None
).all()
count = len(assignments)
for a in assignments:
a.user_id = None
db.commit()
return count
def run_batch_allocation(office_id: str, pool_date: date, db: Session) -> dict:
"""
Run the batch allocation for a specific date.
Force clears existing assignments to ensure a fair clean-slate allocation.
"""
# 1. Clear existing assignments
clear_assignments_for_office_date(office_id, pool_date, db)
# 2. Run fair allocation
return assign_parking_fairly(office_id, pool_date, db)

View File

@@ -161,17 +161,18 @@ def require_manager_or_admin(user=Depends(get_current_user)):
def check_manager_access_to_user(current_user, target_user, db: Session) -> bool:
"""
Check if current_user (manager) has access to target_user.
Admins always have access. Managers can only access users they manage.
Admins always have access. Managers can only access users in their Office.
Returns True if access granted, raises HTTPException if not.
"""
if current_user.role == "admin":
return True
if current_user.role == "manager":
if target_user.manager_id != current_user.id:
# Access granted if they are in the same office
if not current_user.office_id or target_user.office_id != current_user.office_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is not managed by you"
detail="User is not in your office"
)
return True

View File

@@ -5,6 +5,7 @@ Common helpers used across the application
import uuid
import re
from typing import TYPE_CHECKING
from database.models import UserRole
from app import config
@@ -24,7 +25,7 @@ def is_ldap_user(user: "User") -> bool:
def is_ldap_admin(user: "User") -> bool:
"""Check if user is an LDAP-managed admin"""
return is_ldap_user(user) and user.role == "admin"
return is_ldap_user(user) and user.role == UserRole.ADMIN
def validate_password(password: str) -> list[str]:

28
utils/promote_admins.py Normal file
View File

@@ -0,0 +1,28 @@
import sys
import os
from dotenv import load_dotenv
# Add parent directory to path to allow importing from root
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Load environment variables first
load_dotenv()
from database.connection import get_db_session
from database.models import User, UserRole
def promote_all_users():
print("Promoting all users to ADMIN...")
with get_db_session() as db:
users = db.query(User).all()
count = 0
for user in users:
if user.role != UserRole.ADMIN:
user.role = UserRole.ADMIN
count += 1
db.commit()
print(f"Promoted {count} users to ADMIN.")
if __name__ == "__main__":
promote_all_users()