Initial commit: Parking Manager

Features:
- Manager-centric parking spot management
- Fair assignment algorithm (parking/presence ratio)
- Presence tracking calendar
- Closing days (specific & weekly recurring)
- Guarantees and exclusions
- Authelia/LLDAP integration for SSO

Stack:
- FastAPI backend
- SQLite database
- Vanilla JS frontend
- Docker deployment
This commit is contained in:
Stefano Manfredi
2025-11-26 23:37:50 +00:00
commit c74a0ed350
49 changed files with 9094 additions and 0 deletions

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
# Parking Manager Configuration
# Security - REQUIRED: Change in production!
SECRET_KEY=change-me-to-a-random-string-at-least-32-chars
# Server
HOST=0.0.0.0
PORT=8000
# Database (SQLite path)
DATABASE_PATH=/app/data/parking.db
# CORS (comma-separated origins, or * for all)
ALLOWED_ORIGINS=https://parking.rocketscale.it
# Authentication
# Set to true when behind Authelia reverse proxy
AUTHELIA_ENABLED=false
# SMTP - Email Notifications (optional)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@rocketscale.it
SMTP_USE_TLS=true

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg
*.egg-info/
.eggs/
dist/
build/
*.manifest
*.spec
# Virtual environment
.venv/
venv/
ENV/
# Database
*.db
*.sqlite
*.sqlite3
# Environment variables
.env
.env.local
.env.production
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Testing
.pytest_cache/
.coverage
htmlcov/
# Docker (local overrides)
docker-compose.override.yml

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.12-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create data directory for SQLite
RUN mkdir -p /app/data
# Expose port
EXPOSE 8000
# Health check
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"]

179
README.md Normal file
View File

