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:
26
.env.example
Normal file
26
.env.example
Normal 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
51
.gitignore
vendored
Normal 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
23
Dockerfile
Normal 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
179
README.md
Normal 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
0
app/__init__.py
Normal file
44
app/config.py
Normal file
44
app/config.py
Normal 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
0
app/routes/__init__.py
Normal file
137
app/routes/auth.py
Normal file
137
app/routes/auth.py
Normal 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
372
app/routes/managers.py
Normal 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
197
app/routes/offices.py
Normal 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
358
app/routes/parking.py
Normal 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
437
app/routes/presence.py
Normal 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
317
app/routes/users.py
Normal 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
34
compose.yml
Normal 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
183
create_test_db.py
Normal 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
0
database/__init__.py
Normal file
39
database/connection.py
Normal file
39
database/connection.py
Normal 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
232
database/models.py
Normal 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
8
deploy/Caddyfile.snippet
Normal 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
137
deploy/DEPLOY.md
Normal 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`
|
||||
32
deploy/compose.production.yml
Normal file
32
deploy/compose.production.yml
Normal 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
1749
frontend/css/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
4
frontend/favicon.svg
Normal file
4
frontend/favicon.svg
Normal 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
247
frontend/js/admin-users.js
Normal 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
174
frontend/js/api.js
Normal 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
183
frontend/js/nav.js
Normal 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
376
frontend/js/office-rules.js
Normal 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
370
frontend/js/presence.js
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
408
frontend/js/team-calendar.js
Normal file
408
frontend/js/team-calendar.js
Normal 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
222
frontend/js/utils.js
Normal 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
|
||||
};
|
||||
132
frontend/pages/admin-users.html
Normal file
132
frontend/pages/admin-users.html
Normal 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">×</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>
|
||||
32
frontend/pages/landing.html
Normal file
32
frontend/pages/landing.html
Normal 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
64
frontend/pages/login.html
Normal 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>
|
||||
215
frontend/pages/office-rules.html
Normal file
215
frontend/pages/office-rules.html
Normal 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">×</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">×</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">×</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>
|
||||
186
frontend/pages/presence.html
Normal file
186
frontend/pages/presence.html
Normal 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">×</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">×</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">×</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
188
frontend/pages/profile.html
Normal 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>
|
||||
98
frontend/pages/register.html
Normal file
98
frontend/pages/register.html
Normal 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>
|
||||
217
frontend/pages/settings.html
Normal file
217
frontend/pages/settings.html
Normal 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>
|
||||
157
frontend/pages/team-calendar.html
Normal file
157
frontend/pages/team-calendar.html
Normal 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">×</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">×</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
127
main.py
Normal 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
6
requirements.txt
Normal 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
32
run_notifications.py
Normal 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
0
services/__init__.py
Normal file
79
services/auth.py
Normal file
79
services/auth.py
Normal 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
116
services/holidays.py
Normal 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
393
services/notifications.py
Normal 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
343
services/parking.py
Normal 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
0
utils/__init__.py
Normal file
170
utils/auth_middleware.py
Normal file
170
utils/auth_middleware.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user