@@ -0,0 +1,179 @@
# Parking Manager
A manager-centric parking spot management application with fair assignment algorithm.
## Features
- **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
## Architecture
```
├── app/
│ ├── routes/ # API endpoints
│ │ ├── auth.py # Authentication + holidays
│ │ ├── users.py # User management
│ │ ├── offices.py # Office CRUD
│ │ ├── 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
```
## Quick Start (Development)
```bash
# Create virtual environment
python3 -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Run development server
python main.py
```
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
```
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:
- `parking-admins` → admin role
- `parking-managers` → manager role
- Others → employee role
## User Roles
| Role | Permissions |
|------|-------------|
| **admin** | Full access, manage users/offices |
| **manager** | Manage assigned offices, set rules |
| **employee** | Mark own presence, view calendar |
## 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
### 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
### Offices
- `GET /api/offices` - List offices
- `POST /api/offices` - Create office (admin)
- `PUT /api/offices/{id}` - Update office (admin)
- `DELETE /api/offices/{id}` - Delete office (admin)
### 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
### 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)
### 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
## Fairness Algorithm
Parking spots are assigned based on a fairness ratio:
```
ratio = parking_days / office_days
```
Users with the lowest ratio get priority. Guaranteed users are always assigned first.
## License
MIT

0
app/__init__.py Normal file
View File

44
app/config.py Normal file
View File

@@ -0,0 +1,44 @@
"""
Application Configuration
Environment-based settings with sensible defaults
"""
import os
from pathlib import Path
# Database
DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}")
# JWT Authentication
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 hours
# Server
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(",")
# Authelia Integration
AUTHELIA_ENABLED = os.getenv("AUTHELIA_ENABLED", "false").lower() == "true"
# Header names sent by Authelia
AUTHELIA_HEADER_USER = os.getenv("AUTHELIA_HEADER_USER", "Remote-User")
AUTHELIA_HEADER_NAME = os.getenv("AUTHELIA_HEADER_NAME", "Remote-Name")
AUTHELIA_HEADER_EMAIL = os.getenv("AUTHELIA_HEADER_EMAIL", "Remote-Email")
AUTHELIA_HEADER_GROUPS = os.getenv("AUTHELIA_HEADER_GROUPS", "Remote-Groups")
# Group to role mapping
AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking-admins")
AUTHELIA_MANAGER_GROUP = os.getenv("AUTHELIA_MANAGER_GROUP", "parking-managers")
# Email (optional)
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USER)
# Paths
BASE_DIR = Path(__file__).resolve().parent.parent
FRONTEND_DIR = BASE_DIR / "frontend"

0
app/routes/__init__.py Normal file
View File

137
app/routes/auth.py Normal file
View File

@@ -0,0 +1,137 @@
"""
Authentication Routes
Login, register, logout, and user info
"""
from fastapi import APIRouter, Depends, HTTPException, status, Response
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from database.connection import get_db
from services.auth import (
create_user, authenticate_user, create_access_token,
get_user_by_email, hash_password, verify_password
)
from utils.auth_middleware import get_current_user
from app import config
import re
router = APIRouter(prefix="/api/auth", tags=["auth"])
class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: str
office_id: str | None = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
id: str
email: str
name: str | None
office_id: str | None
role: str
manager_parking_quota: int | None = None
week_start_day: int = 0
# Notification preferences
notify_weekly_parking: int = 1
notify_daily_parking: int = 1
notify_daily_parking_hour: int = 8
notify_daily_parking_minute: int = 0
notify_parking_changes: int = 1
@router.post("/register", response_model=TokenResponse)
def register(data: RegisterRequest, db: Session = Depends(get_db)):
"""Register a new user"""
if get_user_by_email(db, data.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
if len(data.password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters"
)
user = create_user(
db=db,
email=data.email,
password=data.password,
name=data.name,
office_id=data.office_id
)
token = create_access_token(user.id, user.email)
return TokenResponse(access_token=token)
@router.post("/login", response_model=TokenResponse)
def login(data: LoginRequest, response: Response, db: Session = Depends(get_db)):
"""Login with email and password"""
user = authenticate_user(db, data.email, data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials"
)
token = create_access_token(user.id, user.email)
response.set_cookie(
key="session_token",
value=token,
httponly=True,
max_age=config.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
samesite="lax"
)
return TokenResponse(access_token=token)
@router.post("/logout")
def logout(response: Response):
"""Logout and clear session"""
response.delete_cookie("session_token")
return {"message": "Logged out"}
@router.get("/me", response_model=UserResponse)
def get_me(user=Depends(get_current_user)):
"""Get current user info"""
return UserResponse(
id=user.id,
email=user.email,
name=user.name,
office_id=user.office_id,
role=user.role,
manager_parking_quota=user.manager_parking_quota,
week_start_day=user.week_start_day or 0,
notify_weekly_parking=user.notify_weekly_parking if user.notify_weekly_parking is not None else 1,
notify_daily_parking=user.notify_daily_parking if user.notify_daily_parking is not None else 1,
notify_daily_parking_hour=user.notify_daily_parking_hour if user.notify_daily_parking_hour is not None else 8,
notify_daily_parking_minute=user.notify_daily_parking_minute if user.notify_daily_parking_minute is not None else 0,
notify_parking_changes=user.notify_parking_changes if user.notify_parking_changes is not None else 1
)
@router.get("/holidays/{year}")
def get_holidays(year: int):
"""Get public holidays for a given year"""
from services.holidays import get_holidays_for_year
if year < 2000 or year > 2100:
raise HTTPException(status_code=400, detail="Year must be between 2000 and 2100")
return get_holidays_for_year(year)

372
app/routes/managers.py Normal file
View File

@@ -0,0 +1,372 @@
"""
Manager Rules Routes
Manager settings, closing days, guarantees, and exclusions
Key concept: Managers own parking spots and set rules for all their managed offices.
Rules are set at manager level, not office level.
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import uuid
from database.connection import get_db
from database.models import (
Office, User, OfficeMembership,
ManagerClosingDay, ManagerWeeklyClosingDay,
ParkingGuarantee, ParkingExclusion
)
from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user
router = APIRouter(prefix="/api/managers", tags=["managers"])
# Request/Response Models
class ClosingDayCreate(BaseModel):
date: str # YYYY-MM-DD
reason: str | None = None
class WeeklyClosingDayCreate(BaseModel):
weekday: int # 0=Sunday, 1=Monday, ..., 6=Saturday
class GuaranteeCreate(BaseModel):
user_id: str
start_date: str | None = None
end_date: str | None = None
class ExclusionCreate(BaseModel):
user_id: str
start_date: str | None = None
end_date: str | None = None
class ManagerSettingsUpdate(BaseModel):
parking_quota: int | None = None
spot_prefix: str | None = None
# Manager listing and details
@router.get("")
def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all managers with their managed offices and parking quota"""
managers = db.query(User).filter(User.role == "manager").all()
result = []
for manager in managers:
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
result.append({
"id": manager.id,
"name": manager.name,
"email": manager.email,
"parking_quota": manager.manager_parking_quota or 0,
"spot_prefix": manager.manager_spot_prefix,
"offices": [{"id": o.id, "name": o.name} for o in offices]
})
return result
@router.get("/{manager_id}")
def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get manager details including offices and parking settings"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
return {
"id": manager.id,
"name": manager.name,
"email": manager.email,
"parking_quota": manager.manager_parking_quota or 0,
"spot_prefix": manager.manager_spot_prefix,
"offices": [{"id": o.id, "name": o.name} for o in offices]
}
@router.put("/{manager_id}/settings")
def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Update manager parking settings (admin only)"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
if data.parking_quota is not None:
if data.parking_quota < 0:
raise HTTPException(status_code=400, detail="Parking quota must be non-negative")
manager.manager_parking_quota = data.parking_quota
if data.spot_prefix is not None:
if data.spot_prefix and not data.spot_prefix.isalpha():
raise HTTPException(status_code=400, detail="Spot prefix must be a letter")
if data.spot_prefix:
data.spot_prefix = data.spot_prefix.upper()
existing = db.query(User).filter(
User.manager_spot_prefix == data.spot_prefix,
User.id != manager_id
).first()
if existing:
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()
db.commit()
return {
"id": manager.id,
"parking_quota": manager.manager_parking_quota,
"spot_prefix": manager.manager_spot_prefix
}
@router.get("/{manager_id}/users")
def get_manager_users(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all users from offices managed by this manager"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all()
managed_office_ids = [m.office_id for m in memberships]
if not managed_office_ids:
return []
users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all()
return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role, "office_id": u.office_id} for u in users]
# Closing days
@router.get("/{manager_id}/closing-days")
def get_manager_closing_days(manager_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Get closing days for a manager"""
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]
@router.post("/{manager_id}/closing-days")
def add_manager_closing_day(manager_id: str, data: ClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add a closing day for a manager"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
existing = db.query(ManagerClosingDay).filter(
ManagerClosingDay.manager_id == manager_id,
ManagerClosingDay.date == data.date
).first()
if existing:
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
closing_day = ManagerClosingDay(
id=str(uuid.uuid4()),
manager_id=manager_id,
date=data.date,
reason=data.reason
)
db.add(closing_day)
db.commit()
return {"id": closing_day.id, "message": "Closing day added"}
@router.delete("/{manager_id}/closing-days/{closing_day_id}")
def remove_manager_closing_day(manager_id: str, closing_day_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove a closing day for a manager"""
closing_day = db.query(ManagerClosingDay).filter(
ManagerClosingDay.id == closing_day_id,
ManagerClosingDay.manager_id == manager_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("/{manager_id}/weekly-closing-days")
def get_manager_weekly_closing_days(manager_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Get weekly closing days for a manager"""
days = db.query(ManagerWeeklyClosingDay).filter(
ManagerWeeklyClosingDay.manager_id == manager_id
).all()
return [{"id": d.id, "weekday": d.weekday} for d in days]
@router.post("/{manager_id}/weekly-closing-days")
def add_manager_weekly_closing_day(manager_id: str, data: WeeklyClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add a weekly closing day for a manager"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager 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(ManagerWeeklyClosingDay).filter(
ManagerWeeklyClosingDay.manager_id == manager_id,
ManagerWeeklyClosingDay.weekday == data.weekday
).first()
if existing:
raise HTTPException(status_code=400, detail="Weekly closing day already exists for this weekday")
weekly_closing = ManagerWeeklyClosingDay(
id=str(uuid.uuid4()),
manager_id=manager_id,
weekday=data.weekday
)
db.add(weekly_closing)
db.commit()
return {"id": weekly_closing.id, "message": "Weekly closing day added"}
@router.delete("/{manager_id}/weekly-closing-days/{weekly_id}")
def remove_manager_weekly_closing_day(manager_id: str, weekly_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove a weekly closing day for a manager"""
weekly_closing = db.query(ManagerWeeklyClosingDay).filter(
ManagerWeeklyClosingDay.id == weekly_id,
ManagerWeeklyClosingDay.manager_id == manager_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("/{manager_id}/guarantees")
def get_manager_guarantees(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get parking guarantees for a manager"""
guarantees = db.query(ParkingGuarantee).filter(ParkingGuarantee.manager_id == manager_id).all()
result = []
for g in guarantees:
target_user = db.query(User).filter(User.id == g.user_id).first()
result.append({
"id": g.id,
"user_id": g.user_id,
"user_name": target_user.name if target_user else None,
"start_date": g.start_date,
"end_date": g.end_date
})
return result
@router.post("/{manager_id}/guarantees")
def add_manager_guarantee(manager_id: str, data: GuaranteeCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add parking guarantee for a manager"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager 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.manager_id == manager_id,
ParkingGuarantee.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
guarantee = ParkingGuarantee(
id=str(uuid.uuid4()),
manager_id=manager_id,
user_id=data.user_id,
start_date=data.start_date,
end_date=data.end_date,
created_at=datetime.utcnow().isoformat()
)
db.add(guarantee)
db.commit()
return {"id": guarantee.id, "message": "Guarantee added"}
@router.delete("/{manager_id}/guarantees/{guarantee_id}")
def remove_manager_guarantee(manager_id: str, guarantee_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove parking guarantee for a manager"""
guarantee = db.query(ParkingGuarantee).filter(
ParkingGuarantee.id == guarantee_id,
ParkingGuarantee.manager_id == manager_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("/{manager_id}/exclusions")
def get_manager_exclusions(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get parking exclusions for a manager"""
exclusions = db.query(ParkingExclusion).filter(ParkingExclusion.manager_id == manager_id).all()
result = []
for e in exclusions:
target_user = db.query(User).filter(User.id == e.user_id).first()
result.append({
"id": e.id,
"user_id": e.user_id,
"user_name": target_user.name if target_user else None,
"start_date": e.start_date,
"end_date": e.end_date
})
return result
@router.post("/{manager_id}/exclusions")
def add_manager_exclusion(manager_id: str, data: ExclusionCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add parking exclusion for a manager"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager 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.manager_id == manager_id,
ParkingExclusion.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
exclusion = ParkingExclusion(
id=str(uuid.uuid4()),
manager_id=manager_id,
user_id=data.user_id,
start_date=data.start_date,
end_date=data.end_date,
created_at=datetime.utcnow().isoformat()
)
db.add(exclusion)
db.commit()
return {"id": exclusion.id, "message": "Exclusion added"}
@router.delete("/{manager_id}/exclusions/{exclusion_id}")
def remove_manager_exclusion(manager_id: str, exclusion_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove parking exclusion for a manager"""
exclusion = db.query(ParkingExclusion).filter(
ParkingExclusion.id == exclusion_id,
ParkingExclusion.manager_id == manager_id
).first()
if not exclusion:
raise HTTPException(status_code=404, detail="Exclusion not found")
db.delete(exclusion)
db.commit()
return {"message": "Exclusion removed"}

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

@@ -0,0 +1,197 @@
"""
Office Management Routes
Admin CRUD for offices and manager-office memberships
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import uuid
from database.connection import get_db
from database.models import Office, User, OfficeMembership
from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user
router = APIRouter(prefix="/api/offices", tags=["offices"])
# Request/Response Models
class OfficeCreate(BaseModel):
name: str
location: str | None = None
class OfficeUpdate(BaseModel):
name: str | None = None
location: str | None = None
class OfficeResponse(BaseModel):
id: str
name: str
location: str | None = None
created_at: str | None
class Config:
from_attributes = True
class AddManagerRequest(BaseModel):
user_id: str
# Office CRUD Routes
@router.get("")
def list_offices(db: Session = Depends(get_db), user=Depends(get_current_user)):
"""List all offices with counts"""
offices = db.query(Office).all()
result = []
for office in offices:
manager_count = db.query(OfficeMembership).filter(OfficeMembership.office_id == office.id).count()
employee_count = db.query(User).filter(User.office_id == office.id).count()
result.append({
"id": office.id,
"name": office.name,
"location": office.location,
"created_at": office.created_at,
"manager_count": manager_count,
"employee_count": employee_count
})
return result
@router.get("/{office_id}", response_model=OfficeResponse)
def get_office(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Get office by ID"""
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
return office
@router.post("", response_model=OfficeResponse)
def create_office(data: OfficeCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Create new office (admin only)"""
office = Office(
id=str(uuid.uuid4()),
name=data.name,
location=data.location,
created_at=datetime.utcnow().isoformat()
)
db.add(office)
db.commit()
db.refresh(office)
return office
@router.put("/{office_id}", response_model=OfficeResponse)
def update_office(office_id: str, data: OfficeUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Update 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")
if data.name is not None:
office.name = data.name
if data.location is not None:
office.location = data.location
office.updated_at = datetime.utcnow().isoformat()
db.commit()
db.refresh(office)
return office
@router.delete("/{office_id}")
def delete_office(office_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Delete 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")
if db.query(User).filter(User.office_id == office_id).count() > 0:
raise HTTPException(status_code=400, detail="Cannot delete office with assigned users")
db.delete(office)
db.commit()
return {"message": "Office deleted"}
# Office membership routes (linking managers to offices)
@router.get("/{office_id}/managers")
def get_office_managers(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get managers for an office"""
memberships = db.query(OfficeMembership).filter(OfficeMembership.office_id == office_id).all()
manager_ids = [m.user_id for m in memberships]
managers = db.query(User).filter(User.id.in_(manager_ids)).all()
return [{"id": m.id, "name": m.name, "email": m.email} for m in managers]
@router.post("/{office_id}/managers")
def add_office_manager(office_id: str, data: AddManagerRequest, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Add manager to office (admin only)"""
if not db.query(Office).filter(Office.id == office_id).first():
raise HTTPException(status_code=404, detail="Office not found")
manager = db.query(User).filter(User.id == data.user_id).first()
if not manager:
raise HTTPException(status_code=404, detail="User not found")
if manager.role != "manager":
raise HTTPException(status_code=400, detail="User must have manager role")
existing = db.query(OfficeMembership).filter(
OfficeMembership.office_id == office_id,
OfficeMembership.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Manager already assigned to office")
membership = OfficeMembership(
id=str(uuid.uuid4()),
office_id=office_id,
user_id=data.user_id,
created_at=datetime.utcnow().isoformat()
)
db.add(membership)
db.commit()
return {"message": "Manager added to office"}
@router.delete("/{office_id}/managers/{manager_id}")
def remove_office_manager(office_id: str, manager_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Remove manager from office (admin only)"""
membership = db.query(OfficeMembership).filter(
OfficeMembership.office_id == office_id,
OfficeMembership.user_id == manager_id
).first()
if not membership:
raise HTTPException(status_code=404, detail="Manager not assigned to office")
db.delete(membership)
db.commit()
return {"message": "Manager removed from office"}
# Legacy redirect for /api/offices/managers/list -> /api/managers
@router.get("/managers/list")
def list_managers_legacy(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all managers with their managed offices and parking quota (legacy endpoint)"""
managers = db.query(User).filter(User.role == "manager").all()
result = []
for manager in managers:
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
result.append({
"id": manager.id,
"name": manager.name,
"email": manager.email,
"parking_quota": manager.manager_parking_quota or 0,
"spot_prefix": manager.manager_spot_prefix,
"offices": [{"id": o.id, "name": o.name} for o in offices]
})
return result

358
app/routes/parking.py Normal file
View File

@@ -0,0 +1,358 @@
"""
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 fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import uuid
from database.connection import get_db
from database.models import DailyParkingAssignment, User, OfficeMembership
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.notifications import queue_parking_change_notification
router = APIRouter(prefix="/api/parking", tags=["parking"])
# Request/Response Models
class InitPoolRequest(BaseModel):
date: str # YYYY-MM-DD
class ManualAssignRequest(BaseModel):
manager_id: str
user_id: str
spot_id: str
date: str
class ReassignSpotRequest(BaseModel):
assignment_id: str
new_user_id: str | None # None = release spot
class AssignmentResponse(BaseModel):
id: str
date: str
spot_id: str
spot_display_name: str | None = None
user_id: str | None
manager_id: str
user_name: str | None = None
user_email: str | None = None
user_office_id: str | None = None
# 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")
quota = current_user.manager_parking_quota or 0
if quota == 0:
return {"success": True, "message": "No parking quota configured", "spots": 0}
spots = initialize_parking_pool(current_user.id, quota, request.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")
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == date)
if manager_id:
query = query.filter(DailyParkingAssignment.manager_id == manager_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)
result = AssignmentResponse(
id=assignment.id,
date=assignment.date,
spot_id=assignment.spot_id,
spot_display_name=spot_display_name,
user_id=assignment.user_id,
manager_id=assignment.manager_id
)
if assignment.user_id:
user = db.query(User).filter(User.id == assignment.user_id).first()
if user:
result.user_name = user.name
result.user_email = user.email
result.user_office_id = user.office_id
results.append(result)
return results
@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)):
"""Get current user's parking assignments"""
query = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == current_user.id
)
if start_date:
query = query.filter(DailyParkingAssignment.date >= start_date)
if end_date:
query = query.filter(DailyParkingAssignment.date <= end_date)
assignments = query.order_by(DailyParkingAssignment.date.desc()).all()
results = []
for assignment in assignments:
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
results.append(AssignmentResponse(
id=assignment.id,
date=assignment.date,
spot_id=assignment.spot_id,
spot_display_name=spot_display_name,
user_id=assignment.user_id,
manager_id=assignment.manager_id,
user_name=current_user.name,
user_email=current_user.email
))
return results
@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"""
# 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")
# 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")
# Check if spot exists and is free
spot = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == data.manager_id,
DailyParkingAssignment.date == data.date,
DailyParkingAssignment.spot_id == data.spot_id
).first()
if not spot:
raise HTTPException(status_code=404, detail="Spot not found")
if spot.user_id:
raise HTTPException(status_code=400, detail="Spot already assigned")
# Check if user already has a spot for this date (from any manager)
existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.date == data.date,
DailyParkingAssignment.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a spot for this date")
spot.user_id = data.user_id
db.commit()
spot_display_name = get_spot_display_name(data.spot_id, data.manager_id, db)
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name}
@router.post("/release-my-spot/{assignment_id}")
def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Release a parking spot assigned to the current user"""
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.id == assignment_id
).first()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.user_id != current_user.id:
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)
assignment.user_id = None
db.commit()
# Queue notification (self-release, so just confirmation)
queue_parking_change_notification(
current_user, assignment.date, "released",
spot_display_name, db=db
)
return {"message": "Parking spot released"}
@router.post("/reassign-spot")
def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Reassign a spot to another user or release it.
Allowed by: spot owner, their manager, or admin.
"""
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.id == data.assignment_id
).first()
if not assignment:
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_spot_owner = assignment.user_id == current_user.id
is_manager = current_user.id == assignment.manager_id
if not (is_admin or is_manager or is_spot_owner):
raise HTTPException(status_code=403, detail="Not authorized to reassign this spot")
# Store old user for notification
old_user_id = assignment.user_id
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)
if 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:
raise HTTPException(status_code=404, detail="User not found")
# Check user doesn't already have a spot for this date
existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == data.new_user_id,
DailyParkingAssignment.date == assignment.date,
DailyParkingAssignment.id != assignment.id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a spot for this date")
assignment.user_id = data.new_user_id
# Queue notifications
# Notify old user that spot was reassigned
if old_user and old_user.id != new_user.id:
queue_parking_change_notification(
old_user, assignment.date, "reassigned",
spot_display_name, new_user.name, db
)
# Notify new user that spot was assigned
queue_parking_change_notification(
new_user, assignment.date, "assigned",
spot_display_name, db=db
)
else:
assignment.user_id = None
# Notify old user that spot was released
if old_user:
queue_parking_change_notification(
old_user, assignment.date, "released",
spot_display_name, db=db
)
db.commit()
db.refresh(assignment)
# Build response
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
result = AssignmentResponse(
id=assignment.id,
date=assignment.date,
spot_id=assignment.spot_id,
spot_display_name=spot_display_name,
user_id=assignment.user_id,
manager_id=assignment.manager_id
)
if assignment.user_id:
user = db.query(User).filter(User.id == assignment.user_id).first()
if user:
result.user_name = user.name
result.user_email = user.email
return result
@router.get("/eligible-users/{assignment_id}")
def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get users eligible for reassignment of a parking spot.
Returns users in the same manager's offices.
"""
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.id == assignment_id
).first()
if not assignment:
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_spot_owner = assignment.user_id == current_user.id
is_manager = current_user.id == assignment.manager_id
if not (is_admin or is_manager or is_spot_owner):
raise HTTPException(status_code=403, detail="Not authorized")
# Get all users belonging to offices managed by this manager
# Get offices managed by this manager
managed_office_ids = db.query(OfficeMembership.office_id).filter(
OfficeMembership.user_id == assignment.manager_id
).all()
managed_office_ids = [o[0] for o in managed_office_ids]
# Get users in those offices
users = db.query(User).filter(
User.office_id.in_(managed_office_ids),
User.id != assignment.user_id # Exclude current holder
).all()
# Filter out users who already have a spot for this date
existing_assignments = db.query(DailyParkingAssignment.user_id).filter(
DailyParkingAssignment.date == assignment.date,
DailyParkingAssignment.user_id.isnot(None),
DailyParkingAssignment.id != assignment.id
).all()
users_with_spots = {a[0] for a in existing_assignments}
result = []
for user in users:
if user.id not in users_with_spots:
result.append({
"id": user.id,
"name": user.name,
"email": user.email,
"office_id": user.office_id
})
return result

437
app/routes/presence.py Normal file
View File

@@ -0,0 +1,437 @@
"""
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 pydantic import BaseModel
from sqlalchemy.orm import Session
import uuid
from database.connection import get_db
from database.models import UserPresence, User, DailyParkingAssignment, OfficeMembership, Office
from utils.auth_middleware import get_current_user, require_manager_or_admin, check_manager_access_to_user
from services.parking import handle_presence_change, get_spot_display_name
router = APIRouter(prefix="/api/presence", tags=["presence"])
# Request/Response Models
class PresenceMarkRequest(BaseModel):
date: str # YYYY-MM-DD
status: str # present, remote, absent
class AdminPresenceMarkRequest(BaseModel):
user_id: str
date: str
status: str
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
parking_spot_number: str | None = None
class Config:
from_attributes = True
# 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 _mark_presence_for_user(
user_id: str,
date: str,
status: str,
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
).first()
now = datetime.utcnow().isoformat()
old_status = existing.status if existing else None
if existing:
existing.status = status
existing.updated_at = now
db.commit()
db.refresh(existing)
presence = existing
else:
presence = UserPresence(
id=str(uuid.uuid4()),
user_id=user_id,
date=date,
status=status,
created_at=now,
updated_at=now
)
db.add(presence)
db.commit()
db.refresh(presence)
# Handle parking assignment
if old_status != status and target_user.office_id:
try:
handle_presence_change(
user_id, date,
old_status or "absent", status,
target_user.office_id, db
)
except Exception as e:
print(f"Warning: Parking handler failed: {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=str(uuid.uuid4()),
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
if old_status != status and target_user.office_id:
try:
handle_presence_change(
user_id, date_str,
old_status or "absent", status,
target_user.office_id, db
)
except Exception:
pass
current_date += timedelta(days=1)
db.commit()
return results
def _delete_presence(
user_id: str,
date: str,
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
).first()
if not presence:
raise HTTPException(status_code=404, detail="Presence not found")
old_status = presence.status
db.delete(presence)
db.commit()
if target_user.office_id:
try:
handle_presence_change(
user_id, date,
old_status, "absent",
target_user.office_id, db
)
except Exception:
pass
return {"message": "Presence deleted"}
# User Routes
@router.post("/mark", response_model=PresenceResponse)
def mark_presence(data: PresenceMarkRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Mark presence for a date"""
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)):
"""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)):
"""Delete presence for a date"""
return _delete_presence(current_user.id, date, db, current_user)
# Admin/Manager Routes
@router.post("/admin/mark", response_model=PresenceResponse)
def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""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_to_user(current_user, target_user, db)
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_to_user(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)):
"""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_to_user(current_user, target_user, db)
return _delete_presence(user_id, date, 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(require_manager_or_admin)):
"""Get team presences with parking info for managers/admins, filtered by manager"""
parse_date(start_date)
parse_date(end_date)
# Get users based on permissions and manager filter
if manager_id:
# Filter by specific manager's offices
managed_office_ids = [m.office_id for m in db.query(OfficeMembership).filter(
OfficeMembership.user_id == manager_id
).all()]
if not managed_office_ids:
return []
users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all()
elif current_user.role == "admin":
# Admin sees all users
users = db.query(User).all()
else:
# Manager sees only users in their managed offices
managed_ids = [m.office_id for m in current_user.managed_offices]
if not managed_ids:
return []
users = db.query(User).filter(User.office_id.in_(managed_ids)).all()
# Batch query presences and parking for all users
user_ids = [u.id for u in users]
presences = db.query(UserPresence).filter(
UserPresence.user_id.in_(user_ids),
UserPresence.date >= start_date,
UserPresence.date <= end_date
).all()
parking_assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id.in_(user_ids),
DailyParkingAssignment.date >= start_date,
DailyParkingAssignment.date <= end_date
).all()
# Build lookups
parking_lookup = {}
parking_info_lookup = {}
for p in parking_assignments:
if p.user_id not in parking_lookup:
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)
parking_info_lookup[p.user_id].append({
"id": p.id,
"date": p.date,
"spot_id": p.spot_id,
"spot_display_name": spot_display_name
})
# Build office and managed offices lookups
offices_lookup = {o.id: o.name for o in db.query(Office).all()}
managed_offices_lookup = {}
for m in db.query(OfficeMembership).all():
if m.user_id not in managed_offices_lookup:
managed_offices_lookup[m.user_id] = []
managed_offices_lookup[m.user_id].append(offices_lookup.get(m.office_id, m.office_id))
# Build response
result = []
for user in users:
user_presences = [p for p in presences if p.user_id == user.id]
# For managers, show managed offices; for others, show their office
if user.role == "manager" and user.id in managed_offices_lookup:
office_display = ", ".join(managed_offices_lookup[user.id])
else:
office_display = offices_lookup.get(user.office_id)
result.append({
"id": user.id,
"name": user.name,
"office_id": user.office_id,
"office_name": office_display,
"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, [])
})
return result
@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)):
"""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:
raise HTTPException(status_code=404, detail="User not found")
check_manager_access_to_user(current_user, target_user, db)
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]
parking_map = {}
if date_strs:
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.date.in_(date_strs)
).all()
for a in assignments:
parking_map[a.date] = get_spot_display_name(a.spot_id, a.manager_id, db)
# Build response
result = []
for presence in presences:
result.append({
"id": presence.id,
"user_id": presence.user_id,
"date": presence.date,
"status": presence.status,
"created_at": presence.created_at,
"updated_at": presence.updated_at,
"parking_spot_number": parking_map.get(presence.date)
})
return result

317
app/routes/users.py Normal file
View File

@@ -0,0 +1,317 @@
"""
User Management Routes
Admin user CRUD and user self-service (profile, settings, password)
"""
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
import uuid
import re
from database.connection import get_db
from database.models import User, Office, OfficeMembership
from utils.auth_middleware import get_current_user, require_admin
from services.auth import hash_password, verify_password
router = APIRouter(prefix="/api/users", tags=["users"])
# Request/Response Models
class UserCreate(BaseModel):
email: EmailStr
password: str
name: str | None = None
role: str = "employee"
office_id: str | None = None
class UserUpdate(BaseModel):
email: EmailStr | None = None
name: str | None = None
role: str | None = None
office_id: str | None = None
manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None
class ProfileUpdate(BaseModel):
name: str | None = None
office_id: str | None = None
class SettingsUpdate(BaseModel):
week_start_day: int | None = None
# Notification preferences
notify_weekly_parking: int | None = None
notify_daily_parking: int | None = None
notify_daily_parking_hour: int | None = None
notify_daily_parking_minute: int | None = None
notify_parking_changes: int | None = None
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
class UserResponse(BaseModel):
id: str
email: str
name: str | None
role: str
office_id: str | None
manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None
created_at: str | None
class Config:
from_attributes = True
# Admin Routes
@router.get("", response_model=List[UserResponse])
def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
"""List all users (admin only)"""
users = db.query(User).all()
return users
@router.get("/{user_id}", response_model=UserResponse)
def get_user(user_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Get user by ID (admin only)"""
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="User not found")
return target
@router.post("", response_model=UserResponse)
def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Create new user (admin only)"""
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")
if data.office_id:
if not db.query(Office).filter(Office.id == data.office_id).first():
raise HTTPException(status_code=404, detail="Office not found")
new_user = User(
id=str(uuid.uuid4()),
email=data.email,
password_hash=hash_password(data.password),
name=data.name,
role=data.role,
office_id=data.office_id,
created_at=datetime.utcnow().isoformat()
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.put("/{user_id}", response_model=UserResponse)
def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Update user (admin only)"""
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="User not found")
if data.email is not None:
existing = db.query(User).filter(User.email == data.email, User.id != user_id).first()
if existing:
raise HTTPException(status_code=400, detail="Email already in use")
target.email = data.email
if data.name is not None:
target.name = data.name
if data.role is not None:
if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role")
target.role = data.role
if data.office_id is not None:
if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first():
raise HTTPException(status_code=404, detail="Office not found")
target.office_id = data.office_id
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()
db.commit()
db.refresh(target)
return target
@router.delete("/{user_id}")
def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depends(require_admin)):
"""Delete user (admin only)"""
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="User not found")
db.delete(target)
db.commit()
return {"message": "User deleted"}
# Self-service Routes
@router.get("/me/managed-offices")
def get_managed_offices(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get offices the current user manages"""
if current_user.role == "admin":
offices = db.query(Office).all()
return {"role": "admin", "offices": [{"id": o.id, "name": o.name} for o in offices]}
if current_user.role == "manager":
memberships = db.query(OfficeMembership).filter(
OfficeMembership.user_id == current_user.id
).all()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all()
return {"role": "manager", "offices": [{"id": o.id, "name": o.name} for o in offices]}
if current_user.office_id:
office = db.query(Office).filter(Office.id == current_user.office_id).first()
if office:
return {"role": "employee", "offices": [{"id": office.id, "name": office.name}]}
return {"role": current_user.role, "offices": []}
@router.get("/me/profile")
def get_profile(current_user=Depends(get_current_user)):
"""Get current user's profile"""
return {
"id": current_user.id,
"email": current_user.email,
"name": current_user.name,
"role": current_user.role,
"office_id": current_user.office_id
}
@router.put("/me/profile")
def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Update current user's profile"""
if data.name is not None:
current_user.name = data.name
if data.office_id is not None:
if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first():
raise HTTPException(status_code=404, detail="Office not found")
current_user.office_id = data.office_id if data.office_id else None
current_user.updated_at = datetime.utcnow().isoformat()
db.commit()
return {"message": "Profile updated"}
@router.get("/me/settings")
def get_settings(current_user=Depends(get_current_user)):
"""Get current user's settings"""
return {
"week_start_day": current_user.week_start_day or 0,
"notify_weekly_parking": current_user.notify_weekly_parking if current_user.notify_weekly_parking is not None else 1,
"notify_daily_parking": current_user.notify_daily_parking if current_user.notify_daily_parking is not None else 1,
"notify_daily_parking_hour": current_user.notify_daily_parking_hour if current_user.notify_daily_parking_hour is not None else 8,
"notify_daily_parking_minute": current_user.notify_daily_parking_minute if current_user.notify_daily_parking_minute is not None else 0,
"notify_parking_changes": current_user.notify_parking_changes if current_user.notify_parking_changes is not None else 1
}
@router.put("/me/settings")
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)")
current_user.week_start_day = data.week_start_day
# Notification preferences
if data.notify_weekly_parking is not None:
current_user.notify_weekly_parking = data.notify_weekly_parking
if data.notify_daily_parking is not None:
current_user.notify_daily_parking = data.notify_daily_parking
if data.notify_daily_parking_hour is not None:
if data.notify_daily_parking_hour < 0 or data.notify_daily_parking_hour > 23:
raise HTTPException(status_code=400, detail="Hour must be 0-23")
current_user.notify_daily_parking_hour = data.notify_daily_parking_hour
if data.notify_daily_parking_minute is not None:
if data.notify_daily_parking_minute < 0 or data.notify_daily_parking_minute > 59:
raise HTTPException(status_code=400, detail="Minute must be 0-59")
current_user.notify_daily_parking_minute = data.notify_daily_parking_minute
if data.notify_parking_changes is not None:
current_user.notify_parking_changes = data.notify_parking_changes
current_user.updated_at = datetime.utcnow().isoformat()
db.commit()
return {
"message": "Settings updated",
"week_start_day": current_user.week_start_day,
"notify_weekly_parking": current_user.notify_weekly_parking,
"notify_daily_parking": current_user.notify_daily_parking,
"notify_daily_parking_hour": current_user.notify_daily_parking_hour,
"notify_daily_parking_minute": current_user.notify_daily_parking_minute,
"notify_parking_changes": current_user.notify_parking_changes
}
@router.post("/me/change-password")
def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Change current user's password"""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
# Validate new password
password = data.new_password
errors = []
if len(password) < 8:
errors.append("at least 8 characters")
if not re.search(r'[A-Z]', password):
errors.append("one uppercase letter")
if not re.search(r'[a-z]', password):
errors.append("one lowercase letter")
if not re.search(r'[0-9]', password):
errors.append("one number")
if errors:
raise HTTPException(status_code=400, detail=f"Password must contain: {', '.join(errors)}")
current_user.password_hash = hash_password(password)
current_user.updated_at = datetime.utcnow().isoformat()
db.commit()
return {"message": "Password changed"}

34
compose.yml Normal file
View File

@@ -0,0 +1,34 @@
services:
parking:
build: .
container_name: parking-manager
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
environment:
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- HOST=0.0.0.0
- PORT=8000
- DATABASE_PATH=/app/data/parking.db
- AUTHELIA_ENABLED=${AUTHELIA_ENABLED:-false}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
# SMTP (optional)
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# For production with external network (Caddy/Authelia):
# networks:
# default:
# external: true
# name: proxy

183
create_test_db.py Normal file
View File

@@ -0,0 +1,183 @@
"""
Create test database with sample data
Run: .venv/bin/python create_test_db.py
Manager-centric model:
- Managers own parking spots (manager_parking_quota)
- Each manager has a spot prefix (A, B, C...) for display names
- Offices are containers for employees (like LDAP groups)
LDAP Group Simulation:
- parking_admin: admin role
- parking_manager: manager role
- office groups (presales, design, operations): employee office assignment
"""
import uuid
from datetime import datetime, timezone
from database.connection import engine, SessionLocal
from database.models import Base, User, Office, OfficeMembership
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 offices (representing LDAP groups)
offices = [
Office(id="presales", name="Presales", location="Building A", created_at=now),
Office(id="design", name="Design", location="Building B", created_at=now),
Office(id="operations", name="Operations", location="Building C", created_at=now),
]
for office in offices:
db.add(office)
print(f"Created office: {office.name}")
db.commit()
# Create users
# LDAP groups simulation:
# admin: parking_admin, presales
# manager1: parking_manager, presales, design
# manager2: parking_manager, operations
# user1: presales
# user2: design
# user3: design
# user4: operations
# user5: operations
users_data = [
{
"id": "admin",
"email": "admin@example.com",
"name": "Admin User",
"role": "admin",
"office_id": "presales", # Primary office from LDAP groups
},
{
"id": "manager1",
"email": "manager1@example.com",
"name": "Alice Manager",
"role": "manager",
"office_id": "presales",
"manager_parking_quota": 3, # For 5 users: admin, alice, user1, user2, user3
"manager_spot_prefix": "A",
},
{
"id": "manager2",
"email": "manager2@example.com",
"name": "Bob Manager",
"role": "manager",
"office_id": "operations",
"manager_parking_quota": 2, # For 3 users: bob, user4, user5
"manager_spot_prefix": "B",
},
{
"id": "user1",
"email": "user1@example.com",
"name": "User One",
"role": "employee",
"office_id": "presales",
},
{
"id": "user2",
"email": "user2@example.com",
"name": "User Two",
"role": "employee",
"office_id": "design",
},
{
"id": "user3",
"email": "user3@example.com",
"name": "User Three",
"role": "employee",
"office_id": "design",
},
{
"id": "user4",
"email": "user4@example.com",
"name": "User Four",
"role": "employee",
"office_id": "operations",
},
{
"id": "user5",
"email": "user5@example.com",
"name": "User Five",
"role": "employee",
"office_id": "operations",
},
]
for data in users_data:
user = User(
id=data["id"],
email=data["email"],
password_hash=password_hash,
name=data["name"],
role=data["role"],
office_id=data.get("office_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()
# Create office memberships for managers
# Manager 1 (Alice) manages Presales and Design
for office_id in ["presales", "design"]:
membership = OfficeMembership(
id=str(uuid.uuid4()),
user_id="manager1",
office_id=office_id,
created_at=now
)
db.add(membership)
print(f"Created membership: Alice -> {office_id}")
# Manager 2 (Bob) manages Operations
membership = OfficeMembership(
id=str(uuid.uuid4()),
user_id="manager2",
office_id="operations",
created_at=now
)
db.add(membership)
print(f"Created membership: Bob -> operations")
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} {'Office':<12} {'Spots'}")
print("-"*60)
print(f"{'admin@example.com':<25} {'admin':<10} {'presales':<12}")
print(f"{'manager1@example.com':<25} {'manager':<10} {'presales':<12}")
print(f"{'manager2@example.com':<25} {'manager':<10} {'operations':<12}")
print(f"{'user1@example.com':<25} {'employee':<10} {'presales':<12}")
print(f"{'user2@example.com':<25} {'employee':<10} {'design':<12}")
print(f"{'user3@example.com':<25} {'employee':<10} {'design':<12}")
print(f"{'user4@example.com':<25} {'employee':<10} {'operations':<12}")
print(f"{'user5@example.com':<25} {'employee':<10} {'operations':<12}")
print("-"*60)
print("\nParking pools:")
print(" Alice (manager1): 3 spots (A1,A2,A3)")
print(" -> presales: admin, alice, user1")
print(" -> design: user2, user3")
print(" -> 5 users, 3 spots = 60% ratio target")
print()
print(" Bob (manager2): 2 spots (B1,B2)")
print(" -> operations: bob, user4, user5")
print(" -> 3 users, 2 spots = 67% ratio target")

0
database/__init__.py Normal file
View File

39
database/connection.py Normal file
View File

@@ -0,0 +1,39 @@
"""
Database Connection Management
"""
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app import config
engine = create_engine(
config.DATABASE_URL,
connect_args={"check_same_thread": False} if "sqlite" in config.DATABASE_URL else {}
)
SessionLocal = sessionmaker(bind=engine)
def get_db():
"""Database session for FastAPI dependency injection"""
db = SessionLocal()
try:
yield db
finally:
db.close()
@contextmanager
def get_db_session():
"""Database session for regular Python code"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""Create all tables"""
from database.models import Base
Base.metadata.create_all(bind=engine)

232
database/models.py Normal file
View File

@@ -0,0 +1,232 @@
"""
SQLAlchemy ORM Models
Clean, focused data models for parking management
"""
from sqlalchemy import Column, String, Integer, Text, ForeignKey, Index
from sqlalchemy.orm import relationship, declarative_base
Base = declarative_base()
class User(Base):
"""Application users"""
__tablename__ = "users"
id = Column(Text, primary_key=True)
email = Column(Text, unique=True, nullable=False)
password_hash = Column(Text)
name = Column(Text)
role = Column(Text, nullable=False, default="employee") # admin, manager, employee
office_id = Column(Text, ForeignKey("offices.id"))
# 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.
# User preferences
week_start_day = Column(Integer, default=0) # 0=Sunday, 1=Monday, ..., 6=Saturday
# 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_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
created_at = Column(Text)
updated_at = Column(Text)
# Relationships
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")
managed_offices = relationship("OfficeMembership", back_populates="user", cascade="all, delete-orphan")
__table_args__ = (
Index('idx_user_email', 'email'),
Index('idx_user_office', 'office_id'),
)
class Office(Base):
"""Office locations - containers for grouping employees"""
__tablename__ = "offices"
id = Column(Text, primary_key=True)
name = Column(Text, nullable=False)
location = Column(Text)
# Note: parking_spots removed - spots are now managed at manager level
created_at = Column(Text)
updated_at = Column(Text)
# Relationships
users = relationship("User", back_populates="office")
managers = relationship("OfficeMembership", back_populates="office", cascade="all, delete-orphan")
class OfficeMembership(Base):
"""Manager-Office relationship (which managers manage which offices)"""
__tablename__ = "office_memberships"
id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
created_at = Column(Text)
# Relationships
user = relationship("User", back_populates="managed_offices")
office = relationship("Office", back_populates="managers")
__table_args__ = (
Index('idx_membership_user', 'user_id'),
Index('idx_membership_office', 'office_id'),
Index('idx_membership_unique', 'user_id', 'office_id', unique=True),
)
class UserPresence(Base):
"""Daily presence records"""
__tablename__ = "user_presences"
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)
# Relationships
user = relationship("User", back_populates="presences")
__table_args__ = (
Index('idx_presence_user_date', 'user_id', 'date', unique=True),
Index('idx_presence_date', 'date'),
)
class DailyParkingAssignment(Base):
"""Parking spot assignments per day - spots belong to managers"""
__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)
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)
# Relationships
user = relationship("User", back_populates="assignments", foreign_keys=[user_id])
manager = relationship("User", foreign_keys=[manager_id])
__table_args__ = (
Index('idx_assignment_manager_date', 'manager_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 offices (holidays, special closures)"""
__tablename__ = "manager_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
reason = Column(Text)
# Relationships
manager = relationship("User")
__table_args__ = (
Index('idx_closing_manager_date', 'manager_id', 'date', unique=True),
)
class ManagerWeeklyClosingDay(Base):
"""Weekly recurring closing days for a manager's offices (e.g., Saturday and Sunday)"""
__tablename__ = "manager_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
# Relationships
manager = relationship("User")
__table_args__ = (
Index('idx_weekly_closing_manager_day', 'manager_id', 'weekday', unique=True),
)
class ParkingGuarantee(Base):
"""Users guaranteed a parking spot when present (set by manager)"""
__tablename__ = "parking_guarantees"
id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.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)
# Relationships
manager = relationship("User", foreign_keys=[manager_id])
user = relationship("User", foreign_keys=[user_id])
__table_args__ = (
Index('idx_guarantee_manager_user', 'manager_id', 'user_id', unique=True),
)
class ParkingExclusion(Base):
"""Users excluded from parking assignment (set by manager)"""
__tablename__ = "parking_exclusions"
id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.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)
# Relationships
manager = relationship("User", foreign_keys=[manager_id])
user = relationship("User", foreign_keys=[user_id])
__table_args__ = (
Index('idx_exclusion_manager_user', 'manager_id', 'user_id', unique=True),
)
class NotificationLog(Base):
"""Log of sent notifications to prevent duplicates"""
__tablename__ = "notification_logs"
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)
__table_args__ = (
Index('idx_notification_user_type_date', 'user_id', 'notification_type', 'reference_date'),
)
class NotificationQueue(Base):
"""Queue for pending notifications (for immediate parking change notifications)"""
__tablename__ = "notification_queue"
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
subject = Column(Text, nullable=False)
body = Column(Text, nullable=False)
created_at = Column(Text, nullable=False)
sent_at = Column(Text) # null = not sent yet
__table_args__ = (
Index('idx_queue_pending', 'sent_at'),
)

8
deploy/Caddyfile.snippet Normal file
View File

@@ -0,0 +1,8 @@
# 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
}

137
deploy/DEPLOY.md Normal file
View File

@@ -0,0 +1,137 @@
# Deployment Guide for parking.rocketscale.it
## Prerequisites
- org-stack running on rocky@rocketscale.it
- Git repository on git.rocketscale.it
## Step 1: Push to Git
```bash
# On development machine
cd /mnt/code/boilerplate/org-parking
git init
git add .
git commit -m "Initial commit: Parking Manager"
git remote add origin git@git.rocketscale.it:rocky/parking-manager.git
git push -u origin main
```
## Step 2: Clone on Server
```bash
# SSH to server
ssh rocky@rocketscale.it
# Clone into org-stack
cd ~/org-stack
git clone git@git.rocketscale.it:rocky/parking-manager.git parking
```
## Step 3: Add to .env
Add to `~/org-stack/.env`:
```bash
# Parking Manager
PARKING_SECRET_KEY=your-random-secret-key-here
```
Generate a secret key:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
## Step 4: Add to compose.yml
Add the parking service to `~/org-stack/compose.yml`:
```yaml
# ===========================================================================
# Parking Manager - Parking Spot Management
# ===========================================================================
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_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
```
Add to volumes section:
```yaml
parking_data: # Parking SQLite database
```
Add `parking` to Caddy's depends_on list.
## Step 5: Add to Caddyfile
Add to `~/org-stack/Caddyfile`:
```
# Parking Manager - Protected by Authelia
parking.rocketscale.it {
import auth
reverse_proxy parking:8000
}
```
## Step 6: Create LLDAP Groups
In lldap (https://ldap.rocketscale.it):
1. Create group: `parking-admins`
2. Create group: `parking-managers`
3. Add yourself to `parking-admins`
## Step 7: Deploy
```bash
cd ~/org-stack
# Build and start parking service
docker compose build parking
docker compose up -d parking
# Reload Caddy to pick up new domain
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
# Check logs
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
### 401 Unauthorized
- Check Authelia headers are being passed
- Check `docker compose logs authelia`
### User has wrong role
- Verify LLDAP group membership
- Roles sync on each login
### Database errors
- Check volume mount: `docker compose exec parking ls -la /app/data`
- Check permissions: `docker compose exec parking id`

View File

@@ -0,0 +1,32 @@
# 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

1749
frontend/css/styles.css Normal file

File diff suppressed because it is too large Load Diff

4
frontend/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#1a1a1a"/>
<text x="50" y="70" font-family="Arial, sans-serif" font-size="60" font-weight="bold" fill="white" text-anchor="middle">P</text>
</svg>

After

Width:  |  Height:  |  Size: 259 B

247
frontend/js/admin-users.js Normal file
View File

@@ -0,0 +1,247 @@
/**
* Admin Users Page
* Manage all users in the system
*/
let currentUser = null;
let users = [];
let offices = [];
let managedOfficesMap = {}; // user_id -> [office_ids]
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
if (!currentUser) return;
// Only admins can access
if (currentUser.role !== 'admin') {
window.location.href = '/presence';
return;
}
await Promise.all([loadUsers(), loadOffices()]);
await loadManagedOffices();
renderUsers();
setupEventListeners();
});
async function loadUsers() {
const response = await api.get('/api/users');
if (response && response.ok) {
users = await response.json();
}
}
async function loadOffices() {
const response = await api.get('/api/offices');
if (response && response.ok) {
offices = await response.json();
const select = document.getElementById('editOffice');
offices.forEach(office => {
const option = document.createElement('option');
option.value = office.id;
option.textContent = office.name;
select.appendChild(option);
});
}
}
async function loadManagedOffices() {
// Load managed offices for all managers
const response = await api.get('/api/offices/managers/list');
if (response && response.ok) {
const managers = await response.json();
managedOfficesMap = {};
managers.forEach(m => {
managedOfficesMap[m.id] = m.offices.map(o => o.id);
});
}
}
function getManagedOfficeNames(userId) {
const officeIds = managedOfficesMap[userId] || [];
if (officeIds.length === 0) return '-';
return officeIds.map(id => {
const office = offices.find(o => o.id === id);
return office ? office.name : id;
}).join(', ');
}
function renderUsers(filter = '') {
const tbody = document.getElementById('usersBody');
const filtered = filter
? users.filter(u =>
u.name.toLowerCase().includes(filter.toLowerCase()) ||
u.email.toLowerCase().includes(filter.toLowerCase())
)
: users;
if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center">No users found</td></tr>';
return;
}
tbody.innerHTML = filtered.map(user => {
const office = offices.find(o => o.id === user.office_id);
const isManager = user.role === 'manager';
const managedOffices = isManager ? getManagedOfficeNames(user.id) : '-';
return `
<tr>
<td>${user.name}</td>
<td>${user.email}</td>
<td><span class="badge badge-${user.role}">${user.role}</span></td>
<td>${office ? office.name : '-'}</td>
<td>${managedOffices}</td>
<td>${isManager ? (user.manager_parking_quota || 0) : '-'}</td>
<td>${isManager ? (user.manager_spot_prefix || '-') : '-'}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="editUser('${user.id}')">Edit</button>
${user.id !== currentUser.id ? `
<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')">Delete</button>
` : ''}
</td>
</tr>
`;
}).join('');
}
function editUser(userId) {
const user = users.find(u => u.id === userId);
if (!user) return;
document.getElementById('userId').value = user.id;
document.getElementById('editName').value = user.name;
document.getElementById('editEmail').value = user.email;
document.getElementById('editRole').value = user.role;
document.getElementById('editOffice').value = user.office_id || '';
document.getElementById('editQuota').value = user.manager_parking_quota || 0;
document.getElementById('editPrefix').value = user.manager_spot_prefix || '';
const isManager = user.role === 'manager';
// Show/hide manager fields based on role
document.getElementById('quotaGroup').style.display = isManager ? 'block' : 'none';
document.getElementById('prefixGroup').style.display = isManager ? 'block' : 'none';
document.getElementById('managedOfficesGroup').style.display = isManager ? 'block' : 'none';
// Build managed offices checkboxes
const checkboxContainer = document.getElementById('managedOfficesCheckboxes');
const userManagedOffices = managedOfficesMap[userId] || [];
checkboxContainer.innerHTML = offices.map(office => `
<label class="checkbox-label">
<input type="checkbox" name="managedOffice" value="${office.id}"
${userManagedOffices.includes(office.id) ? 'checked' : ''}>
${office.name}
</label>
`).join('');
document.getElementById('userModal').style.display = 'flex';
}
async function deleteUser(userId) {
const user = users.find(u => u.id === userId);
if (!user) return;
if (!confirm(`Delete user "${user.name}"? This cannot be undone.`)) return;
const response = await api.delete(`/api/users/${userId}`);
if (response && response.ok) {
await loadUsers();
await loadManagedOffices();
renderUsers();
utils.showMessage('User deleted', 'success');
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to delete user', 'error');
}
}
function setupEventListeners() {
// Search
document.getElementById('searchInput').addEventListener('input', (e) => {
renderUsers(e.target.value);
});
// Modal
document.getElementById('closeUserModal').addEventListener('click', () => {
document.getElementById('userModal').style.display = 'none';
});
document.getElementById('cancelUser').addEventListener('click', () => {
document.getElementById('userModal').style.display = 'none';
});
// Role change shows/hides manager fields
document.getElementById('editRole').addEventListener('change', (e) => {
const isManager = e.target.value === 'manager';
document.getElementById('quotaGroup').style.display = isManager ? 'block' : 'none';
document.getElementById('prefixGroup').style.display = isManager ? 'block' : 'none';
document.getElementById('managedOfficesGroup').style.display = isManager ? 'block' : 'none';
});
// Form submit
document.getElementById('userForm').addEventListener('submit', async (e) => {
e.preventDefault();
const userId = document.getElementById('userId').value;
const role = document.getElementById('editRole').value;
const data = {
name: document.getElementById('editName').value,
role: role,
office_id: document.getElementById('editOffice').value || null
};
if (role === 'manager') {
data.manager_parking_quota = parseInt(document.getElementById('editQuota').value) || 0;
data.manager_spot_prefix = document.getElementById('editPrefix').value.toUpperCase() || null;
}
const response = await api.put(`/api/users/${userId}`, data);
if (response && response.ok) {
// Update managed offices if manager
if (role === 'manager') {
await updateManagedOffices(userId);
}
document.getElementById('userModal').style.display = 'none';
await loadUsers();
await loadManagedOffices();
renderUsers();
utils.showMessage('User updated', 'success');
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to update user', 'error');
}
});
utils.setupModalClose('userModal');
}
async function updateManagedOffices(userId) {
// Get currently selected offices
const checkboxes = document.querySelectorAll('input[name="managedOffice"]:checked');
const selectedOfficeIds = Array.from(checkboxes).map(cb => cb.value);
// Get current managed offices
const currentOfficeIds = managedOfficesMap[userId] || [];
// Find offices to add and remove
const toAdd = selectedOfficeIds.filter(id => !currentOfficeIds.includes(id));
const toRemove = currentOfficeIds.filter(id => !selectedOfficeIds.includes(id));
// Add new memberships
for (const officeId of toAdd) {
await api.post(`/api/offices/${officeId}/managers`, { user_id: userId });
}
// Remove old memberships
for (const officeId of toRemove) {
await api.delete(`/api/offices/${officeId}/managers/${userId}`);
}
}
// Make functions globally accessible
window.editUser = editUser;
window.deleteUser = deleteUser;

174
frontend/js/api.js Normal file
View File

@@ -0,0 +1,174 @@
/**
* API Client Wrapper
* Centralized API communication with auth handling
*/
const api = {
/**
* Get the auth token from localStorage
*/
getToken() {
return localStorage.getItem('access_token');
},
/**
* Set the auth token
*/
setToken(token) {
localStorage.setItem('access_token', token);
},
/**
* Clear the auth token
*/
clearToken() {
localStorage.removeItem('access_token');
},
/**
* Check if user is authenticated
*/
isAuthenticated() {
return !!this.getToken();
},
/**
* Redirect to login if not authenticated
*/
requireAuth() {
if (!this.isAuthenticated()) {
window.location.href = '/login';
return false;
}
return true;
},
/**
* Make an API request
*/
async request(method, url, data = null) {
const options = {
method,
headers: {}
};
const token = this.getToken();
if (token) {
options.headers['Authorization'] = `Bearer ${token}`;
}
if (data) {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(data);
}
const response = await fetch(url, options);
// Handle 401 - redirect to login
if (response.status === 401) {
this.clearToken();
window.location.href = '/login';
return null;
}
return response;
},
/**
* GET request
*/
async get(url) {
return this.request('GET', url);
},
/**
* POST request
*/
async post(url, data) {
return this.request('POST', url, data);
},
/**
* PUT request
*/
async put(url, data) {
return this.request('PUT', url, data);
},
/**
* PATCH request
*/
async patch(url, data) {
return this.request('PATCH', url, data);
},
/**
* DELETE request
*/
async delete(url) {
return this.request('DELETE', url);
},
/**
* Get current user info
*/
async getCurrentUser() {
const response = await this.get('/api/auth/me');
if (response && response.ok) {
return await response.json();
}
return null;
},
/**
* Login
*/
async login(email, password) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (response.ok) {
const data = await response.json();
this.setToken(data.access_token);
return { success: true };
}
const error = await response.json();
return { success: false, error: error.detail || 'Login failed' };
},
/**
* Register
*/
async register(email, password, name, officeId = null) {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name, office_id: officeId })
});
if (response.ok) {
const data = await response.json();
this.setToken(data.access_token);
return { success: true };
}
const error = await response.json();
return { success: false, error: error.detail || 'Registration failed' };
},
/**
* Logout
*/
async logout() {
await this.post('/api/auth/logout', {});
this.clearToken();
window.location.href = '/login';
}
};
// Make globally available
window.api = api;

183
frontend/js/nav.js Normal file
View File

@@ -0,0 +1,183 @@
/**
* Navigation Component
* Sidebar navigation and user menu
*/
const MENU_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>`;
const ICONS = {
calendar: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>`,
users: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>`,
rules: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3L22 4"></path>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>`,
user: `<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>`,
building: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="6" y="2" width="12" height="20"></rect>
<rect x="9" y="6" width="1.5" height="1.5" fill="currentColor"></rect>
<rect x="13.5" y="6" width="1.5" height="1.5" fill="currentColor"></rect>
<rect x="9" y="11" width="1.5" height="1.5" fill="currentColor"></rect>
<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>`
};
const NAV_ITEMS = [
{ href: '/presence', icon: 'calendar', label: 'My Presence' },
{ href: '/team-calendar', icon: 'users', label: 'Team Calendar', roles: ['admin', 'manager'] },
{ href: '/office-rules', icon: 'rules', label: 'Office Rules', roles: ['admin', 'manager'] },
{ href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] }
];
function getIcon(name) {
return ICONS[name] || '';
}
function canAccessNavItem(item, userRole) {
if (!item.roles || item.roles.length === 0) return true;
return userRole && item.roles.includes(userRole);
}
function renderNav(currentPath, userRole) {
return NAV_ITEMS
.filter(item => canAccessNavItem(item, userRole))
.map(item => {
const isActive = item.href === currentPath ||
(currentPath !== '/' && item.href !== '/' && currentPath.startsWith(item.href));
const activeClass = isActive ? ' active' : '';
return `<a href="${item.href}" class="nav-item${activeClass}">
${getIcon(item.icon)}
<span>${item.label}</span>
</a>`;
}).join('\n');
}
async function initNav() {
const navContainer = document.querySelector('.sidebar-nav');
if (!navContainer) return;
const currentPath = window.location.pathname;
let userRole = null;
let currentUser = null;
// Get user info
if (api.isAuthenticated()) {
currentUser = await api.getCurrentUser();
if (currentUser) {
userRole = currentUser.role;
}
}
// Render navigation
navContainer.innerHTML = renderNav(currentPath, userRole);
// Update user info in sidebar
if (currentUser) {
const userName = document.getElementById('userName');
const userRole = document.getElementById('userRole');
if (userName) userName.textContent = currentUser.name || 'User';
if (userRole) userRole.textContent = currentUser.role || '-';
}
// Setup user menu
setupUserMenu();
// Setup mobile menu
setupMobileMenu();
}
function setupMobileMenu() {
const sidebar = document.querySelector('.sidebar');
const pageHeader = document.querySelector('.page-header');
if (!sidebar || !pageHeader) return;
// Add menu toggle button to page header (at the start)
const menuToggle = document.createElement('button');
menuToggle.className = 'menu-toggle';
menuToggle.innerHTML = MENU_ICON;
menuToggle.setAttribute('aria-label', 'Toggle menu');
pageHeader.insertBefore(menuToggle, pageHeader.firstChild);
// Add overlay
const overlay = document.createElement('div');
overlay.className = 'sidebar-overlay';
document.body.appendChild(overlay);
// Toggle sidebar
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('open');
overlay.classList.toggle('open');
});
// Close sidebar when clicking overlay
overlay.addEventListener('click', () => {
sidebar.classList.remove('open');
overlay.classList.remove('open');
});
// Close sidebar when clicking a nav item (on mobile)
sidebar.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.remove('open');
overlay.classList.remove('open');
}
});
});
}
function setupUserMenu() {
const userMenuButton = document.getElementById('userMenuButton');
const userDropdown = document.getElementById('userDropdown');
const logoutButton = document.getElementById('logoutButton');
if (userMenuButton && userDropdown) {
userMenuButton.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = userDropdown.style.display === 'block';
userDropdown.style.display = isOpen ? 'none' : 'block';
});
document.addEventListener('click', () => {
userDropdown.style.display = 'none';
});
userDropdown.addEventListener('click', (e) => e.stopPropagation());
}
if (logoutButton) {
logoutButton.addEventListener('click', () => {
api.logout();
});
}
}
// Export for use in other scripts
window.getIcon = getIcon;
// Auto-initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initNav);
} else {
initNav();
}

376
frontend/js/office-rules.js Normal file
View File

@@ -0,0 +1,376 @@
/**
* Office Rules Page
* Manage closing days, parking guarantees, and exclusions
*
* Rules are set at manager level and apply to all offices managed by that manager.
*/
let currentUser = null;
let selectedManagerId = null;
let managerUsers = [];
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
if (!currentUser) return;
// Only managers and admins can access
if (currentUser.role === 'employee') {
window.location.href = '/presence';
return;
}
await loadManagers();
setupEventListeners();
});
async function loadManagers() {
const response = await api.get('/api/offices/managers/list');
if (response && response.ok) {
const managers = await response.json();
const select = document.getElementById('officeSelect');
// Filter to managers this user can see
let filteredManagers = managers;
if (currentUser.role === 'manager') {
// Manager only sees themselves
filteredManagers = managers.filter(m => m.id === currentUser.id);
}
// Show managers in dropdown
let totalManagers = 0;
let firstManagerId = null;
filteredManagers.forEach(manager => {
const option = document.createElement('option');
option.value = manager.id;
// Show manager name and count of offices
const officeCount = manager.offices.length;
const officeNames = manager.offices.map(o => o.name).join(', ');
if (officeCount > 0) {
option.textContent = `${manager.name} (${officeNames})`;
} else {
option.textContent = `${manager.name} (no offices)`;
}
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);
}
}
}
async function selectManager(managerId) {
selectedManagerId = managerId;
if (!managerId) {
document.getElementById('rulesContent').style.display = 'none';
document.getElementById('noOfficeMessage').style.display = 'block';
return;
}
document.getElementById('rulesContent').style.display = 'block';
document.getElementById('noOfficeMessage').style.display = 'none';
await Promise.all([
loadWeeklyClosingDays(),
loadClosingDays(),
loadGuarantees(),
loadExclusions(),
loadManagerUsers()
]);
}
async function loadWeeklyClosingDays() {
const response = await api.get(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`);
if (response && response.ok) {
const days = await response.json();
const weekdays = 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);
});
}
}
async function loadManagerUsers() {
const response = await api.get(`/api/offices/managers/${selectedManagerId}/users`);
if (response && response.ok) {
managerUsers = await response.json();
updateUserSelects();
}
}
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/offices/managers/${selectedManagerId}/closing-days`);
const container = document.getElementById('closingDaysList');
if (response && response.ok) {
const days = await response.json();
if (days.length === 0) {
container.innerHTML = '';
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>` : ''}
</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>
</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 loadGuarantees() {
const response = await api.get(`/api/offices/managers/${selectedManagerId}/guarantees`);
const container = document.getElementById('guaranteesList');
if (response && response.ok) {
const guarantees = await response.json();
if (guarantees.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = guarantees.map(g => {
const dateRange = formatDateRange(g.start_date, g.end_date);
return `
<div class="rule-item">
<div class="rule-info">
<span class="rule-name">${g.user_name}</span>
${dateRange ? `<span class="rule-date-range">${dateRange}</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>
</button>
</div>
`}).join('');
}
}
async function loadExclusions() {
const response = await api.get(`/api/offices/managers/${selectedManagerId}/exclusions`);
const container = document.getElementById('exclusionsList');
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/offices/managers/${selectedManagerId}/closing-days/${id}`);
if (response && response.ok) {
await loadClosingDays();
}
}
async function deleteGuarantee(id) {
if (!confirm('Remove this parking guarantee?')) return;
const response = await api.delete(`/api/offices/managers/${selectedManagerId}/guarantees/${id}`);
if (response && response.ok) {
await loadGuarantees();
}
}
async function deleteExclusion(id) {
if (!confirm('Remove this parking exclusion?')) return;
const response = await api.delete(`/api/offices/managers/${selectedManagerId}/exclusions/${id}`);
if (response && response.ok) {
await loadExclusions();
}
}
function setupEventListeners() {
// Manager selection
document.getElementById('officeSelect').addEventListener('change', (e) => {
selectManager(e.target.value);
});
// 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/offices/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/offices/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/offices/managers/${selectedManagerId}/weekly-closing-days/${day.id}`);
if (!deleteResponse || !deleteResponse.ok) {
e.target.checked = true;
}
}
}
}
});
});
// Modal openers
document.getElementById('addClosingDayBtn').addEventListener('click', () => {
document.getElementById('closingDayForm').reset();
document.getElementById('closingDayModal').style.display = 'flex';
});
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/offices/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/offices/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/offices/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
window.deleteClosingDay = deleteClosingDay;
window.deleteGuarantee = deleteGuarantee;
window.deleteExclusion = deleteExclusion;

370
frontend/js/presence.js Normal file
View File

@@ -0,0 +1,370 @@
/**
* My Presence Page
* Personal calendar for marking daily presence
*/
let currentUser = null;
let currentDate = new Date();
let presenceData = {};
let parkingData = {};
let currentAssignmentId = null;
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
if (!currentUser) return;
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
setupEventListeners();
});
async function loadPresences() {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = utils.formatDate(firstDay);
const endDate = utils.formatDate(lastDay);
const response = await api.get(`/api/presence/my-presences?start_date=${startDate}&end_date=${endDate}`);
if (response && response.ok) {
const presences = await response.json();
presenceData = {};
presences.forEach(p => {
presenceData[p.date] = p;
});
}
}
async function loadParkingAssignments() {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = utils.formatDate(firstDay);
const endDate = utils.formatDate(lastDay);
const response = await api.get(`/api/parking/my-assignments?start_date=${startDate}&end_date=${endDate}`);
if (response && response.ok) {
const assignments = await response.json();
parkingData = {};
assignments.forEach(a => {
parkingData[a.date] = a;
});
}
}
function renderCalendar() {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const weekStartDay = currentUser.week_start_day || 0; // 0=Sunday, 1=Monday
// Update month header
document.getElementById('currentMonth').textContent = `${utils.getMonthName(month)} ${year}`;
// Get calendar info
const daysInMonth = utils.getDaysInMonth(year, month);
const firstDayOfMonth = new Date(year, month, 1).getDay(); // 0=Sunday
const today = new Date();
// Calculate offset based on week start preference
let firstDayOffset = firstDayOfMonth - weekStartDay;
if (firstDayOffset < 0) firstDayOffset += 7;
// Build calendar grid
const grid = document.getElementById('calendarGrid');
grid.innerHTML = '';
// Day headers - reorder based on week start day
const allDayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const dayNames = [];
for (let i = 0; i < 7; i++) {
dayNames.push(allDayNames[(weekStartDay + i) % 7]);
}
dayNames.forEach(name => {
const header = document.createElement('div');
header.className = 'calendar-day';
header.style.cursor = 'default';
header.style.fontWeight = '600';
header.style.fontSize = '0.75rem';
header.textContent = name;
grid.appendChild(header);
});
// Empty cells before first day
for (let i = 0; i < firstDayOffset; i++) {
const empty = document.createElement('div');
empty.className = 'calendar-day';
empty.style.visibility = 'hidden';
grid.appendChild(empty);
}
// Day cells
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dateStr = utils.formatDate(date);
const dayOfWeek = date.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isHoliday = utils.isItalianHoliday(date);
const isToday = date.toDateString() === today.toDateString();
const presence = presenceData[dateStr];
const parking = parkingData[dateStr];
const cell = document.createElement('div');
cell.className = 'calendar-day';
cell.dataset.date = dateStr;
if (isWeekend) cell.classList.add('weekend');
if (isHoliday) cell.classList.add('holiday');
if (isToday) cell.classList.add('today');
if (presence) {
cell.classList.add(`status-${presence.status}`);
}
// Show parking badge if assigned
const parkingBadge = parking
? `<span class="parking-badge">${parking.spot_display_name || parking.spot_id}</span>`
: '';
cell.innerHTML = `
<div class="day-number">${day}</div>
${parkingBadge}
`;
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');
}
});
// 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;
const response = await api.post('/api/presence/mark', { date, status });
if (response && response.ok) {
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
modal.style.display = 'none';
} else {
const error = await response.json();
alert(error.detail || 'Failed to mark presence');
}
}
async function clearPresence() {
const modal = document.getElementById('dayModal');
const date = modal.dataset.date;
if (!confirm('Clear presence for this date?')) return;
const response = await api.delete(`/api/presence/${date}`);
if (response && response.ok) {
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
modal.style.display = 'none';
}
}
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;
const response = await api.post(`/api/parking/release-my-spot/${assignmentId}`);
if (response && response.ok) {
await loadParkingAssignments();
renderCalendar();
modal.style.display = 'none';
} else {
const error = await response.json();
alert(error.detail || 'Failed to release parking spot');
}
}
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;
if (!assignmentId || !newUserId) {
alert('Please select a user');
return;
}
const response = await api.post('/api/parking/reassign-spot', {
assignment_id: assignmentId,
new_user_id: newUserId
});
if (response && response.ok) {
await loadParkingAssignments();
renderCalendar();
document.getElementById('reassignModal').style.display = 'none';
} else {
const error = await response.json();
alert(error.detail || 'Failed to reassign parking spot');
}
}
function setupEventListeners() {
// Month navigation
document.getElementById('prevMonth').addEventListener('click', async () => {
currentDate.setMonth(currentDate.getMonth() - 1);
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
});
document.getElementById('nextMonth').addEventListener('click', async () => {
currentDate.setMonth(currentDate.getMonth() + 1);
await Promise.all([loadPresences(), loadParkingAssignments()]);
renderCalendar();
});
// Day modal
document.getElementById('closeDayModal').addEventListener('click', () => {
document.getElementById('dayModal').style.display = 'none';
});
document.querySelectorAll('.status-btn').forEach(btn => {
btn.addEventListener('click', () => markPresence(btn.dataset.status));
});
document.getElementById('clearDayBtn').addEventListener('click', clearPresence);
document.getElementById('releaseParkingBtn').addEventListener('click', releaseParking);
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');
// Bulk mark
document.getElementById('bulkMarkBtn').addEventListener('click', () => {
document.getElementById('bulkMarkModal').style.display = 'flex';
});
document.getElementById('closeBulkModal').addEventListener('click', () => {
document.getElementById('bulkMarkModal').style.display = 'none';
});
document.getElementById('cancelBulk').addEventListener('click', () => {
document.getElementById('bulkMarkModal').style.display = 'none';
});
utils.setupModalClose('bulkMarkModal');
document.getElementById('bulkMarkForm').addEventListener('submit', async (e) => {
e.preventDefault();
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const status = document.getElementById('bulkStatus').value;
const weekdaysOnly = document.getElementById('weekdaysOnly').checked;
const data = { start_date: startDate, end_date: endDate, status };
if (weekdaysOnly) {
data.days = [1, 2, 3, 4, 5]; // Mon-Fri (JS weekday)
}
const response = await api.post('/api/presence/mark-bulk', data);
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();
} else {
const error = await response.json();
alert(error.detail || 'Failed to bulk mark');
}
});
}

View File

@@ -0,0 +1,408 @@
/**
* Team Calendar Page
* Shows presence and parking for all team members
* Filtered by manager (manager-centric model)
*/
let currentUser = null;
let currentStartDate = null;
let viewMode = 'week'; // 'week' or 'month'
let managers = [];
let teamData = [];
let parkingDataLookup = {};
let parkingAssignmentLookup = {};
let selectedUserId = null;
let selectedDate = null;
let currentAssignmentId = null;
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
if (!currentUser) return;
// Only managers and admins can view
if (currentUser.role === 'employee') {
window.location.href = '/presence';
return;
}
// Initialize start date based on week start preference
const weekStartDay = currentUser.week_start_day || 0;
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
await loadManagers();
await loadTeamData();
renderCalendar();
setupEventListeners();
});
async function loadManagers() {
const response = await api.get('/api/offices/managers/list');
if (response && response.ok) {
managers = await response.json();
const select = document.getElementById('managerFilter');
// Filter managers based on user role
let filteredManagers = managers;
if (currentUser.role === 'manager') {
// Manager only sees themselves
filteredManagers = managers.filter(m => m.id === currentUser.id);
}
filteredManagers.forEach(manager => {
const option = document.createElement('option');
option.value = manager.id;
const officeNames = manager.offices.map(o => o.name).join(', ');
option.textContent = `${manager.name} (${officeNames})`;
select.appendChild(option);
});
// Auto-select if only one manager (for manager role)
if (filteredManagers.length === 1) {
select.value = filteredManagers[0].id;
}
}
}
function getDateRange() {
let startDate = new Date(currentStartDate);
let endDate = new Date(currentStartDate);
if (viewMode === 'week') {
endDate.setDate(endDate.getDate() + 6);
} else {
// Month view - start from first day of month
startDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1);
endDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth() + 1, 0);
}
return { startDate, endDate };
}
async function loadTeamData() {
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 response = await api.get(url);
if (response && response.ok) {
teamData = await response.json();
// Build parking lookup with spot names and assignment IDs
parkingDataLookup = {};
parkingAssignmentLookup = {};
teamData.forEach(member => {
if (member.parking_info) {
member.parking_info.forEach(p => {
const key = `${member.id}_${p.date}`;
parkingDataLookup[key] = p.spot_display_name || p.spot_id;
parkingAssignmentLookup[key] = p.id;
});
}
});
}
}
function renderCalendar() {
const header = document.getElementById('calendarHeader');
const body = document.getElementById('calendarBody');
const { startDate, endDate } = getDateRange();
// Update header text
if (viewMode === 'week') {
document.getElementById('currentWeek').textContent =
`${utils.formatDateShort(startDate)} - ${utils.formatDateShort(endDate)}`;
} else {
document.getElementById('currentWeek').textContent =
`${utils.getMonthName(startDate.getMonth())} ${startDate.getFullYear()}`;
}
// Calculate number of days
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>Office</th>';
for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
const dayOfWeek = date.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isHoliday = utils.isItalianHoliday(date);
const isToday = date.toDateString() === new Date().toDateString();
let classes = [];
if (isWeekend) classes.push('weekend');
if (isHoliday) classes.push('holiday');
if (isToday) classes.push('today');
headerHtml += `<th class="${classes.join(' ')}">
<div>${dayNames[dayOfWeek].charAt(0)}</div>
<div class="day-number">${date.getDate()}</div>
</th>`;
}
header.innerHTML = headerHtml;
// Build body rows
if (teamData.length === 0) {
body.innerHTML = `<tr><td colspan="${dayCount + 2}" class="text-center">No team members found</td></tr>`;
return;
}
let bodyHtml = '';
teamData.forEach(member => {
bodyHtml += `<tr>
<td class="member-name">${member.name || 'Unknown'}</td>
<td class="member-office">${member.office_name || '-'}</td>`;
for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
const dateStr = utils.formatDate(date);
const dayOfWeek = date.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isHoliday = utils.isItalianHoliday(date);
const presence = member.presences.find(p => p.date === dateStr);
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();
let cellClasses = ['calendar-cell'];
if (isWeekend) cellClasses.push('weekend');
if (isHoliday) cellClasses.push('holiday');
if (isToday) cellClasses.push('today');
if (presence) cellClasses.push(`status-${presence.status}`);
// Show parking badge instead of just 'P'
let parkingBadge = '';
if (hasParking) {
const spotName = parkingSpot || 'P';
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 += '</tr>';
});
body.innerHTML = bodyHtml;
// Add click handlers to cells
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);
});
});
}
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];
// 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');
}
});
// 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;
const response = await api.post('/api/presence/admin/mark', {
user_id: selectedUserId,
date: selectedDate,
status: status
});
if (response && response.ok) {
document.getElementById('dayModal').style.display = 'none';
await loadTeamData();
renderCalendar();
} else {
const error = await response.json();
alert(error.detail || 'Failed to mark presence');
}
}
async function clearPresence() {
if (!selectedUserId || !selectedDate) return;
if (!confirm('Clear presence for this date?')) return;
const response = await api.delete(`/api/presence/admin/${selectedUserId}/${selectedDate}`);
if (response && response.ok) {
document.getElementById('dayModal').style.display = 'none';
await loadTeamData();
renderCalendar();
}
}
async function openReassignModal() {
if (!currentAssignmentId) 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;
}
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
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');
return;
}
const response = await api.post('/api/parking/reassign-spot', {
assignment_id: currentAssignmentId,
new_user_id: newUserId
});
if (response && response.ok) {
await loadTeamData();
renderCalendar();
document.getElementById('reassignModal').style.display = 'none';
} else {
const error = await response.json();
alert(error.detail || 'Failed to reassign parking spot');
}
}
function setupEventListeners() {
// Navigation (prev/next)
document.getElementById('prevWeek').addEventListener('click', async () => {
if (viewMode === 'week') {
currentStartDate.setDate(currentStartDate.getDate() - 7);
} else {
currentStartDate.setMonth(currentStartDate.getMonth() - 1);
}
await loadTeamData();
renderCalendar();
});
document.getElementById('nextWeek').addEventListener('click', async () => {
if (viewMode === 'week') {
currentStartDate.setDate(currentStartDate.getDate() + 7);
} else {
currentStartDate.setMonth(currentStartDate.getMonth() + 1);
}
await loadTeamData();
renderCalendar();
});
// View toggle (week/month)
document.getElementById('viewToggle').addEventListener('change', async (e) => {
viewMode = e.target.value;
if (viewMode === 'month') {
// Set to first day of current month
currentStartDate = new Date(currentStartDate.getFullYear(), currentStartDate.getMonth(), 1);
} else {
// Set to current week start
const weekStartDay = currentUser.week_start_day || 0;
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
}
await loadTeamData();
renderCalendar();
});
// Manager filter
document.getElementById('managerFilter').addEventListener('change', async () => {
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');
}

222
frontend/js/utils.js Normal file
View File

@@ -0,0 +1,222 @@
/**
* Utility Functions
* Date handling, holidays, and common helpers
*/
// Holiday cache: { year: Set of date strings }
const holidayCache = {};
/**
* Load holidays for a year from API (called automatically)
*/
async function loadHolidaysForYear(year) {
if (holidayCache[year]) return;
try {
const response = await fetch(`/api/auth/holidays/${year}`);
if (response.ok) {
const holidays = await response.json();
holidayCache[year] = new Set(holidays.map(h => h.date));
}
} catch (e) {
// Fall back to local calculation if API fails
console.warn('Holiday API failed, using local fallback');
}
}
/**
* Calculate Easter Sunday using Computus algorithm (fallback)
*/
function calculateEaster(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
/**
* Check if a date is an Italian holiday
* Uses cached API data if available, otherwise falls back to local calculation
*/
function isItalianHoliday(date) {
const year = date.getFullYear();
const dateStr = formatDate(date);
// Check cache first
if (holidayCache[year] && holidayCache[year].has(dateStr)) {
return true;
}
// Fallback: local calculation
const month = date.getMonth() + 1;
const day = date.getDate();
const fixedHolidays = [
[1, 1], [1, 6], [4, 25], [5, 1], [6, 2],
[8, 15], [11, 1], [12, 8], [12, 25], [12, 26]
];
for (const [hm, hd] of fixedHolidays) {
if (month === hm && day === hd) return true;
}
// Easter Monday
const easter = calculateEaster(year);
const easterMonday = new Date(easter);
easterMonday.setDate(easter.getDate() + 1);
if (date.getMonth() === easterMonday.getMonth() && date.getDate() === easterMonday.getDate()) {
return true;
}
return false;
}
/**
* Format date as YYYY-MM-DD
*/
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Format date for display
*/
function formatDateDisplay(dateStr) {
const date = new Date(dateStr + 'T12:00:00');
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
});
}
/**
* Get month name
*/
function getMonthName(month) {
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
return months[month];
}
/**
* Get day name
*/
function getDayName(dayIndex) {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return days[dayIndex];
}
/**
* Get days in month
*/
function getDaysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
}
/**
* Get start of week for a date
*/
function getWeekStart(date, weekStartDay = 0) {
const d = new Date(date);
const day = d.getDay();
const diff = (day - weekStartDay + 7) % 7;
d.setDate(d.getDate() - diff);
d.setHours(0, 0, 0, 0);
return d;
}
/**
* Format date as short display (e.g., "Nov 26")
*/
function formatDateShort(date) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
}
/**
* Show a temporary message (creates toast if no container)
*/
function showMessage(message, type = 'success', duration = 3000) {
// Create toast container if it doesn't exist
let toastContainer = document.getElementById('toastContainer');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toastContainer';
toastContainer.style.cssText = `
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
`;
document.body.appendChild(toastContainer);
}
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;
`;
toast.textContent = message;
toastContainer.appendChild(toast);
if (duration > 0) {
setTimeout(() => {
toast.style.animation = 'slideOut 0.2s ease';
setTimeout(() => toast.remove(), 200);
}, duration);
}
}
/**
* 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';
}
});
}
}
// Export utilities
window.utils = {
loadHolidaysForYear,
isItalianHoliday,
formatDate,
formatDateDisplay,
formatDateShort,
getMonthName,
getDayName,
getDaysInMonth,
getWeekStart,
showMessage,
setupModalClose
};

View File

@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Users - 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>
</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">Loading...</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>
<hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button>
</div>
</div>
</div>
</aside>
<main class="main-content">
<header class="page-header">
<h2>Manage Users</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 class="data-table-container">
<table class="data-table" id="usersTable">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Office</th>
<th>Managed Offices</th>
<th>Parking Quota</th>
<th>Spot Prefix</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="usersBody"></tbody>
</table>
</div>
</div>
</div>
</main>
<!-- Edit User Modal -->
<div class="modal" id="userModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="userModalTitle">Edit User</h3>
<button class="modal-close" id="closeUserModal">&times;</button>
</div>
<div class="modal-body">
<form id="userForm">
<input type="hidden" id="userId">
<div class="form-group">
<label for="editName">Name</label>
<input type="text" id="editName" required>
</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>
<select id="editRole" required>
<option value="employee">Employee</option>
<option value="manager">Manager</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group">
<label for="editOffice">Office</label>
<select id="editOffice">
<option value="">No office</option>
</select>
</div>
<div class="form-group" id="quotaGroup" style="display: none;">
<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 can assign</small>
</div>
<div class="form-group" id="prefixGroup" style="display: none;">
<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 class="form-group" id="managedOfficesGroup" style="display: none;">
<label>Managed Offices</label>
<div id="managedOfficesCheckboxes" class="checkbox-group"></div>
<small class="text-muted">Select offices this manager controls</small>
</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>
</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-users.js"></script>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Parking Manager</title>
<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>Parking Manager</h1>
<p>Manage office presence and parking assignments</p>
</div>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<a href="/login" class="btn btn-dark btn-full">Sign In</a>
<a href="/register" class="btn btn-secondary btn-full">Create Account</a>
</div>
</div>
</div>
<script>
// Redirect if already logged in
if (localStorage.getItem('access_token')) {
window.location.href = '/presence';
}
</script>
</body>
</html>

64
frontend/pages/login.html Normal file
View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Parking Manager</title>
<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>
</div>
<div id="errorMessage"></div>
<form id="loginForm">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" required autocomplete="email">
</div>
<div class="form-group">
<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>
</form>
<div class="auth-footer">
Don't have an account? <a href="/register">Sign up</a>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script>
// Redirect if already logged in
if (api.isAuthenticated()) {
window.location.href = '/presence';
}
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const errorDiv = document.getElementById('errorMessage');
errorDiv.innerHTML = '';
const result = await api.login(email, password);
if (result.success) {
window.location.href = '/presence';
} else {
errorDiv.innerHTML = `<div class="message error">${result.error}</div>`;
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Office Rules - 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>
</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">Loading...</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>
<hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button>
</div>
</div>
</div>
</aside>
<main class="main-content">
<header class="page-header">
<h2>Office Rules</h2>
<div class="header-actions">
<select id="officeSelect" 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>
<div class="card-body">
<p class="text-muted" style="margin-bottom: 1rem;">Days of the week the office is regularly closed</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>
</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>
</div>
<div class="card-body">
<p class="text-muted">Specific dates when the office is closed (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>
</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>
</div>
</div>
</div>
<div class="content-wrapper" id="noOfficeMessage">
<div class="card">
<div class="card-body text-center">
<p>Select a manager to manage their office rules</p>
</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>
<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>
<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">
</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>
</div>
</form>
</div>
</div>
</div>
<!-- Add Guarantee Modal -->
<div class="modal" id="guaranteeModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Add Parking Guarantee</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>
<select id="guaranteeUser" required>
<option value="">Select user...</option>
</select>
</div>
<div class="form-group">
<label for="guaranteeStartDate">Start Date (optional)</label>
<input type="date" id="guaranteeStartDate">
<small>Leave empty for no start limit</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>
</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>
</div>
</form>
</div>
</div>
</div>
<!-- Add Exclusion Modal -->
<div class="modal" id="exclusionModal" style="display: none;">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Add Parking Exclusion</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>
<select id="exclusionUser" required>
<option value="">Select user...</option>
</select>
</div>
<div class="form-group">
<label for="exclusionStartDate">Start Date (optional)</label>
<input type="date" id="exclusionStartDate">
<small>Leave empty for no start limit</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>
</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>
</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/office-rules.js"></script>
</body>
</html>

View File

@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Presence - 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>
</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">Loading...</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>
<hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button>
</div>
</div>
</div>
</aside>
<main class="main-content">
<header class="page-header">
<h2>My Presence</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 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">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<h3 id="currentMonth">Loading...</h3>
<button class="btn-icon" id="nextMonth">
<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="calendar-grid" id="calendarGrid"></div>
<div class="legend">
<div class="legend-item">
<div class="legend-color status-present"></div>
<span>Present (Office)</span>
</div>
<div class="legend-item">
<div class="legend-color status-remote"></div>
<span>Remote</span>
</div>
<div class="legend-item">
<div class="legend-color status-absent"></div>
<span>Absent</span>
</div>
</div>
</div>
</div>
</main>
<!-- Day Modal -->
<div class="modal" id="dayModal" 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>
</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>
</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>
<!-- 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>
</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/presence.js"></script>
</body>
</html>

188
frontend/pages/profile.html Normal file
View File

@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profile - 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>
</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">Loading...</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>
<hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button>
</div>
</div>
</div>
</aside>
<main class="main-content">
<header class="page-header">
<h2>Profile</h2>
</header>
<div class="content-wrapper">
<div class="card">
<div class="card-header">
<h3>Personal Information</h3>
</div>
<div class="card-body">
<form id="profileForm">
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" id="name" required>
</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>
</div>
<div class="form-group">
<label for="office">Office</label>
<select id="office">
<option value="">No office</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-dark">Save Changes</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Change Password</h3>
</div>
<div class="card-body">
<form id="passwordForm">
<div class="form-group">
<label for="currentPassword">Current Password</label>
<input type="password" id="currentPassword" required>
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<input type="password" id="newPassword" required minlength="8">
<small>Minimum 8 characters</small>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm New Password</label>
<input type="password" id="confirmPassword" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-dark">Change Password</button>
</div>
</form>
</div>
</div>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/nav.js"></script>
<script>
let currentUser = null;
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
if (!currentUser) return;
await loadOffices();
populateForm();
setupEventListeners();
});
async function loadOffices() {
const response = await api.get('/api/offices');
if (response && response.ok) {
const offices = await response.json();
const select = document.getElementById('office');
offices.forEach(office => {
const option = document.createElement('option');
option.value = office.id;
option.textContent = office.name;
select.appendChild(option);
});
}
}
function populateForm() {
document.getElementById('name').value = currentUser.name || '';
document.getElementById('email').value = currentUser.email;
document.getElementById('office').value = currentUser.office_id || '';
}
function setupEventListeners() {
// Profile form
document.getElementById('profileForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
name: document.getElementById('name').value,
office_id: document.getElementById('office').value || null
};
const response = await api.put('/api/users/me/profile', data);
if (response && response.ok) {
utils.showMessage('Profile updated successfully', 'success');
currentUser = await api.getCurrentUser();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to update profile', 'error');
}
});
// Password form
document.getElementById('passwordForm').addEventListener('submit', async (e) => {
e.preventDefault();
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (newPassword !== confirmPassword) {
utils.showMessage('Passwords do not match', 'error');
return;
}
const data = {
current_password: document.getElementById('currentPassword').value,
new_password: newPassword
};
const response = await api.post('/api/users/me/change-password', data);
if (response && response.ok) {
utils.showMessage('Password changed successfully', 'success');
document.getElementById('passwordForm').reset();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to change password', 'error');
}
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - Parking Manager</title>
<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>
</div>
<div id="errorMessage"></div>
<form id="registerForm">
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" id="name" required autocomplete="name">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" required autocomplete="email">
</div>
<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>
</div>
<div class="form-group">
<label for="office">Office (optional)</label>
<select id="office">
<option value="">Select an office...</option>
</select>
</div>
<button type="submit" class="btn btn-dark btn-full">Create Account</button>
</form>
<div class="auth-footer">
Already have an account? <a href="/login">Sign in</a>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script>
// Redirect if already logged in
if (api.isAuthenticated()) {
window.location.href = '/presence';
}
// Load offices
async function loadOffices() {
try {
const response = await fetch('/api/offices');
if (response.ok) {
const offices = await response.json();
const select = document.getElementById('office');
offices.forEach(office => {
const option = document.createElement('option');
option.value = office.id;
option.textContent = office.name;
select.appendChild(option);
});
}
} catch (error) {
console.error('Failed to load offices:', error);
}
}
loadOffices();
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('name').value;
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const officeId = document.getElementById('office').value || null;
const errorDiv = document.getElementById('errorMessage');
errorDiv.innerHTML = '';
const result = await api.register(email, password, name, officeId);
if (result.success) {
window.location.href = '/presence';
} else {
errorDiv.innerHTML = `<div class="message error">${result.error}</div>`;
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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>Parking Manager</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">Loading...</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>
<hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button>
</div>
</div>
</div>
</aside>
<main class="main-content">
<header class="page-header">
<h2>Settings</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>
</div>
<div class="card-body">
<form id="notificationForm">
<div class="form-group">
<label class="toggle-label">
<span>Weekly Summary</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>
</div>
<div class="form-group">
<label class="toggle-label">
<span>Daily Reminder</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>
</div>
<div class="form-group" id="dailyTimeGroup" style="margin-left: 1rem;">
<label>Reminder Time</label>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<select id="notifyDailyHour" style="width: 80px;">
<!-- Hours populated by JS -->
</select>
<span>:</span>
<select id="notifyDailyMinute" 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>
</div>
<div class="form-group">
<label class="toggle-label">
<span>Assignment Changes</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>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-dark">Save Notifications</button>
</div>
</form>
</div>
</div>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/nav.js"></script>
<script>
let currentUser = null;
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
if (!currentUser) return;
populateHourSelect();
populateForm();
setupEventListeners();
});
function populateHourSelect() {
const select = document.getElementById('notifyDailyHour');
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);
}
}
function populateForm() {
document.getElementById('weekStartDay').value = currentUser.week_start_day || 0;
// Notification settings
document.getElementById('notifyWeeklyParking').checked = currentUser.notify_weekly_parking !== 0;
document.getElementById('notifyDailyParking').checked = currentUser.notify_daily_parking !== 0;
document.getElementById('notifyDailyHour').value = currentUser.notify_daily_parking_hour || 8;
document.getElementById('notifyDailyMinute').value = currentUser.notify_daily_parking_minute || 0;
document.getElementById('notifyParkingChanges').checked = currentUser.notify_parking_changes !== 0;
updateDailyTimeVisibility();
}
function updateDailyTimeVisibility() {
const enabled = document.getElementById('notifyDailyParking').checked;
document.getElementById('dailyTimeGroup').style.display = enabled ? 'block' : 'none';
}
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) => {
e.preventDefault();
const data = {
notify_weekly_parking: document.getElementById('notifyWeeklyParking').checked ? 1 : 0,
notify_daily_parking: document.getElementById('notifyDailyParking').checked ? 1 : 0,
notify_daily_parking_hour: parseInt(document.getElementById('notifyDailyHour').value),
notify_daily_parking_minute: parseInt(document.getElementById('notifyDailyMinute').value),
notify_parking_changes: document.getElementById('notifyParkingChanges').checked ? 1 : 0
};
const response = await api.put('/api/users/me/settings', data);
if (response && response.ok) {
utils.showMessage('Notification settings saved', 'success');
currentUser = await api.getCurrentUser();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to save notifications', 'error');
}
});
// Toggle daily time visibility
document.getElementById('notifyDailyParking').addEventListener('change', updateDailyTimeVisibility);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Team Calendar - 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>
</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">Loading...</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>
<hr class="dropdown-divider">
<button class="dropdown-item" id="logoutButton">Logout</button>
</div>
</div>
</div>
</aside>
<main class="main-content">
<header class="page-header">
<h2>Team Calendar</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 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">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<h3 id="currentWeek">Loading...</h3>
<button class="btn-icon" id="nextWeek">
<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">
<thead>
<tr id="calendarHeader"></tr>
</thead>
<tbody id="calendarBody"></tbody>
</table>
</div>
<div class="legend">
<div class="legend-item">
<div class="legend-color status-present"></div>
<span>Present</span>
</div>
<div class="legend-item">
<div class="legend-color status-remote"></div>
<span>Remote</span>
</div>
<div class="legend-item">
<div class="legend-color status-absent"></div>
<span>Absent</span>
</div>
</div>
</div>
</div>
</main>
<!-- Day Status Modal -->
<div class="modal" id="dayModal" 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>
</div>
<div class="modal-body">
<p id="dayModalUser" style="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>
</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>
<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>
<!-- 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>
<script src="/js/api.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/team-calendar.js"></script>
</body>
</html>

127
main.py Normal file
View File

@@ -0,0 +1,127 @@
"""
Parking Manager Application
FastAPI + SQLite + Vanilla JS
"""
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
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.offices import router as offices_router
from app.routes.managers import router as managers_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
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize database on startup"""
init_db()
yield
app = FastAPI(title="Parking Manager", version="1.0.0", lifespan=lifespan)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=config.ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API Routes
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(offices_router)
app.include_router(managers_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")
# Page Routes
@app.get("/")
async def index():
"""Landing page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "landing.html")
@app.get("/login")
async def login_page():
"""Login page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "login.html")
@app.get("/register")
async def register_page():
"""Register page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "register.html")
@app.get("/dashboard")
async def dashboard():
"""Dashboard - redirect to team calendar"""
return RedirectResponse(url="/team-calendar", status_code=302)
@app.get("/presence")
async def presence_page():
"""My Presence page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "presence.html")
@app.get("/team-calendar")
async def team_calendar_page():
"""Team Calendar page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "team-calendar.html")
@app.get("/office-rules")
async def office_rules_page():
"""Office Rules page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "office-rules.html")
@app.get("/admin/users")
async def admin_users_page():
"""Admin Users page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "admin-users.html")
@app.get("/profile")
async def profile_page():
"""Profile page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "profile.html")
@app.get("/settings")
async def settings_page():
"""Settings page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "settings.html")
@app.get("/favicon.svg")
async def favicon():
"""Favicon"""
return FileResponse(config.FRONTEND_DIR / "favicon.svg", media_type="image/svg+xml")
@app.get("/health")
async def health():
"""Health check"""
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host=config.HOST, port=config.PORT, reload=True)

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
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

32
run_notifications.py Normal file
View File

@@ -0,0 +1,32 @@
#!/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()

0
services/__init__.py Normal file
View File

79
services/auth.py Normal file
View File

@@ -0,0 +1,79 @@
"""
Authentication Service
JWT token management and password hashing
"""
import uuid
import bcrypt
from datetime import datetime, timedelta
from jose import jwt
from sqlalchemy.orm import Session
from app import config
from database.models import User
def hash_password(password: str) -> str:
"""Hash a password using bcrypt"""
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, hashed: str) -> bool:
"""Verify a password against its hash"""
return bcrypt.checkpw(password.encode(), hashed.encode())
def create_access_token(user_id: str, email: str) -> str:
"""Create a JWT access token"""
expire = datetime.utcnow() + timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": user_id,
"email": email,
"exp": expire
}
return jwt.encode(payload, config.SECRET_KEY, algorithm=config.ALGORITHM)
def decode_token(token: str) -> dict | None:
"""Decode and validate a JWT token"""
try:
return jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
except Exception:
return None
def get_user_by_email(db: Session, email: str) -> User | None:
"""Get user by email address"""
return db.query(User).filter(User.email == email).first()
def get_user_by_id(db: Session, user_id: str) -> User | None:
"""Get user by ID"""
return db.query(User).filter(User.id == user_id).first()
def authenticate_user(db: Session, email: str, password: str) -> User | None:
"""Authenticate user with email and password"""
user = get_user_by_email(db, email)
if not user or not user.password_hash:
return None
if not verify_password(password, user.password_hash):
return None
return user
def create_user(db: Session, email: str, password: str, name: str, office_id: str = None, role: str = "employee") -> User:
"""Create a new user"""
user = User(
id=str(uuid.uuid4()),
email=email,
password_hash=hash_password(password),
name=name,
office_id=office_id,
role=role,
created_at=datetime.utcnow().isoformat(),
updated_at=datetime.utcnow().isoformat()
)
db.add(user)
db.commit()
db.refresh(user)
return user

116
services/holidays.py Normal file
View File

@@ -0,0 +1,116 @@
"""
Holiday Service
Configurable holiday calculation for different regions
Currently supports Italian holidays. Can be extended to support other regions
by adding new holiday sets and a configuration option.
"""
from datetime import datetime, date, timedelta
def calculate_easter(year: int) -> date:
"""Calculate Easter Sunday using the Computus algorithm"""
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
def get_easter_monday(year: int) -> date:
"""Get Easter Monday for a given year"""
easter = calculate_easter(year)
return easter + timedelta(days=1)
# Italian fixed holidays (month, day)
ITALIAN_FIXED_HOLIDAYS = [
(1, 1), # New Year's Day
(1, 6), # Epiphany
(4, 25), # Liberation Day
(5, 1), # Labour Day
(6, 2), # Republic Day
(8, 15), # Assumption
(11, 1), # All Saints
(12, 8), # Immaculate Conception
(12, 25), # Christmas
(12, 26), # St. Stephen's
]
def is_italian_holiday(check_date: date | datetime) -> bool:
"""Check if a date is an Italian public holiday"""
if isinstance(check_date, datetime):
check_date = check_date.date()
year = check_date.year
month = check_date.month
day = check_date.day
# Check fixed holidays
if (month, day) in ITALIAN_FIXED_HOLIDAYS:
return True
# Check Easter Monday
easter_monday = get_easter_monday(year)
if check_date == easter_monday:
return True
return False
def get_holidays_for_year(year: int) -> list[dict]:
"""
Get all holidays for a given year.
Returns list of {date: YYYY-MM-DD, name: string}
"""
holidays = []
# Fixed holidays
holiday_names = [
"New Year's Day", "Epiphany", "Liberation Day", "Labour Day",
"Republic Day", "Assumption", "All Saints", "Immaculate Conception",
"Christmas", "St. Stephen's Day"
]
for (month, day), name in zip(ITALIAN_FIXED_HOLIDAYS, holiday_names):
holidays.append({
"date": f"{year}-{month:02d}-{day:02d}",
"name": name
})
# Easter Monday
easter_monday = get_easter_monday(year)
holidays.append({
"date": easter_monday.strftime("%Y-%m-%d"),
"name": "Easter Monday"
})
# Sort by date
holidays.sort(key=lambda h: h["date"])
return holidays
def is_holiday(check_date: date | datetime | str, region: str = "IT") -> bool:
"""
Check if a date is a holiday for the given region.
Currently only supports IT (Italy).
"""
if isinstance(check_date, str):
check_date = datetime.strptime(check_date, "%Y-%m-%d").date()
if region == "IT":
return is_italian_holiday(check_date)
# Default: no holidays
return False

393
services/notifications.py Normal file
View File

@@ -0,0 +1,393 @@
"""
Notification Service
Handles email notifications for presence reminders and parking assignments
TODO: This service is NOT YET ACTIVE. To enable notifications:
1. Add APScheduler or similar to run run_scheduled_notifications() periodically
2. Configure SMTP environment variables (SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM)
3. Notifications will be sent for:
- Presence reminders (Thursday at 12:00)
- Weekly parking summary (Friday at 12:00)
- Daily parking reminders (at user's preferred time)
- Immediate parking change notifications (via queue)
"""
import smtplib
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
import uuid
from database.models import (
User, UserPresence, DailyParkingAssignment,
NotificationLog, NotificationQueue, OfficeMembership
)
from services.parking import get_spot_display_name
# Email configuration (from environment variables)
SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM = os.getenv("SMTP_FROM", "noreply@parkingmanager.local")
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
def send_email(to_email: str, subject: str, body_html: str, body_text: str = None):
"""Send an email"""
if not SMTP_USER or not SMTP_PASSWORD:
print(f"[NOTIFICATION] Email not configured. Would send to {to_email}: {subject}")
return False
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = to_email
if body_text:
msg.attach(MIMEText(body_text, "plain"))
msg.attach(MIMEText(body_html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
if SMTP_USE_TLS:
server.starttls()
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(SMTP_FROM, to_email, msg.as_string())
print(f"[NOTIFICATION] Email sent to {to_email}: {subject}")
return True
except Exception as e:
print(f"[NOTIFICATION] Failed to send email to {to_email}: {e}")
return False
def get_week_dates(reference_date: datetime):
"""Get Monday-Sunday dates for the week containing reference_date"""
# Find Monday of this week
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):
"""Get Monday-Sunday dates for the week after reference_date"""
# Find Monday of next week
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 check_week_presence_compiled(user_id: str, week_dates: list, db: Session) -> bool:
"""Check if user has filled presence for all working days in a week"""
date_strs = [d.strftime("%Y-%m-%d") for d in week_dates]
presences = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.date.in_(date_strs)
).all()
# Consider week compiled if at least 5 days have presence marked
# (allowing for weekends or holidays)
return len(presences) >= 5
def get_week_reference(date: datetime) -> str:
"""Get ISO week reference string (e.g., 2024-W48)"""
return date.strftime("%Y-W%W")
def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bool:
"""Send presence compilation reminder for next week"""
week_ref = get_week_reference(next_week_dates[0])
# Check if already sent today for this week
today = datetime.now().strftime("%Y-%m-%d")
existing = db.query(NotificationLog).filter(
NotificationLog.user_id == user.id,
NotificationLog.notification_type == "presence_reminder",
NotificationLog.reference_date == week_ref,
NotificationLog.sent_at >= today
).first()
if existing:
return False # Already sent today
# Check if week is compiled
if check_week_presence_compiled(user.id, next_week_dates, db):
return False # Already compiled
# Send reminder
start_date = next_week_dates[0].strftime("%B %d")
end_date = next_week_dates[-1].strftime("%B %d, %Y")
subject = f"Reminder: Please fill your presence for {start_date} - {end_date}"
body_html = f"""
<html>
<body>
<h2>Presence Reminder</h2>
<p>Hi {user.name},</p>
<p>This is a friendly reminder to fill your presence for the upcoming week
({start_date} - {end_date}).</p>
<p>Please log in to the Parking Manager to mark your presence.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
if send_email(user.email, subject, body_html):
# Log the notification
log = NotificationLog(
id=str(uuid.uuid4()),
user_id=user.id,
notification_type="presence_reminder",
reference_date=week_ref,
sent_at=datetime.now().isoformat()
)
db.add(log)
db.commit()
return True
return False
def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session) -> bool:
"""Send weekly parking assignment summary for next week (Friday at 12)"""
if not user.notify_weekly_parking:
return False
week_ref = get_week_reference(next_week_dates[0])
# Check if already sent for this week
existing = db.query(NotificationLog).filter(
NotificationLog.user_id == user.id,
NotificationLog.notification_type == "weekly_parking",
NotificationLog.reference_date == week_ref
).first()
if existing:
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)
).all()
if not assignments:
return False # No assignments, no need to notify
# Build assignment list
assignment_lines = []
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>")
start_date = next_week_dates[0].strftime("%B %d")
end_date = next_week_dates[-1].strftime("%B %d, %Y")
subject = f"Your parking spots for {start_date} - {end_date}"
body_html = f"""
<html>
<body>
<h2>Weekly Parking Summary</h2>
<p>Hi {user.name},</p>
<p>Here are your parking spot assignments for the upcoming week:</p>
<ul>
{''.join(assignment_lines)}
</ul>
<p>Parking assignments are now frozen. You can still release or reassign your spots if needed.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
if send_email(user.email, subject, body_html):
log = NotificationLog(
id=str(uuid.uuid4()),
user_id=user.id,
notification_type="weekly_parking",
reference_date=week_ref,
sent_at=datetime.now().isoformat()
)
db.add(log)
db.commit()
return True
return False
def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool:
"""Send daily parking reminder for a specific date"""
if not user.notify_daily_parking:
return False
date_str = date.strftime("%Y-%m-%d")
# Check if already sent for this date
existing = db.query(NotificationLog).filter(
NotificationLog.user_id == user.id,
NotificationLog.notification_type == "daily_parking",
NotificationLog.reference_date == date_str
).first()
if existing:
return False
# Get parking assignment for this date
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user.id,
DailyParkingAssignment.date == date_str
).first()
if not assignment:
return False # No assignment today
spot_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
day_name = date.strftime("%A, %B %d")
subject = f"Parking reminder for {day_name}"
body_html = f"""
<html>
<body>
<h2>Daily Parking Reminder</h2>
<p>Hi {user.name},</p>
<p>You have a parking spot assigned for today ({day_name}):</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
if send_email(user.email, subject, body_html):
log = NotificationLog(
id=str(uuid.uuid4()),
user_id=user.id,
notification_type="daily_parking",
reference_date=date_str,
sent_at=datetime.now().isoformat()
)
db.add(log)
db.commit()
return True
return False
def queue_parking_change_notification(
user: User,
date: str,
change_type: str, # "assigned", "released", "reassigned"
spot_name: str,
new_user_name: str = None,
db: Session = None
):
"""Queue an immediate notification for a parking assignment change"""
if not user.notify_parking_changes:
return
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
if change_type == "assigned":
subject = f"Parking spot assigned for {day_name}"
body = f"""
<html>
<body>
<h2>Parking Spot Assigned</h2>
<p>Hi {user.name},</p>
<p>You have been assigned a parking spot for {day_name}:</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
elif change_type == "released":
subject = f"Parking spot released for {day_name}"
body = f"""
<html>
<body>
<h2>Parking Spot Released</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been released.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
elif change_type == "reassigned":
subject = f"Parking spot reassigned for {day_name}"
body = f"""
<html>
<body>
<h2>Parking Spot Reassigned</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been reassigned to {new_user_name}.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
else:
return
# Add to queue
notification = NotificationQueue(
id=str(uuid.uuid4()),
user_id=user.id,
notification_type="parking_change",
subject=subject,
body=body,
created_at=datetime.now().isoformat()
)
db.add(notification)
db.commit()
def process_notification_queue(db: Session):
"""Process and send all pending notifications in the queue"""
pending = db.query(NotificationQueue).filter(
NotificationQueue.sent_at.is_(None)
).all()
for notification in pending:
user = db.query(User).filter(User.id == notification.user_id).first()
if user and send_email(user.email, notification.subject, notification.body):
notification.sent_at = datetime.now().isoformat()
db.commit()
def run_scheduled_notifications(db: Session):
"""Run all scheduled notifications - called by a scheduler/cron job"""
now = datetime.now()
today = now.date()
current_hour = now.hour
current_minute = now.minute
current_weekday = now.weekday() # 0=Monday, 6=Sunday
users = db.query(User).all()
for user in users:
# Thursday at 12: Presence reminder (unmanageable)
if current_weekday == 3 and current_hour == 12 and current_minute < 5:
next_week = get_next_week_dates(now)
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)
send_weekly_parking_summary(user, next_week, db)
# Daily parking reminder at user's preferred time (working days only)
if current_weekday < 5: # Monday to Friday
user_hour = user.notify_daily_parking_hour or 8
user_minute = user.notify_daily_parking_minute or 0
if current_hour == user_hour and abs(current_minute - user_minute) < 5:
send_daily_parking_reminder(user, now, db)
# Process queued notifications
process_notification_queue(db)

343
services/parking.py Normal file
View File

@@ -0,0 +1,343 @@
"""
Parking Assignment Service
Manager-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
- Fairness: users with lowest parking_days/office_days ratio get priority
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from sqlalchemy import func
from database.models import (
DailyParkingAssignment, User, OfficeMembership, UserPresence,
ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay
)
def get_manager_for_office(office_id: str, db: Session) -> User | None:
"""Find the manager responsible for an office"""
membership = db.query(OfficeMembership).filter(
OfficeMembership.office_id == office_id
).first()
if not membership:
return None
return db.query(User).filter(User.id == membership.user_id).first()
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
# 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()
# 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
).all()
)
# Find first available letter
manager_index = next((i for i, m in enumerate(managers) if m.id == manager.id), 0)
letter = 'A'
count = 0
while letter in existing_prefixes or count < manager_index:
if letter not in existing_prefixes:
count += 1
letter = chr(ord(letter) + 1)
if ord(letter) > ord('Z'):
letter = 'A'
break
return letter
def get_spot_display_name(spot_id: str, manager_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:
return spot_id
prefix = get_spot_prefix(manager, db)
spot_number = spot_id.replace("spot-", "")
return f"{prefix}{spot_number}"
def is_closing_day(manager_id: str, date: str, db: Session) -> bool:
"""
Check if date is a closing day for this manager.
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
).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
weekly = db.query(ManagerWeeklyClosingDay).filter(
ManagerWeeklyClosingDay.manager_id == manager_id,
ManagerWeeklyClosingDay.weekday == weekday_sunday_start
).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.
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):
return 0
existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == manager_id,
DailyParkingAssignment.date == date
).count()
if existing > 0:
return existing
for i in range(1, quota + 1):
spot = DailyParkingAssignment(
id=str(uuid.uuid4()),
date=date,
spot_id=f"spot-{i}",
user_id=None,
manager_id=manager_id,
created_at=datetime.now(timezone.utc).isoformat()
)
db.add(spot)
db.commit()
return quota
def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
"""
Calculate user's parking ratio: parking_days / office_days
Lower ratio = higher priority for next parking spot
"""
# Get offices managed by this manager
managed_office_ids = [
m.office_id for m in db.query(OfficeMembership).filter(
OfficeMembership.user_id == manager_id
).all()
]
# Count days user was present (office_days)
office_days = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.status == "present"
).count()
if office_days == 0:
return 0.0 # New user, highest priority
# Count days user got parking
parking_days = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.manager_id == manager_id
).count()
return parking_days / office_days
def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> bool:
"""Check if user is excluded from parking for this date"""
exclusion = db.query(ParkingExclusion).filter(
ParkingExclusion.manager_id == manager_id,
ParkingExclusion.user_id == user_id
).first()
if not exclusion:
return False
# Check date range
if exclusion.start_date and date < exclusion.start_date:
return False
if exclusion.end_date and date > exclusion.end_date:
return False
return True
def has_guarantee(user_id: str, manager_id: str, date: str, db: Session) -> bool:
"""Check if user has a parking guarantee for this date"""
guarantee = db.query(ParkingGuarantee).filter(
ParkingGuarantee.manager_id == manager_id,
ParkingGuarantee.user_id == user_id
).first()
if not guarantee:
return False
# Check date range
if guarantee.start_date and date < guarantee.start_date:
return False
if guarantee.end_date and date > guarantee.end_date:
return False
return True
def get_users_wanting_parking(manager_id: str, date: str, 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}
"""
# Get offices managed by this manager
managed_office_ids = [
m.office_id for m in db.query(OfficeMembership).filter(
OfficeMembership.user_id == manager_id
).all()
]
# Get users who marked "present" for this date and belong to managed offices
present_users = db.query(UserPresence).join(User).filter(
UserPresence.date == date,
UserPresence.status == "present",
User.office_id.in_(managed_office_ids)
).all()
candidates = []
for presence in present_users:
user_id = presence.user_id
# Skip excluded users
if is_user_excluded(user_id, manager_id, date, db):
continue
# Skip users who already have a spot
existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.date == date,
DailyParkingAssignment.user_id == user_id
).first()
if existing:
continue
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)
})
# Sort: guaranteed users first, then by ratio (lowest first for fairness)
candidates.sort(key=lambda x: (not x["has_guarantee"], x["ratio"]))
return candidates
def assign_parking_fairly(manager_id: str, date: str, 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:
return {"assigned": [], "waitlist": []}
# No parking on closing days
if is_closing_day(manager_id, date, db):
return {"assigned": [], "waitlist": [], "closed": True}
# Initialize pool
initialize_parking_pool(manager_id, manager.manager_parking_quota, date, db)
# Get candidates sorted by fairness
candidates = get_users_wanting_parking(manager_id, date, db)
# Get available spots
free_spots = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == manager_id,
DailyParkingAssignment.date == date,
DailyParkingAssignment.user_id == None
).all()
assigned = []
waitlist = []
for candidate in candidates:
if free_spots:
spot = free_spots.pop(0)
spot.user_id = candidate["user_id"]
assigned.append(candidate["user_id"])
else:
waitlist.append(candidate["user_id"])
db.commit()
return {"assigned": assigned, "waitlist": waitlist}
def release_user_spot(manager_id: str, user_id: str, date: str, 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.user_id == user_id
).first()
if not assignment:
return False
# Release the spot
assignment.user_id = None
db.commit()
# Try to assign to next user in fairness queue
candidates = get_users_wanting_parking(manager_id, date, db)
if candidates:
assignment.user_id = candidates[0]["user_id"]
db.commit()
return True
def handle_presence_change(user_id: str, date: str, old_status: str, new_status: str, office_id: str, db: Session):
"""
Handle presence status change and update parking accordingly.
Uses fairness algorithm for assignment.
"""
# Don't process past dates
target_date = datetime.strptime(date, "%Y-%m-%d").date()
if target_date < datetime.now().date():
return
# Find manager for this office
manager = get_manager_for_office(office_id, db)
if not manager or not manager.manager_parking_quota:
return
# Initialize pool if needed
initialize_parking_pool(manager.id, manager.manager_parking_quota, date, db)
if old_status == "present" and new_status in ["remote", "absent"]:
# User no longer coming - release their spot (will auto-reassign)
release_user_spot(manager.id, user_id, date, db)
elif new_status == "present":
# User coming in - run fair assignment for this date
assign_parking_fairly(manager.id, date, db)

0
utils/__init__.py Normal file
View File

170
utils/auth_middleware.py Normal file
View File

@@ -0,0 +1,170 @@
"""
Authentication Middleware
JWT token validation and Authelia header authentication for protected routes
"""
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from datetime import datetime
import uuid
from database.connection import get_db
from database.models import User
from services.auth import decode_token, get_user_by_id, get_user_by_email
from app import config
security = HTTPBearer(auto_error=False)
def get_role_from_groups(groups: list[str]) -> str:
"""Map Authelia groups to application roles"""
if config.AUTHELIA_ADMIN_GROUP in groups:
return "admin"
if config.AUTHELIA_MANAGER_GROUP in groups:
return "manager"
return "employee"
def get_or_create_authelia_user(
email: str,
name: str,
groups: list[str],
db: Session
) -> User:
"""Get existing user or create from Authelia headers"""
user = get_user_by_email(db, email)
role = get_role_from_groups(groups)
if user:
# Update role if changed in LLDAP
if user.role != role:
user.role = role
user.updated_at = datetime.utcnow().isoformat()
db.commit()
db.refresh(user)
# Update name if changed
if user.name != name and name:
user.name = name
user.updated_at = datetime.utcnow().isoformat()
db.commit()
db.refresh(user)
return user
# Create new user from Authelia
user = User(
id=str(uuid.uuid4()),
email=email,
name=name or email.split("@")[0],
role=role,
password_hash=None, # No password for Authelia users
created_at=datetime.utcnow().isoformat(),
updated_at=datetime.utcnow().isoformat()
)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
Extract and validate user from JWT token or Authelia headers.
Authelia mode takes precedence when enabled.
"""
# Authelia mode: trust headers from reverse proxy
if config.AUTHELIA_ENABLED:
remote_user = request.headers.get(config.AUTHELIA_HEADER_USER)
remote_email = request.headers.get(config.AUTHELIA_HEADER_EMAIL, remote_user)
remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "")
remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "")
if not remote_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated (Authelia headers missing)"
)
# Parse groups (comma-separated)
groups = [g.strip() for g in remote_groups.split(",") if g.strip()]
# Get or create user
return get_or_create_authelia_user(remote_email, remote_name, groups, db)
# JWT mode: validate Bearer token
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated"
)
token = credentials.credentials
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
user = get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
return user
def require_admin(user=Depends(get_current_user)):
"""Require admin role"""
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required"
)
return user
def require_manager_or_admin(user=Depends(get_current_user)):
"""Require manager or admin role"""
if user.role not in ["admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Manager or admin role required"
)
return 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 in their managed offices.
Returns True if access granted, raises HTTPException if not.
"""
if current_user.role == "admin":
return True
if current_user.role == "manager":
managed_office_ids = [m.office_id for m in current_user.managed_offices]
if target_user.office_id not in managed_office_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User not in your managed offices"
)
return True
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)