Refactor to manager-centric model, add team calendar for all users
Key changes: - Removed office-centric model (deleted offices.py, office-rules) - Renamed to team-rules, managers are part of their own team - Team calendar visible to all (read-only for employees) - Admins can have a manager assigned
This commit is contained in:
263
CLAUDE.md
Normal file
263
CLAUDE.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# CLAUDE.md - Project Intelligence
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Org-Parking** is a manager-centric parking spot management system for organizations. It features fair parking assignment based on presence/parking ratio, supporting both standalone JWT authentication and Authelia/LLDAP SSO integration.
|
||||
|
||||
### Technology Stack
|
||||
- **Backend:** FastAPI + SQLAlchemy + SQLite
|
||||
- **Frontend:** Vanilla JavaScript (no frameworks)
|
||||
- **Auth:** JWT tokens + Authelia SSO support
|
||||
- **Containerization:** Docker + Docker Compose
|
||||
|
||||
### Architecture
|
||||
```
|
||||
app/routes/ → API endpoints (auth, users, managers, presence, parking)
|
||||
services/ → Business logic (parking algorithm, auth, notifications)
|
||||
database/ → SQLAlchemy models and connection
|
||||
frontend/ → Static HTML pages + JS modules
|
||||
utils/ → Auth middleware
|
||||
```
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# Docker
|
||||
docker compose up -d
|
||||
|
||||
# Dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Initialize test database
|
||||
python create_test_db.py
|
||||
```
|
||||
|
||||
## Code Style & Conventions
|
||||
|
||||
### Python
|
||||
- FastAPI async patterns with `Depends()` for dependency injection
|
||||
- Pydantic models for request/response validation
|
||||
- SQLAlchemy ORM (no raw SQL)
|
||||
- UUIDs as string primary keys: `str(uuid.uuid4())`
|
||||
- Dates stored as TEXT in "YYYY-MM-DD" format
|
||||
|
||||
### JavaScript
|
||||
- ES6 modules with centralized API client (`/js/api.js`)
|
||||
- Token stored in localStorage, auto-included in requests
|
||||
- Utility functions in `/js/utils.js`
|
||||
- Role-based navigation in `/js/nav.js`
|
||||
|
||||
### Authentication
|
||||
- Dual mode: JWT tokens (standalone) or Authelia headers (SSO)
|
||||
- LDAP users have `password_hash = None`
|
||||
- Check pattern: `config.AUTHELIA_ENABLED and user.password_hash is None`
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Technical Debt
|
||||
|
||||
### Critical
|
||||
|
||||
1. **Inactive Notification System**
|
||||
- Location: [services/notifications.py](services/notifications.py)
|
||||
- Issue: All code implemented but no scheduler integrated
|
||||
- TODO: Integrate APScheduler or similar
|
||||
|
||||
2. **Default SECRET_KEY**
|
||||
- Location: [app/config.py:13](app/config.py#L13)
|
||||
- Issue: Defaults to "change-me-in-production"
|
||||
- Fix: Add startup validation to error if default key used
|
||||
|
||||
3. **No CSRF Protection**
|
||||
- Forms use token auth only, vulnerable to CSRF attacks
|
||||
- Fix: Implement CSRF tokens or validate referer header
|
||||
|
||||
4. **No Rate Limiting**
|
||||
- Login endpoint has no brute force protection
|
||||
- Fix: Add slowapi or similar middleware
|
||||
|
||||
### Performance
|
||||
|
||||
1. **N+1 Query Problems**
|
||||
- Location: [app/routes/managers.py:244-259](app/routes/managers.py#L244-L259)
|
||||
- Location: [app/routes/presence.py:336-419](app/routes/presence.py#L336-L419)
|
||||
- Issue: Loops that query database for each item
|
||||
- Fix: Use joins and relationship loading
|
||||
|
||||
2. **Inefficient Spot Prefix Lookups**
|
||||
- Location: [services/parking.py:56-64](services/parking.py#L56-L64)
|
||||
- Issue: Repeated DB queries for same data
|
||||
- Fix: Cache in request context
|
||||
|
||||
### Code Quality
|
||||
|
||||
1. **Duplicated LDAP Check Logic** (4+ locations)
|
||||
```python
|
||||
# Appears in: users.py:91, 168, 257, 280
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and user.password_hash is None
|
||||
```
|
||||
- Fix: Create `utils.is_ldap_user(user)` helper
|
||||
|
||||
2. **Inline JavaScript in HTML**
|
||||
- 500+ lines embedded across pages
|
||||
- Affected: team-rules.html, team-calendar.html, settings.html
|
||||
- Fix: Extract to separate JS files
|
||||
|
||||
3. **Inconsistent Response Formats**
|
||||
- Some endpoints return dicts, others Pydantic models
|
||||
- Fix: Standardize on Pydantic response schemas
|
||||
|
||||
4. **God Object: User Model**
|
||||
- Location: [database/models.py:11-47](database/models.py#L11-L47)
|
||||
- Issue: 27 columns mixing auth, profile, preferences, manager settings
|
||||
- Fix: Normalize into UserProfile, UserPreferences, ManagerSettings tables
|
||||
|
||||
5. **Repetitive CRUD in managers.py**
|
||||
- Location: [app/routes/managers.py](app/routes/managers.py)
|
||||
- Issue: 4 resources × 3 operations with near-identical code
|
||||
- Fix: Create generic CRUD factory or base class
|
||||
|
||||
6. **Silent Exception Handling**
|
||||
- Location: [app/routes/presence.py:135-143](app/routes/presence.py#L135-L143)
|
||||
- Issue: Catches all exceptions and only prints
|
||||
- Fix: Log properly and propagate meaningful errors
|
||||
|
||||
---
|
||||
|
||||
## Areas for Simplification
|
||||
|
||||
### 1. Consolidate Response Building
|
||||
The `user_to_response()` pattern is duplicated in [users.py:76-107](app/routes/users.py#L76-L107) and [users.py:254-274](app/routes/users.py#L254-L274). Create single reusable function.
|
||||
|
||||
### 2. Generic CRUD Router Factory
|
||||
[managers.py](app/routes/managers.py) has 12 nearly identical endpoints. Create:
|
||||
```python
|
||||
def create_crud_router(model: Type, schema: Type, parent_key: str):
|
||||
router = APIRouter()
|
||||
# Generate GET, POST, DELETE endpoints
|
||||
return router
|
||||
```
|
||||
|
||||
### 3. Frontend State Management
|
||||
Each page maintains its own globals (`currentUser`, `currentData`). Consider simple app-level state or Web Components.
|
||||
|
||||
### 4. Date Handling
|
||||
All dates stored as TEXT strings. Could use proper DATE columns for better query performance and validation.
|
||||
|
||||
### 5. Notification Service Activation
|
||||
Create [services/scheduler.py](services/scheduler.py) with APScheduler:
|
||||
```python
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
scheduler.add_job(process_notification_queue, 'interval', minutes=5)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Improvements Needed
|
||||
|
||||
| Priority | Issue | Location | Recommendation |
|
||||
|----------|-------|----------|----------------|
|
||||
| HIGH | Rate limiting | main.py | Add slowapi middleware |
|
||||
| HIGH | CSRF protection | All forms | Implement CSRF tokens |
|
||||
| HIGH | Secret validation | config.py:13 | Error on default key |
|
||||
| MEDIUM | Password validation | auth.py:63-67 | Enforce complexity rules |
|
||||
| MEDIUM | Input sanitization | notification emails | Use template library |
|
||||
| LOW | CORS configuration | compose.yml | Document production settings |
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy (Missing)
|
||||
|
||||
No tests currently exist. Recommended structure:
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # Fixtures (test DB, client, auth)
|
||||
├── test_auth.py # JWT and Authelia modes
|
||||
├── test_parking.py # Fair assignment algorithm
|
||||
├── test_presence.py # Bulk operations, team calendar
|
||||
├── test_managers.py # CRUD operations
|
||||
└── integration/
|
||||
└── test_workflows.py # End-to-end scenarios
|
||||
```
|
||||
|
||||
Key test scenarios:
|
||||
1. Parking algorithm with varying ratios
|
||||
2. Manager closing days affect assignments
|
||||
3. Guarantee and exclusion rules
|
||||
4. Authelia header authentication flow
|
||||
5. LDAP vs local user password handling
|
||||
|
||||
---
|
||||
|
||||
## API Quick Reference
|
||||
|
||||
### Authentication
|
||||
- `POST /api/auth/register` - Create user (standalone mode)
|
||||
- `POST /api/auth/login` - Get JWT token
|
||||
- `GET /api/auth/me` - Current user (JWT or Authelia)
|
||||
|
||||
### Presence
|
||||
- `POST /api/presence/mark` - Mark single day
|
||||
- `POST /api/presence/mark-bulk` - Mark multiple days
|
||||
- `GET /api/presence/team` - Team calendar with parking
|
||||
|
||||
### Parking
|
||||
- `POST /api/parking/manual-assign` - Manager assigns spot
|
||||
- `POST /api/parking/reassign-spot` - Reassign existing spot
|
||||
- `GET /api/parking/eligible-users/{id}` - Users for reassignment
|
||||
|
||||
### Manager Settings
|
||||
- `GET/POST/DELETE /api/managers/closing-days`
|
||||
- `GET/POST/DELETE /api/managers/weekly-closing-days`
|
||||
- `GET/POST/DELETE /api/managers/guarantees`
|
||||
- `GET/POST/DELETE /api/managers/exclusions`
|
||||
|
||||
---
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Adding a New Route
|
||||
1. Create file in `app/routes/`
|
||||
2. Use `APIRouter(prefix="/api/...", tags=["..."])`
|
||||
3. Register in `main.py`: `app.include_router(...)`
|
||||
4. Add auth dependency: `current_user: User = Depends(get_current_user)`
|
||||
|
||||
### Database Migrations
|
||||
No migration system (Alembic) configured. Schema changes require:
|
||||
1. Update [database/models.py](database/models.py)
|
||||
2. Delete SQLite file or write manual migration
|
||||
3. Run `create_test_db.py` for fresh database
|
||||
|
||||
### Frontend Page Pattern
|
||||
```html
|
||||
<script type="module">
|
||||
import api from '/js/api.js';
|
||||
import { initNav } from '/js/nav.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await api.checkAuth(true); // Redirect to login if not auth'd
|
||||
initNav();
|
||||
// Page-specific logic
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Quick Links
|
||||
|
||||
| Purpose | File |
|
||||
|---------|------|
|
||||
| Main entry | [main.py](main.py) |
|
||||
| Configuration | [app/config.py](app/config.py) |
|
||||
| Database models | [database/models.py](database/models.py) |
|
||||
| Parking algorithm | [services/parking.py](services/parking.py) |
|
||||
| Auth middleware | [utils/auth_middleware.py](utils/auth_middleware.py) |
|
||||
| Frontend API client | [frontend/js/api.js](frontend/js/api.js) |
|
||||
| CSS styles | [frontend/css/styles.css](frontend/css/styles.css) |
|
||||
| Docker config | [compose.yml](compose.yml) |
|
||||
13
README.md
13
README.md
@@ -18,7 +18,6 @@ A manager-centric parking spot management application with fair assignment algor
|
||||
│ ├── 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
|
||||
@@ -116,8 +115,8 @@ Group mapping (follows lldap naming convention):
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| **admin** | Full access, manage users/offices |
|
||||
| **manager** | Manage assigned offices, set rules |
|
||||
| **admin** | Full access, manage users and managers |
|
||||
| **manager** | Manage their team, set parking rules |
|
||||
| **employee** | Mark own presence, view calendar |
|
||||
|
||||
## API Endpoints
|
||||
@@ -137,12 +136,6 @@ Group mapping (follows lldap naming convention):
|
||||
- `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
|
||||
@@ -169,7 +162,7 @@ Group mapping (follows lldap naming convention):
|
||||
Parking spots are assigned based on a fairness ratio:
|
||||
|
||||
```
|
||||
ratio = parking_days / office_days
|
||||
ratio = parking_days / presence_days
|
||||
```
|
||||
|
||||
Users with the lowest ratio get priority. Guaranteed users are always assigned first.
|
||||
|
||||
@@ -28,9 +28,9 @@ 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 (follows lldap naming convention)
|
||||
# Only parking_admins group is synced from LLDAP -> admin role
|
||||
# Manager role and user assignments are managed by admin in the app UI
|
||||
AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins")
|
||||
AUTHELIA_MANAGER_GROUP = os.getenv("AUTHELIA_MANAGER_GROUP", "managers")
|
||||
|
||||
# Email (optional)
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
||||
|
||||
@@ -22,7 +22,7 @@ class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: str
|
||||
office_id: str | None = None
|
||||
manager_id: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -39,7 +39,7 @@ class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str | None
|
||||
office_id: str | None
|
||||
manager_id: str | None
|
||||
role: str
|
||||
manager_parking_quota: int | None = None
|
||||
week_start_day: int = 0
|
||||
@@ -71,7 +71,7 @@ def register(data: RegisterRequest, db: Session = Depends(get_db)):
|
||||
email=data.email,
|
||||
password=data.password,
|
||||
name=data.name,
|
||||
office_id=data.office_id
|
||||
manager_id=data.manager_id
|
||||
)
|
||||
|
||||
token = create_access_token(user.id, user.email)
|
||||
@@ -116,7 +116,7 @@ def get_me(user=Depends(get_current_user)):
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
office_id=user.office_id,
|
||||
manager_id=user.manager_id,
|
||||
role=user.role,
|
||||
manager_parking_quota=user.manager_parking_quota,
|
||||
week_start_day=user.week_start_day or 0,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
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.
|
||||
Key concept: Managers own parking spots and set rules for their managed users.
|
||||
Rules are set at manager level (users have manager_id pointing to their manager).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -13,7 +13,7 @@ import uuid
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import (
|
||||
Office, User, OfficeMembership,
|
||||
User,
|
||||
ManagerClosingDay, ManagerWeeklyClosingDay,
|
||||
ParkingGuarantee, ParkingExclusion
|
||||
)
|
||||
@@ -52,14 +52,12 @@ class ManagerSettingsUpdate(BaseModel):
|
||||
# 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"""
|
||||
"""Get all managers with their managed user count 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 []
|
||||
managed_user_count = db.query(User).filter(User.manager_id == manager.id).count()
|
||||
|
||||
result.append({
|
||||
"id": manager.id,
|
||||
@@ -67,7 +65,7 @@ def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or
|
||||
"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]
|
||||
"managed_user_count": managed_user_count
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -75,14 +73,12 @@ def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or
|
||||
|
||||
@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"""
|
||||
"""Get manager details including managed users 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 []
|
||||
managed_user_count = db.query(User).filter(User.manager_id == manager_id).count()
|
||||
|
||||
return {
|
||||
"id": manager.id,
|
||||
@@ -90,7 +86,7 @@ def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Dep
|
||||
"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]
|
||||
"managed_user_count": managed_user_count
|
||||
}
|
||||
|
||||
|
||||
@@ -131,19 +127,16 @@ def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Se
|
||||
|
||||
@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"""
|
||||
"""Get all users in a manager's team (including the manager themselves)"""
|
||||
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]
|
||||
# Include users managed by this manager + the manager themselves
|
||||
users = db.query(User).filter(
|
||||
(User.manager_id == manager_id) | (User.id == manager_id)
|
||||
).all()
|
||||
return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role} for u in users]
|
||||
|
||||
|
||||
# Closing days
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import DailyParkingAssignment, User, OfficeMembership
|
||||
from database.models import DailyParkingAssignment, User
|
||||
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
|
||||
@@ -49,7 +49,6 @@ class AssignmentResponse(BaseModel):
|
||||
manager_id: str
|
||||
user_name: str | None = None
|
||||
user_email: str | None = None
|
||||
user_office_id: str | None = None
|
||||
|
||||
|
||||
# Routes
|
||||
@@ -102,7 +101,6 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
|
||||
if user:
|
||||
result.user_name = user.name
|
||||
result.user_email = user.email
|
||||
result.user_office_id = user.office_id
|
||||
|
||||
results.append(result)
|
||||
|
||||
@@ -307,7 +305,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
||||
@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.
|
||||
Returns users managed by the same manager.
|
||||
"""
|
||||
assignment = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.id == assignment_id
|
||||
@@ -324,16 +322,9 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
|
||||
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
|
||||
# Get users in this manager's team (including the manager themselves)
|
||||
users = db.query(User).filter(
|
||||
User.office_id.in_(managed_office_ids),
|
||||
(User.manager_id == assignment.manager_id) | (User.id == assignment.manager_id),
|
||||
User.id != assignment.user_id # Exclude current holder
|
||||
).all()
|
||||
|
||||
@@ -351,8 +342,7 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
"office_id": user.office_id
|
||||
"email": user.email
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@@ -10,8 +10,8 @@ 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 database.models import UserPresence, User, DailyParkingAssignment
|
||||
from utils.auth_middleware import get_current_user, require_manager_or_admin
|
||||
from services.parking import handle_presence_change, get_spot_display_name
|
||||
|
||||
router = APIRouter(prefix="/api/presence", tags=["presence"])
|
||||
@@ -70,6 +70,20 @@ def parse_date(date_str: str) -> datetime:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
|
||||
def check_manager_access(current_user: User, target_user: User, db: Session):
|
||||
"""Check if current_user has access to target_user"""
|
||||
if current_user.role == "admin":
|
||||
return True
|
||||
|
||||
if current_user.role == "manager":
|
||||
# Manager can access users they manage
|
||||
if target_user.manager_id == current_user.id:
|
||||
return True
|
||||
raise HTTPException(status_code=403, detail="User is not managed by you")
|
||||
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
def _mark_presence_for_user(
|
||||
user_id: str,
|
||||
date: str,
|
||||
@@ -111,12 +125,18 @@ def _mark_presence_for_user(
|
||||
db.refresh(presence)
|
||||
|
||||
# Handle parking assignment
|
||||
if old_status != status and target_user.office_id:
|
||||
# Use manager_id if user has one, or user's own id if they are a manager
|
||||
parking_manager_id = target_user.manager_id
|
||||
if not parking_manager_id and target_user.role == "manager":
|
||||
# Manager is part of their own team for parking purposes
|
||||
parking_manager_id = target_user.id
|
||||
|
||||
if old_status != status and parking_manager_id:
|
||||
try:
|
||||
handle_presence_change(
|
||||
user_id, date,
|
||||
old_status or "absent", status,
|
||||
target_user.office_id, db
|
||||
parking_manager_id, db
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Warning: Parking handler failed: {e}")
|
||||
@@ -177,12 +197,17 @@ def _bulk_mark_presence(
|
||||
results.append(presence)
|
||||
|
||||
# Handle parking for each date
|
||||
if old_status != status and target_user.office_id:
|
||||
# Use manager_id if user has one, or user's own id if they are a manager
|
||||
parking_manager_id = target_user.manager_id
|
||||
if not parking_manager_id and target_user.role == "manager":
|
||||
parking_manager_id = target_user.id
|
||||
|
||||
if old_status != status and parking_manager_id:
|
||||
try:
|
||||
handle_presence_change(
|
||||
user_id, date_str,
|
||||
old_status or "absent", status,
|
||||
target_user.office_id, db
|
||||
parking_manager_id, db
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -216,12 +241,17 @@ def _delete_presence(
|
||||
db.delete(presence)
|
||||
db.commit()
|
||||
|
||||
if target_user.office_id:
|
||||
# Use manager_id if user has one, or user's own id if they are a manager
|
||||
parking_manager_id = target_user.manager_id
|
||||
if not parking_manager_id and target_user.role == "manager":
|
||||
parking_manager_id = target_user.id
|
||||
|
||||
if parking_manager_id:
|
||||
try:
|
||||
handle_presence_change(
|
||||
user_id, date,
|
||||
old_status, "absent",
|
||||
target_user.office_id, db
|
||||
parking_manager_id, db
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -274,7 +304,7 @@ def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(ge
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
check_manager_access_to_user(current_user, target_user, db)
|
||||
check_manager_access(current_user, target_user, db)
|
||||
return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user)
|
||||
|
||||
|
||||
@@ -285,7 +315,7 @@ def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depen
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
check_manager_access_to_user(current_user, target_user, db)
|
||||
check_manager_access(current_user, target_user, db)
|
||||
return _bulk_mark_presence(
|
||||
data.user_id, data.start_date, data.end_date,
|
||||
data.status, data.days, db, target_user
|
||||
@@ -299,34 +329,42 @@ def admin_delete_presence(user_id: str, date: str, db: Session = Depends(get_db)
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
check_manager_access_to_user(current_user, target_user, db)
|
||||
check_manager_access(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"""
|
||||
def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get team presences with parking info, filtered by manager.
|
||||
- Admins can see all teams
|
||||
- Managers see their own team
|
||||
- Employees can only see their own team (read-only view)
|
||||
"""
|
||||
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()
|
||||
# Note: Manager is part of their own team (for parking assignment purposes)
|
||||
if current_user.role == "employee":
|
||||
# Employees can only see their own team (users with same manager_id + the manager)
|
||||
if not current_user.manager_id:
|
||||
return [] # No manager assigned, no team to show
|
||||
users = db.query(User).filter(
|
||||
(User.manager_id == current_user.manager_id) | (User.id == current_user.manager_id)
|
||||
).all()
|
||||
elif manager_id:
|
||||
# Filter by specific manager (for admins/managers) - include the manager themselves
|
||||
users = db.query(User).filter(
|
||||
(User.manager_id == manager_id) | (User.id == manager_id)
|
||||
).all()
|
||||
elif current_user.role == "admin":
|
||||
# Admin sees all users
|
||||
users = db.query(User).all()
|
||||
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()
|
||||
# Manager sees their team + themselves
|
||||
users = db.query(User).filter(
|
||||
(User.manager_id == current_user.id) | (User.id == current_user.id)
|
||||
).all()
|
||||
|
||||
# Batch query presences and parking for all users
|
||||
user_ids = [u.id for u in users]
|
||||
@@ -358,30 +396,21 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
|
||||
"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 manager lookup for display
|
||||
manager_ids = list(set(u.manager_id for u in users if u.manager_id))
|
||||
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
|
||||
manager_lookup = {m.id: m.name for m in managers}
|
||||
|
||||
# Build 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,
|
||||
"manager_id": user.manager_id,
|
||||
"manager_name": manager_lookup.get(user.manager_id),
|
||||
"presences": [{"date": p.date, "status": p.status} for p in user_presences],
|
||||
"parking_dates": parking_lookup.get(user.id, []),
|
||||
"parking_info": parking_info_lookup.get(user.id, [])
|
||||
@@ -397,7 +426,7 @@ def get_user_presences(user_id: str, start_date: str = None, end_date: str = Non
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
check_manager_access_to_user(current_user, target_user, db)
|
||||
check_manager_access(current_user, target_user, db)
|
||||
|
||||
query = db.query(UserPresence).filter(UserPresence.user_id == user_id)
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ import uuid
|
||||
import re
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import User, Office, OfficeMembership
|
||||
from database.models import User
|
||||
from utils.auth_middleware import get_current_user, require_admin
|
||||
from services.auth import hash_password, verify_password
|
||||
from app import config
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
@@ -24,21 +25,19 @@ class UserCreate(BaseModel):
|
||||
password: str
|
||||
name: str | None = None
|
||||
role: str = "employee"
|
||||
office_id: str | None = None
|
||||
manager_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_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):
|
||||
@@ -61,44 +60,86 @@ class UserResponse(BaseModel):
|
||||
email: str
|
||||
name: str | None
|
||||
role: str
|
||||
office_id: str | None
|
||||
manager_id: str | None = None
|
||||
manager_name: str | None = None
|
||||
manager_parking_quota: int | None = None
|
||||
manager_spot_prefix: str | None = None
|
||||
managed_user_count: int | None = None
|
||||
is_ldap_user: bool = False
|
||||
is_ldap_admin: bool = False
|
||||
created_at: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
def user_to_response(user: User, db: Session) -> dict:
|
||||
"""Convert user to response dict with computed fields"""
|
||||
# Get manager name if user has a manager
|
||||
manager_name = None
|
||||
if user.manager_id:
|
||||
manager = db.query(User).filter(User.id == user.manager_id).first()
|
||||
if manager:
|
||||
manager_name = manager.name
|
||||
|
||||
# Count managed users if this user is a manager
|
||||
managed_user_count = None
|
||||
if user.role == "manager":
|
||||
managed_user_count = db.query(User).filter(User.manager_id == user.id).count()
|
||||
|
||||
# Determine if user is LDAP-managed
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and user.password_hash is None
|
||||
is_ldap_admin = is_ldap_user and user.role == "admin"
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"manager_id": user.manager_id,
|
||||
"manager_name": manager_name,
|
||||
"manager_parking_quota": user.manager_parking_quota,
|
||||
"manager_spot_prefix": user.manager_spot_prefix,
|
||||
"managed_user_count": managed_user_count,
|
||||
"is_ldap_user": is_ldap_user,
|
||||
"is_ldap_admin": is_ldap_admin,
|
||||
"created_at": user.created_at
|
||||
}
|
||||
|
||||
|
||||
# Admin Routes
|
||||
@router.get("", response_model=List[UserResponse])
|
||||
@router.get("")
|
||||
def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""List all users (admin only)"""
|
||||
users = db.query(User).all()
|
||||
return users
|
||||
return [user_to_response(u, db) for u in users]
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
@router.get("/{user_id}")
|
||||
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
|
||||
return user_to_response(target, db)
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse)
|
||||
@router.post("")
|
||||
def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
|
||||
"""Create new user (admin only)"""
|
||||
"""Create new user (admin only) - only for non-LDAP mode"""
|
||||
if config.AUTHELIA_ENABLED:
|
||||
raise HTTPException(status_code=400, detail="User creation disabled in LDAP mode. Users are created on first login.")
|
||||
|
||||
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")
|
||||
if data.manager_id:
|
||||
manager = db.query(User).filter(User.id == data.manager_id).first()
|
||||
if not manager or manager.role != "manager":
|
||||
raise HTTPException(status_code=400, detail="Invalid manager")
|
||||
|
||||
new_user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -106,42 +147,61 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
|
||||
password_hash=hash_password(data.password),
|
||||
name=data.name,
|
||||
role=data.role,
|
||||
office_id=data.office_id,
|
||||
manager_id=data.manager_id,
|
||||
created_at=datetime.utcnow().isoformat()
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
return user_to_response(new_user, db)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
@router.put("/{user_id}")
|
||||
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
|
||||
# Check if user is LDAP-managed
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and target.password_hash is None
|
||||
is_ldap_admin = is_ldap_user and target.role == "admin"
|
||||
|
||||
# Name update - blocked for LDAP users
|
||||
if data.name is not None:
|
||||
if is_ldap_user:
|
||||
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
|
||||
target.name = data.name
|
||||
|
||||
# Role update
|
||||
if data.role is not None:
|
||||
if data.role not in ["admin", "manager", "employee"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
# Can't change admin role for LDAP admins (they get admin from parking_admins group)
|
||||
if is_ldap_admin and data.role != "admin":
|
||||
raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)")
|
||||
# If changing from manager to another role, check for managed users
|
||||
if target.role == "manager" and data.role != "manager":
|
||||
managed_count = db.query(User).filter(User.manager_id == user_id).count()
|
||||
if managed_count > 0:
|
||||
raise HTTPException(status_code=400, detail=f"Cannot change role: {managed_count} users are assigned to this manager")
|
||||
# Clear manager-specific fields
|
||||
target.manager_parking_quota = 0
|
||||
target.manager_spot_prefix = None
|
||||
target.role = data.role
|
||||
|
||||
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
|
||||
# Manager assignment (any user including admins can be assigned to a manager)
|
||||
if data.manager_id is not None:
|
||||
if data.manager_id:
|
||||
manager = db.query(User).filter(User.id == data.manager_id).first()
|
||||
if not manager or manager.role != "manager":
|
||||
raise HTTPException(status_code=400, detail="Invalid manager")
|
||||
if data.manager_id == user_id:
|
||||
raise HTTPException(status_code=400, detail="User cannot be their own manager")
|
||||
target.manager_id = data.manager_id if data.manager_id else None
|
||||
|
||||
# Manager-specific fields
|
||||
if data.manager_parking_quota is not None:
|
||||
if target.role != "manager":
|
||||
raise HTTPException(status_code=400, detail="Parking quota only for managers")
|
||||
@@ -166,7 +226,7 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
|
||||
target.updated_at = datetime.utcnow().isoformat()
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
return target
|
||||
return user_to_response(target, db)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
@@ -179,60 +239,53 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Check if user is a manager with managed users
|
||||
if target.role == "manager":
|
||||
managed_count = db.query(User).filter(User.manager_id == user_id).count()
|
||||
if managed_count > 0:
|
||||
raise HTTPException(status_code=400, detail=f"Cannot delete: {managed_count} users are assigned to this manager")
|
||||
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
return {"message": "User deleted"}
|
||||
|
||||
|
||||
# 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)):
|
||||
def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get current user's profile"""
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None
|
||||
|
||||
# Get manager name
|
||||
manager_name = None
|
||||
if current_user.manager_id:
|
||||
manager = db.query(User).filter(User.id == current_user.manager_id).first()
|
||||
if manager:
|
||||
manager_name = manager.name
|
||||
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"email": current_user.email,
|
||||
"name": current_user.name,
|
||||
"role": current_user.role,
|
||||
"office_id": current_user.office_id
|
||||
"manager_id": current_user.manager_id,
|
||||
"manager_name": manager_name,
|
||||
"is_ldap_user": is_ldap_user
|
||||
}
|
||||
|
||||
|
||||
@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"""
|
||||
"""Update current user's profile (limited fields)"""
|
||||
is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None
|
||||
|
||||
if data.name is not None:
|
||||
if is_ldap_user:
|
||||
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
|
||||
current_user.name = data.name
|
||||
current_user.updated_at = datetime.utcnow().isoformat()
|
||||
db.commit()
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
@@ -292,7 +345,10 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current
|
||||
|
||||
@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"""
|
||||
"""Change current user's password (not available in LDAP mode)"""
|
||||
if config.AUTHELIA_ENABLED and current_user.password_hash is None:
|
||||
raise HTTPException(status_code=400, detail="Password is managed by LDAP")
|
||||
|
||||
if not verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
|
||||
|
||||
13
compose.yml
13
compose.yml
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
parking:
|
||||
build: .
|
||||
container_name: parking-manager
|
||||
container_name: parking
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -26,9 +26,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- org-network
|
||||
|
||||
# For production with external network (Caddy/Authelia):
|
||||
# networks:
|
||||
# default:
|
||||
# external: true
|
||||
# name: proxy
|
||||
networks:
|
||||
org-network:
|
||||
external: true
|
||||
name: org-stack_org-network
|
||||
|
||||
@@ -3,19 +3,14 @@ Create test database with sample data
|
||||
Run: .venv/bin/python create_test_db.py
|
||||
|
||||
Manager-centric model:
|
||||
- Users have a manager_id pointing to their manager
|
||||
- Managers own parking spots (manager_parking_quota)
|
||||
- Each manager has a spot prefix (A, B, C...) for display names
|
||||
- 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 database.models import Base, User
|
||||
from services.auth import hash_password
|
||||
|
||||
# Drop and recreate all tables for clean slate
|
||||
@@ -27,29 +22,8 @@ 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
|
||||
# Create users with manager-centric model
|
||||
# manager_id points to the user's manager
|
||||
|
||||
users_data = [
|
||||
{
|
||||
@@ -57,15 +31,15 @@ users_data = [
|
||||
"email": "admin@example.com",
|
||||
"name": "Admin User",
|
||||
"role": "admin",
|
||||
"office_id": "presales", # Primary office from LDAP groups
|
||||
"manager_id": None, # Admins don't have managers
|
||||
},
|
||||
{
|
||||
"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_id": None, # Managers don't have managers
|
||||
"manager_parking_quota": 3,
|
||||
"manager_spot_prefix": "A",
|
||||
},
|
||||
{
|
||||
@@ -73,8 +47,8 @@ users_data = [
|
||||
"email": "manager2@example.com",
|
||||
"name": "Bob Manager",
|
||||
"role": "manager",
|
||||
"office_id": "operations",
|
||||
"manager_parking_quota": 2, # For 3 users: bob, user4, user5
|
||||
"manager_id": None,
|
||||
"manager_parking_quota": 2,
|
||||
"manager_spot_prefix": "B",
|
||||
},
|
||||
{
|
||||
@@ -82,35 +56,35 @@ users_data = [
|
||||
"email": "user1@example.com",
|
||||
"name": "User One",
|
||||
"role": "employee",
|
||||
"office_id": "presales",
|
||||
"manager_id": "manager1", # Managed by Alice
|
||||
},
|
||||
{
|
||||
"id": "user2",
|
||||
"email": "user2@example.com",
|
||||
"name": "User Two",
|
||||
"role": "employee",
|
||||
"office_id": "design",
|
||||
"manager_id": "manager1", # Managed by Alice
|
||||
},
|
||||
{
|
||||
"id": "user3",
|
||||
"email": "user3@example.com",
|
||||
"name": "User Three",
|
||||
"role": "employee",
|
||||
"office_id": "design",
|
||||
"manager_id": "manager1", # Managed by Alice
|
||||
},
|
||||
{
|
||||
"id": "user4",
|
||||
"email": "user4@example.com",
|
||||
"name": "User Four",
|
||||
"role": "employee",
|
||||
"office_id": "operations",
|
||||
"manager_id": "manager2", # Managed by Bob
|
||||
},
|
||||
{
|
||||
"id": "user5",
|
||||
"email": "user5@example.com",
|
||||
"name": "User Five",
|
||||
"role": "employee",
|
||||
"office_id": "operations",
|
||||
"manager_id": "manager2", # Managed by Bob
|
||||
},
|
||||
]
|
||||
|
||||
@@ -121,7 +95,7 @@ for data in users_data:
|
||||
password_hash=password_hash,
|
||||
name=data["name"],
|
||||
role=data["role"],
|
||||
office_id=data.get("office_id"),
|
||||
manager_id=data.get("manager_id"),
|
||||
manager_parking_quota=data.get("manager_parking_quota", 0),
|
||||
manager_spot_prefix=data.get("manager_spot_prefix"),
|
||||
created_at=now
|
||||
@@ -129,30 +103,6 @@ for data in users_data:
|
||||
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()
|
||||
|
||||
@@ -161,23 +111,22 @@ 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(f"{'Email':<25} {'Role':<10} {'Manager':<15}")
|
||||
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(f"{'admin@example.com':<25} {'admin':<10} {'-':<15}")
|
||||
print(f"{'manager1@example.com':<25} {'manager':<10} {'-':<15}")
|
||||
print(f"{'manager2@example.com':<25} {'manager':<10} {'-':<15}")
|
||||
print(f"{'user1@example.com':<25} {'employee':<10} {'Alice':<15}")
|
||||
print(f"{'user2@example.com':<25} {'employee':<10} {'Alice':<15}")
|
||||
print(f"{'user3@example.com':<25} {'employee':<10} {'Alice':<15}")
|
||||
print(f"{'user4@example.com':<25} {'employee':<10} {'Bob':<15}")
|
||||
print(f"{'user5@example.com':<25} {'employee':<10} {'Bob':<15}")
|
||||
print("-"*60)
|
||||
print("\nParking pools:")
|
||||
print(" Alice (manager1): 3 spots (A1,A2,A3)")
|
||||
print(" -> presales: admin, alice, user1")
|
||||
print(" -> design: user2, user3")
|
||||
print(" -> 5 users, 3 spots = 60% ratio target")
|
||||
print(" -> manages: user1, user2, user3")
|
||||
print(" -> 3 users, 3 spots = 100% ratio target")
|
||||
print()
|
||||
print(" Bob (manager2): 2 spots (B1,B2)")
|
||||
print(" -> operations: bob, user4, user5")
|
||||
print(" -> 3 users, 2 spots = 67% ratio target")
|
||||
print(" -> manages: user4, user5")
|
||||
print(" -> 2 users, 2 spots = 100% ratio target")
|
||||
|
||||
@@ -36,4 +36,7 @@ def get_db_session():
|
||||
def init_db():
|
||||
"""Create all tables"""
|
||||
from database.models import Base
|
||||
|
||||
print(f"[init_db] Initializing database at {config.DATABASE_URL}")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print(f"[init_db] Tables created: {list(Base.metadata.tables.keys())}")
|
||||
|
||||
@@ -17,7 +17,7 @@ class User(Base):
|
||||
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_id = Column(Text, ForeignKey("users.id")) # Who manages this user (any user can have a manager)
|
||||
|
||||
# Manager-specific fields (only relevant for role='manager')
|
||||
manager_parking_quota = Column(Integer, default=0) # Number of spots this manager controls
|
||||
@@ -37,51 +37,13 @@ class User(Base):
|
||||
updated_at = Column(Text)
|
||||
|
||||
# Relationships
|
||||
office = relationship("Office", back_populates="users")
|
||||
manager = relationship("User", remote_side=[id], backref="managed_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),
|
||||
Index('idx_user_manager', 'manager_id'),
|
||||
)
|
||||
|
||||
|
||||
@@ -128,7 +90,7 @@ class DailyParkingAssignment(Base):
|
||||
|
||||
|
||||
class ManagerClosingDay(Base):
|
||||
"""Specific date closing days for a manager's offices (holidays, special closures)"""
|
||||
"""Specific date closing days for a manager's parking pool (holidays, special closures)"""
|
||||
__tablename__ = "manager_closing_days"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
@@ -145,7 +107,7 @@ class ManagerClosingDay(Base):
|
||||
|
||||
|
||||
class ManagerWeeklyClosingDay(Base):
|
||||
"""Weekly recurring closing days for a manager's offices (e.g., Saturday and Sunday)"""
|
||||
"""Weekly recurring closing days for a manager's parking pool (e.g., Saturday and Sunday)"""
|
||||
__tablename__ = "manager_weekly_closing_days"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
|
||||
190
deploy/DEPLOY.md
190
deploy/DEPLOY.md
@@ -3,55 +3,52 @@
|
||||
## Prerequisites
|
||||
|
||||
- org-stack running on rocky@rocketscale.it
|
||||
- Git repository on git.rocketscale.it
|
||||
- Git repository on git.rocketscale.it (optional, can use rsync)
|
||||
|
||||
## Step 1: Push to Git
|
||||
## Directory Structure
|
||||
|
||||
```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
|
||||
Parking is deployed as a **separate directory** alongside org-stack:
|
||||
|
||||
```
|
||||
~/
|
||||
├── org-stack/ # Main stack (Caddy, Authelia, LLDAP, etc.)
|
||||
│ ├── compose.yml
|
||||
│ ├── Caddyfile
|
||||
│ ├── authelia/
|
||||
│ └── .env
|
||||
│
|
||||
└── org-parking/ # Parking app (separate)
|
||||
├── compose.yml # Production compose (connects to org-stack network)
|
||||
├── .env # Own .env with PARKING_SECRET_KEY
|
||||
├── Dockerfile
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Step 2: Clone on Server
|
||||
## Step 1: Deploy to Server
|
||||
|
||||
Option A - Using rsync (recommended for development):
|
||||
```bash
|
||||
# From development machine
|
||||
rsync -avz --exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \
|
||||
--exclude '.env' --exclude 'data/' --exclude '*.db' --exclude '.venv/' \
|
||||
/path/to/org-parking/ rocky@rocketscale.it:~/org-parking/
|
||||
```
|
||||
|
||||
Option B - Using git:
|
||||
```bash
|
||||
# SSH to server
|
||||
ssh rocky@rocketscale.it
|
||||
|
||||
# Clone into org-stack
|
||||
cd ~/org-stack
|
||||
git clone git@git.rocketscale.it:rocky/parking-manager.git parking
|
||||
cd ~
|
||||
git clone git@git.rocketscale.it:rocky/parking-manager.git org-parking
|
||||
```
|
||||
|
||||
## Step 3: Add to .env
|
||||
## Step 2: Create Production compose.yml
|
||||
|
||||
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`:
|
||||
Create `~/org-parking/compose.yml` on the server:
|
||||
|
||||
```yaml
|
||||
# ===========================================================================
|
||||
# Parking Manager - Parking Spot Management
|
||||
# ===========================================================================
|
||||
services:
|
||||
parking:
|
||||
build: ./parking
|
||||
build: .
|
||||
container_name: parking
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
@@ -68,18 +65,34 @@ Add the parking service to `~/org-stack/compose.yml`:
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
networks:
|
||||
- org-network
|
||||
depends_on:
|
||||
- authelia
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
parking_data:
|
||||
|
||||
networks:
|
||||
org-network:
|
||||
external: true
|
||||
name: org-stack_org-network
|
||||
```
|
||||
|
||||
Add to volumes section:
|
||||
```yaml
|
||||
parking_data: # Parking SQLite database
|
||||
## Step 3: Create .env File
|
||||
|
||||
Create `~/org-parking/.env` with a secret key:
|
||||
|
||||
```bash
|
||||
cd ~/org-parking
|
||||
python3 -c "import secrets; print(f'PARKING_SECRET_KEY={secrets.token_hex(32)}')" > .env
|
||||
```
|
||||
|
||||
Add `parking` to Caddy's depends_on list.
|
||||
**Note**: Each directory needs its own `.env` file since docker compose only reads from the current directory.
|
||||
|
||||
## Step 5: Add to Caddyfile
|
||||
## Step 4: Add to Caddyfile
|
||||
|
||||
Add to `~/org-stack/Caddyfile`:
|
||||
|
||||
@@ -91,27 +104,57 @@ parking.rocketscale.it {
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Create LLDAP Groups
|
||||
## Step 5: Add Authelia Access Control Rule
|
||||
|
||||
**Important**: Authelia's `access_control` must include parking.rocketscale.it or you'll get 403 Forbidden.
|
||||
|
||||
Edit `~/org-stack/authelia/configuration.yml` and add to the `access_control.rules` section:
|
||||
|
||||
```yaml
|
||||
access_control:
|
||||
default_policy: deny
|
||||
rules:
|
||||
# ... existing rules ...
|
||||
|
||||
# Parking Manager - require authentication
|
||||
- domain: parking.rocketscale.it
|
||||
policy: one_factor
|
||||
```
|
||||
|
||||
After editing, restart Authelia:
|
||||
```bash
|
||||
cd ~/org-stack
|
||||
docker compose restart authelia
|
||||
```
|
||||
|
||||
## Step 6: Create LLDAP Group
|
||||
|
||||
In lldap (https://ldap.rocketscale.it):
|
||||
|
||||
1. Create group: `parking_admins` (follows lldap naming convention)
|
||||
2. Create group: `managers` (reusable across apps)
|
||||
3. Add yourself to `parking_admins`
|
||||
1. Create group: `parking_admins`
|
||||
2. Add yourself (or whoever should be admin) to this group
|
||||
|
||||
## Step 7: Deploy
|
||||
**Role Management:**
|
||||
- `parking_admins` group → **admin** role (synced from LLDAP on each login)
|
||||
- **manager** role → assigned by admin in the app UI (Manage Users page)
|
||||
- **employee** role → default for all other users
|
||||
|
||||
The admin can promote users to manager and assign offices via the Manage Users page.
|
||||
|
||||
## Step 7: Build and Deploy
|
||||
|
||||
```bash
|
||||
cd ~/org-stack
|
||||
|
||||
# Build and start parking service
|
||||
cd ~/org-parking
|
||||
docker compose build parking
|
||||
docker compose up -d parking
|
||||
docker compose up -d
|
||||
|
||||
# Reload Caddy to pick up new domain
|
||||
# Reload Caddy to pick up new domain (from org-stack)
|
||||
cd ~/org-stack
|
||||
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
|
||||
# Check logs
|
||||
cd ~/org-parking
|
||||
docker compose logs -f parking
|
||||
```
|
||||
|
||||
@@ -124,14 +167,53 @@ docker compose logs -f parking
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 403 Forbidden from Authelia
|
||||
- Authelia's `access_control` doesn't have a rule for parking.rocketscale.it
|
||||
- Add the domain to `~/org-stack/authelia/configuration.yml` (see Step 6)
|
||||
- Restart Authelia: `docker compose restart authelia`
|
||||
|
||||
### 401 Unauthorized
|
||||
- Check Authelia headers are being passed
|
||||
- Check `docker compose logs authelia`
|
||||
|
||||
### Login redirect loop (keeps redirecting to /login)
|
||||
- Frontend JS must use async auth checking for Authelia mode
|
||||
- The `api.requireAuth()` must call `/api/auth/me` endpoint (not check localStorage)
|
||||
- Ensure all page JS files use: `currentUser = await api.requireAuth();`
|
||||
|
||||
### User has wrong role
|
||||
- Verify LLDAP group membership
|
||||
- Roles sync on each login
|
||||
- **Admin role**: Verify user is in `parking_admins` LLDAP group (synced on each login)
|
||||
- **Manager role**: Must be assigned by admin via Manage Users page (not from LLDAP)
|
||||
- **Employee role**: Default for users not in `parking_admins` group
|
||||
|
||||
### Database errors
|
||||
- Check volume mount: `docker compose exec parking ls -la /app/data`
|
||||
- Check permissions: `docker compose exec parking id`
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Authelia Integration
|
||||
The app supports two authentication modes:
|
||||
1. **JWT mode** (standalone): Users login via `/login`, get JWT token stored in localStorage
|
||||
2. **Authelia mode** (SSO): Authelia handles login, passes headers to backend
|
||||
|
||||
When `AUTHELIA_ENABLED=true`:
|
||||
- Backend reads user info from headers: `Remote-User`, `Remote-Email`, `Remote-Name`, `Remote-Groups`
|
||||
- Users are auto-created on first login
|
||||
- Roles are synced from LLDAP groups on each request
|
||||
- Frontend calls `/api/auth/me` to get user info (backend reads headers)
|
||||
|
||||
### Role Management
|
||||
|
||||
Only the **admin** role is synced from LLDAP:
|
||||
```python
|
||||
AUTHELIA_ADMIN_GROUP = "parking_admins" # → role: admin
|
||||
```
|
||||
|
||||
Other roles are managed within the app:
|
||||
- **manager**: Assigned by admin via Manage Users page
|
||||
- **employee**: Default role for all non-admin users
|
||||
|
||||
This separation allows:
|
||||
- LLDAP to control who has admin access
|
||||
- App admin to assign manager roles and office assignments without LLDAP changes
|
||||
|
||||
@@ -1,141 +1,138 @@
|
||||
/**
|
||||
* Admin Users Page
|
||||
* Manage all users in the system
|
||||
* Manage users with LDAP-aware editing
|
||||
*/
|
||||
|
||||
let currentUser = null;
|
||||
let users = [];
|
||||
let offices = [];
|
||||
let managedOfficesMap = {}; // user_id -> [office_ids]
|
||||
let managers = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!api.requireAuth()) return;
|
||||
|
||||
currentUser = await api.getCurrentUser();
|
||||
currentUser = await api.requireAuth();
|
||||
if (!currentUser) return;
|
||||
|
||||
// Only admins can access
|
||||
if (currentUser.role !== 'admin') {
|
||||
window.location.href = '/presence';
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadUsers(), loadOffices()]);
|
||||
await loadManagedOffices();
|
||||
renderUsers();
|
||||
await loadManagers();
|
||||
await loadUsers();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
async function loadManagers() {
|
||||
const response = await api.get('/api/managers');
|
||||
if (response && response.ok) {
|
||||
managers = await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const response = await api.get('/api/users');
|
||||
if (response && response.ok) {
|
||||
users = await response.json();
|
||||
renderUsers();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
const filterLower = filter.toLowerCase();
|
||||
|
||||
let filtered = users;
|
||||
if (filter) {
|
||||
filtered = users.filter(u =>
|
||||
(u.name || '').toLowerCase().includes(filterLower) ||
|
||||
(u.email || '').toLowerCase().includes(filterLower) ||
|
||||
(u.role || '').toLowerCase().includes(filterLower) ||
|
||||
(u.manager_name || '').toLowerCase().includes(filterLower)
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center">No users found</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="5" 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) : '-';
|
||||
const ldapBadge = user.is_ldap_user ? '<span class="badge badge-info">LDAP</span>' : '';
|
||||
const managerInfo = user.role === 'manager'
|
||||
? `<span class="badge badge-success">${user.managed_user_count || 0} users</span>`
|
||||
: (user.manager_name || '-');
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${user.name}</td>
|
||||
<td>${user.name || '-'} ${ldapBadge}</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><span class="badge badge-${getRoleBadgeClass(user.role)}">${user.role}</span></td>
|
||||
<td>${managerInfo}</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>
|
||||
` : ''}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')" ${user.id === currentUser.id ? 'disabled' : ''}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function editUser(userId) {
|
||||
function getRoleBadgeClass(role) {
|
||||
switch (role) {
|
||||
case 'admin': return 'danger';
|
||||
case 'manager': return 'warning';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
async function editUser(userId) {
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (!user) return;
|
||||
|
||||
// Populate form
|
||||
document.getElementById('userId').value = user.id;
|
||||
document.getElementById('editName').value = user.name;
|
||||
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';
|
||||
// Populate manager dropdown
|
||||
const managerSelect = document.getElementById('editManager');
|
||||
managerSelect.innerHTML = '<option value="">No manager</option>';
|
||||
managers.forEach(m => {
|
||||
if (m.id !== userId) { // Can't be own manager
|
||||
const option = document.createElement('option');
|
||||
option.value = m.id;
|
||||
option.textContent = m.name;
|
||||
if (m.id === user.manager_id) option.selected = true;
|
||||
managerSelect.appendChild(option);
|
||||
}
|
||||
});
|
||||
|
||||
// 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';
|
||||
// Handle LDAP restrictions
|
||||
const isLdap = user.is_ldap_user;
|
||||
const isLdapAdmin = user.is_ldap_admin;
|
||||
|
||||
// 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('');
|
||||
// LDAP notice
|
||||
document.getElementById('ldapNotice').style.display = isLdap ? 'block' : 'none';
|
||||
|
||||
// Name field - disabled for LDAP users
|
||||
const nameInput = document.getElementById('editName');
|
||||
nameInput.disabled = isLdap;
|
||||
document.getElementById('nameHelp').style.display = isLdap ? 'block' : 'none';
|
||||
|
||||
// Role field - admin option disabled for LDAP admins (they can't be demoted)
|
||||
const roleSelect = document.getElementById('editRole');
|
||||
roleSelect.disabled = isLdapAdmin;
|
||||
document.getElementById('roleHelp').style.display = isLdapAdmin ? 'block' : 'none';
|
||||
|
||||
// Manager group - show for all users (admins can also be assigned to a manager)
|
||||
document.getElementById('managerGroup').style.display = 'block';
|
||||
|
||||
// Manager fields - show only for managers
|
||||
document.getElementById('managerFields').style.display = user.role === 'manager' ? 'block' : 'none';
|
||||
|
||||
document.getElementById('userModalTitle').textContent = 'Edit User';
|
||||
document.getElementById('userModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
@@ -143,14 +140,12 @@ 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;
|
||||
if (!confirm(`Delete user "${user.name || user.email}"?`)) return;
|
||||
|
||||
const response = await api.delete(`/api/users/${userId}`);
|
||||
if (response && response.ok) {
|
||||
await loadUsers();
|
||||
await loadManagedOffices();
|
||||
renderUsers();
|
||||
utils.showMessage('User deleted', 'success');
|
||||
await loadUsers();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
utils.showMessage(error.detail || 'Failed to delete user', 'error');
|
||||
@@ -163,22 +158,21 @@ function setupEventListeners() {
|
||||
renderUsers(e.target.value);
|
||||
});
|
||||
|
||||
// Modal
|
||||
// Role change - toggle manager fields (manager group always visible since any user can have a manager)
|
||||
document.getElementById('editRole').addEventListener('change', (e) => {
|
||||
const role = e.target.value;
|
||||
document.getElementById('managerFields').style.display = role === 'manager' ? 'block' : 'none';
|
||||
// Manager group stays visible - any user (including admins) can have a manager assigned
|
||||
});
|
||||
|
||||
// Modal close
|
||||
document.getElementById('closeUserModal').addEventListener('click', () => {
|
||||
document.getElementById('userModal').style.display = 'none';
|
||||
});
|
||||
|
||||
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';
|
||||
});
|
||||
utils.setupModalClose('userModal');
|
||||
|
||||
// Form submit
|
||||
document.getElementById('userForm').addEventListener('submit', async (e) => {
|
||||
@@ -188,60 +182,35 @@ function setupEventListeners() {
|
||||
const role = document.getElementById('editRole').value;
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('editName').value,
|
||||
role: role,
|
||||
office_id: document.getElementById('editOffice').value || null
|
||||
manager_id: document.getElementById('editManager').value || null
|
||||
};
|
||||
|
||||
// Only include name if not disabled (LDAP users can't change name)
|
||||
const nameInput = document.getElementById('editName');
|
||||
if (!nameInput.disabled) {
|
||||
data.name = nameInput.value;
|
||||
}
|
||||
|
||||
// Manager-specific fields
|
||||
if (role === 'manager') {
|
||||
data.manager_parking_quota = parseInt(document.getElementById('editQuota').value) || 0;
|
||||
data.manager_spot_prefix = document.getElementById('editPrefix').value.toUpperCase() || null;
|
||||
data.manager_spot_prefix = document.getElementById('editPrefix').value || 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');
|
||||
await loadManagers(); // Reload in case role changed
|
||||
await loadUsers();
|
||||
} 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
|
||||
// Make functions available globally for onclick handlers
|
||||
window.editUser = editUser;
|
||||
window.deleteUser = deleteUser;
|
||||
|
||||
@@ -26,21 +26,42 @@ const api = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
* Check if user is authenticated (token or Authelia)
|
||||
*/
|
||||
isAuthenticated() {
|
||||
return !!this.getToken();
|
||||
return !!this.getToken() || this._autheliaAuth;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check authentication - works with both JWT and Authelia
|
||||
* Call this on page load to verify auth status
|
||||
*/
|
||||
async checkAuth() {
|
||||
// Try to get current user - works with Authelia headers or JWT
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: this.getToken() ? { 'Authorization': `Bearer ${this.getToken()}` } : {}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this._autheliaAuth = true;
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
this._autheliaAuth = false;
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Redirect to login if not authenticated
|
||||
* Returns user object if authenticated, null otherwise
|
||||
*/
|
||||
requireAuth() {
|
||||
if (!this.isAuthenticated()) {
|
||||
async requireAuth() {
|
||||
const user = await this.checkAuth();
|
||||
if (!user) {
|
||||
window.location.href = '/login';
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
return true;
|
||||
return user;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -143,11 +164,11 @@ const api = {
|
||||
/**
|
||||
* Register
|
||||
*/
|
||||
async register(email, password, name, officeId = null) {
|
||||
async register(email, password, name) {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, name, office_id: officeId })
|
||||
body: JSON.stringify({ email, password, name })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
|
||||
@@ -43,8 +43,8 @@ const ICONS = {
|
||||
|
||||
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: '/team-calendar', icon: 'users', label: 'Team Calendar' },
|
||||
{ href: '/team-rules', icon: 'rules', label: 'Team Rules', roles: ['admin', 'manager'] },
|
||||
{ href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] }
|
||||
];
|
||||
|
||||
@@ -77,26 +77,19 @@ async function initNav() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Get user info (works with both JWT and Authelia)
|
||||
const currentUser = await api.checkAuth();
|
||||
|
||||
// Render navigation
|
||||
navContainer.innerHTML = renderNav(currentPath, userRole);
|
||||
navContainer.innerHTML = renderNav(currentPath, currentUser?.role);
|
||||
|
||||
// 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 || '-';
|
||||
const userNameEl = document.getElementById('userName');
|
||||
const userRoleEl = document.getElementById('userRole');
|
||||
if (userNameEl) userNameEl.textContent = currentUser.name || 'User';
|
||||
if (userRoleEl) userRoleEl.textContent = currentUser.role || '-';
|
||||
}
|
||||
|
||||
// Setup user menu
|
||||
|
||||
@@ -10,9 +10,7 @@ let parkingData = {};
|
||||
let currentAssignmentId = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!api.requireAuth()) return;
|
||||
|
||||
currentUser = await api.getCurrentUser();
|
||||
currentUser = await api.requireAuth();
|
||||
if (!currentUser) return;
|
||||
|
||||
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
||||
|
||||
@@ -16,17 +16,9 @@ let selectedDate = null;
|
||||
let currentAssignmentId = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!api.requireAuth()) return;
|
||||
|
||||
currentUser = await api.getCurrentUser();
|
||||
currentUser = await api.requireAuth();
|
||||
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);
|
||||
@@ -38,7 +30,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
async function loadManagers() {
|
||||
const response = await api.get('/api/offices/managers/list');
|
||||
const response = await api.get('/api/managers');
|
||||
if (response && response.ok) {
|
||||
managers = await response.json();
|
||||
const select = document.getElementById('managerFilter');
|
||||
@@ -48,20 +40,32 @@ async function loadManagers() {
|
||||
if (currentUser.role === 'manager') {
|
||||
// Manager only sees themselves
|
||||
filteredManagers = managers.filter(m => m.id === currentUser.id);
|
||||
} else if (currentUser.role === 'employee') {
|
||||
// Employee only sees their own manager
|
||||
if (currentUser.manager_id) {
|
||||
filteredManagers = managers.filter(m => m.id === currentUser.manager_id);
|
||||
} else {
|
||||
filteredManagers = [];
|
||||
}
|
||||
}
|
||||
|
||||
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})`;
|
||||
const userCount = manager.managed_user_count || 0;
|
||||
option.textContent = `${manager.name} (${userCount} users)`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
// Auto-select if only one manager (for manager role)
|
||||
// Auto-select for managers and employees (they only see their team)
|
||||
if (filteredManagers.length === 1) {
|
||||
select.value = filteredManagers[0].id;
|
||||
}
|
||||
|
||||
// Hide manager filter for employees (they can only see their team)
|
||||
if (currentUser.role === 'employee') {
|
||||
select.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +133,7 @@ function renderCalendar() {
|
||||
|
||||
// Build header row
|
||||
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
let headerHtml = '<th>Name</th><th>Office</th>';
|
||||
let headerHtml = '<th>Name</th><th>Manager</th>';
|
||||
|
||||
for (let i = 0; i < dayCount; i++) {
|
||||
const date = new Date(startDate);
|
||||
@@ -161,7 +165,7 @@ function renderCalendar() {
|
||||
teamData.forEach(member => {
|
||||
bodyHtml += `<tr>
|
||||
<td class="member-name">${member.name || 'Unknown'}</td>
|
||||
<td class="member-office">${member.office_name || '-'}</td>`;
|
||||
<td class="member-manager">${member.manager_name || '-'}</td>`;
|
||||
|
||||
for (let i = 0; i < dayCount; i++) {
|
||||
const date = new Date(startDate);
|
||||
@@ -197,16 +201,18 @@ function renderCalendar() {
|
||||
});
|
||||
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);
|
||||
// Add click handlers to cells (only for admins and managers)
|
||||
if (currentUser.role === 'admin' || currentUser.role === 'manager') {
|
||||
body.querySelectorAll('.calendar-cell').forEach(cell => {
|
||||
cell.style.cursor = 'pointer';
|
||||
cell.addEventListener('click', () => {
|
||||
const userId = cell.dataset.userId;
|
||||
const date = cell.dataset.date;
|
||||
const userName = cell.dataset.userName;
|
||||
openDayModal(userId, date, userName);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openDayModal(userId, dateStr, userName) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Office Rules Page
|
||||
* Team Rules Page
|
||||
* Manage closing days, parking guarantees, and exclusions
|
||||
*
|
||||
* Rules are set at manager level and apply to all offices managed by that manager.
|
||||
* Rules are set at manager level for their parking pool.
|
||||
*/
|
||||
|
||||
let currentUser = null;
|
||||
@@ -10,9 +10,7 @@ let selectedManagerId = null;
|
||||
let managerUsers = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!api.requireAuth()) return;
|
||||
|
||||
currentUser = await api.getCurrentUser();
|
||||
currentUser = await api.requireAuth();
|
||||
if (!currentUser) return;
|
||||
|
||||
// Only managers and admins can access
|
||||
@@ -26,10 +24,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
async function loadManagers() {
|
||||
const response = await api.get('/api/offices/managers/list');
|
||||
const response = await api.get('/api/managers');
|
||||
if (response && response.ok) {
|
||||
const managers = await response.json();
|
||||
const select = document.getElementById('officeSelect');
|
||||
const select = document.getElementById('managerSelect');
|
||||
|
||||
// Filter to managers this user can see
|
||||
let filteredManagers = managers;
|
||||
@@ -45,14 +43,10 @@ async function loadManagers() {
|
||||
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)`;
|
||||
}
|
||||
// Show manager name with user count and parking quota
|
||||
const userCount = manager.managed_user_count || 0;
|
||||
const quota = manager.parking_quota || 0;
|
||||
option.textContent = `${manager.name} (${userCount} users, ${quota} spots)`;
|
||||
select.appendChild(option);
|
||||
totalManagers++;
|
||||
if (!firstManagerId) firstManagerId = manager.id;
|
||||
@@ -71,12 +65,12 @@ async function selectManager(managerId) {
|
||||
|
||||
if (!managerId) {
|
||||
document.getElementById('rulesContent').style.display = 'none';
|
||||
document.getElementById('noOfficeMessage').style.display = 'block';
|
||||
document.getElementById('noManagerMessage').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('rulesContent').style.display = 'block';
|
||||
document.getElementById('noOfficeMessage').style.display = 'none';
|
||||
document.getElementById('noManagerMessage').style.display = 'none';
|
||||
|
||||
await Promise.all([
|
||||
loadWeeklyClosingDays(),
|
||||
@@ -88,7 +82,7 @@ async function selectManager(managerId) {
|
||||
}
|
||||
|
||||
async function loadWeeklyClosingDays() {
|
||||
const response = await api.get(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`);
|
||||
const response = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`);
|
||||
if (response && response.ok) {
|
||||
const days = await response.json();
|
||||
const weekdays = days.map(d => d.weekday);
|
||||
@@ -102,7 +96,7 @@ async function loadWeeklyClosingDays() {
|
||||
}
|
||||
|
||||
async function loadManagerUsers() {
|
||||
const response = await api.get(`/api/offices/managers/${selectedManagerId}/users`);
|
||||
const response = await api.get(`/api/managers/${selectedManagerId}/users`);
|
||||
if (response && response.ok) {
|
||||
managerUsers = await response.json();
|
||||
updateUserSelects();
|
||||
@@ -123,7 +117,7 @@ function updateUserSelects() {
|
||||
}
|
||||
|
||||
async function loadClosingDays() {
|
||||
const response = await api.get(`/api/offices/managers/${selectedManagerId}/closing-days`);
|
||||
const response = await api.get(`/api/managers/${selectedManagerId}/closing-days`);
|
||||
const container = document.getElementById('closingDaysList');
|
||||
|
||||
if (response && response.ok) {
|
||||
@@ -158,7 +152,7 @@ function formatDateRange(startDate, endDate) {
|
||||
}
|
||||
|
||||
async function loadGuarantees() {
|
||||
const response = await api.get(`/api/offices/managers/${selectedManagerId}/guarantees`);
|
||||
const response = await api.get(`/api/managers/${selectedManagerId}/guarantees`);
|
||||
const container = document.getElementById('guaranteesList');
|
||||
|
||||
if (response && response.ok) {
|
||||
@@ -188,7 +182,7 @@ async function loadGuarantees() {
|
||||
}
|
||||
|
||||
async function loadExclusions() {
|
||||
const response = await api.get(`/api/offices/managers/${selectedManagerId}/exclusions`);
|
||||
const response = await api.get(`/api/managers/${selectedManagerId}/exclusions`);
|
||||
const container = document.getElementById('exclusionsList');
|
||||
|
||||
if (response && response.ok) {
|
||||
@@ -220,7 +214,7 @@ async function loadExclusions() {
|
||||
// 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}`);
|
||||
const response = await api.delete(`/api/managers/${selectedManagerId}/closing-days/${id}`);
|
||||
if (response && response.ok) {
|
||||
await loadClosingDays();
|
||||
}
|
||||
@@ -228,7 +222,7 @@ async function deleteClosingDay(id) {
|
||||
|
||||
async function deleteGuarantee(id) {
|
||||
if (!confirm('Remove this parking guarantee?')) return;
|
||||
const response = await api.delete(`/api/offices/managers/${selectedManagerId}/guarantees/${id}`);
|
||||
const response = await api.delete(`/api/managers/${selectedManagerId}/guarantees/${id}`);
|
||||
if (response && response.ok) {
|
||||
await loadGuarantees();
|
||||
}
|
||||
@@ -236,7 +230,7 @@ async function deleteGuarantee(id) {
|
||||
|
||||
async function deleteExclusion(id) {
|
||||
if (!confirm('Remove this parking exclusion?')) return;
|
||||
const response = await api.delete(`/api/offices/managers/${selectedManagerId}/exclusions/${id}`);
|
||||
const response = await api.delete(`/api/managers/${selectedManagerId}/exclusions/${id}`);
|
||||
if (response && response.ok) {
|
||||
await loadExclusions();
|
||||
}
|
||||
@@ -244,7 +238,7 @@ async function deleteExclusion(id) {
|
||||
|
||||
function setupEventListeners() {
|
||||
// Manager selection
|
||||
document.getElementById('officeSelect').addEventListener('change', (e) => {
|
||||
document.getElementById('managerSelect').addEventListener('change', (e) => {
|
||||
selectManager(e.target.value);
|
||||
});
|
||||
|
||||
@@ -255,7 +249,7 @@ function setupEventListeners() {
|
||||
|
||||
if (e.target.checked) {
|
||||
// Add weekly closing day
|
||||
const response = await api.post(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`, { weekday });
|
||||
const response = await api.post(`/api/managers/${selectedManagerId}/weekly-closing-days`, { weekday });
|
||||
if (!response || !response.ok) {
|
||||
e.target.checked = false;
|
||||
const error = await response.json();
|
||||
@@ -263,12 +257,12 @@ function setupEventListeners() {
|
||||
}
|
||||
} else {
|
||||
// Remove weekly closing day - need to find the ID first
|
||||
const getResponse = await api.get(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`);
|
||||
const getResponse = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`);
|
||||
if (getResponse && getResponse.ok) {
|
||||
const days = await getResponse.json();
|
||||
const day = days.find(d => d.weekday === weekday);
|
||||
if (day) {
|
||||
const deleteResponse = await api.delete(`/api/offices/managers/${selectedManagerId}/weekly-closing-days/${day.id}`);
|
||||
const deleteResponse = await api.delete(`/api/managers/${selectedManagerId}/weekly-closing-days/${day.id}`);
|
||||
if (!deleteResponse || !deleteResponse.ok) {
|
||||
e.target.checked = true;
|
||||
}
|
||||
@@ -320,7 +314,7 @@ function setupEventListeners() {
|
||||
date: document.getElementById('closingDate').value,
|
||||
reason: document.getElementById('closingReason').value || null
|
||||
};
|
||||
const response = await api.post(`/api/offices/managers/${selectedManagerId}/closing-days`, data);
|
||||
const response = await api.post(`/api/managers/${selectedManagerId}/closing-days`, data);
|
||||
if (response && response.ok) {
|
||||
document.getElementById('closingDayModal').style.display = 'none';
|
||||
await loadClosingDays();
|
||||
@@ -337,7 +331,7 @@ function setupEventListeners() {
|
||||
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);
|
||||
const response = await api.post(`/api/managers/${selectedManagerId}/guarantees`, data);
|
||||
if (response && response.ok) {
|
||||
document.getElementById('guaranteeModal').style.display = 'none';
|
||||
await loadGuarantees();
|
||||
@@ -354,7 +348,7 @@ function setupEventListeners() {
|
||||
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);
|
||||
const response = await api.post(`/api/managers/${selectedManagerId}/exclusions`, data);
|
||||
if (response && response.ok) {
|
||||
document.getElementById('exclusionModal').style.display = 'none';
|
||||
await loadExclusions();
|
||||
@@ -54,10 +54,7 @@
|
||||
<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>Manager</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -78,9 +75,16 @@
|
||||
<div class="modal-body">
|
||||
<form id="userForm">
|
||||
<input type="hidden" id="userId">
|
||||
|
||||
<!-- LDAP notice -->
|
||||
<div id="ldapNotice" class="form-notice" style="display: none;">
|
||||
<small>This user is managed by LDAP. Some fields cannot be edited.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editName">Name</label>
|
||||
<input type="text" id="editName" required>
|
||||
<small id="nameHelp" class="text-muted" style="display: none;">Managed by LDAP</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editEmail">Email</label>
|
||||
@@ -93,28 +97,32 @@
|
||||
<option value="manager">Manager</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<small id="roleHelp" class="text-muted" style="display: none;">Admin role is managed by LDAP group</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editOffice">Office</label>
|
||||
<select id="editOffice">
|
||||
<option value="">No office</option>
|
||||
<div class="form-group" id="managerGroup">
|
||||
<label for="editManager">Manager</label>
|
||||
<select id="editManager">
|
||||
<option value="">No manager</option>
|
||||
</select>
|
||||
<small class="text-muted">Who manages this user</small>
|
||||
</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>
|
||||
|
||||
<!-- Manager-specific fields -->
|
||||
<div id="managerFields" style="display: none;">
|
||||
<hr>
|
||||
<h4>Manager Settings</h4>
|
||||
<div class="form-group">
|
||||
<label for="editQuota">Parking Quota</label>
|
||||
<input type="number" id="editQuota" min="0" value="0">
|
||||
<small class="text-muted">Number of parking spots this manager controls</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editPrefix">Spot Prefix</label>
|
||||
<input type="text" id="editPrefix" maxlength="2" placeholder="A, B, C...">
|
||||
<small class="text-muted">Letter prefix for parking spots (e.g., A for spots A1, A2...)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancelUser">Cancel</button>
|
||||
<button type="submit" class="btn btn-dark">Save</button>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1>Parking Manager</h1>
|
||||
<p>Manage office presence and parking assignments</p>
|
||||
<p>Manage team presence and parking assignments</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
||||
@@ -23,10 +23,25 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Redirect if already logged in
|
||||
if (localStorage.getItem('access_token')) {
|
||||
window.location.href = '/presence';
|
||||
// Redirect if already logged in (JWT token or Authelia)
|
||||
async function checkAndRedirect() {
|
||||
// Check JWT token first
|
||||
if (localStorage.getItem('access_token')) {
|
||||
window.location.href = '/presence';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Authelia (backend will read headers)
|
||||
try {
|
||||
const response = await fetch('/api/auth/me');
|
||||
if (response.ok) {
|
||||
window.location.href = '/presence';
|
||||
}
|
||||
} catch (e) {
|
||||
// Not authenticated, stay on landing
|
||||
}
|
||||
}
|
||||
checkAndRedirect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -48,10 +48,16 @@
|
||||
<h3>Personal Information</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- LDAP Notice -->
|
||||
<div id="ldapNotice" class="form-notice" style="display: none; margin-bottom: 1rem;">
|
||||
<small>Your account is managed by LDAP. Some information cannot be changed here.</small>
|
||||
</div>
|
||||
|
||||
<form id="profileForm">
|
||||
<div class="form-group">
|
||||
<label for="name">Full Name</label>
|
||||
<input type="text" id="name" required>
|
||||
<small id="nameHelp" class="text-muted" style="display: none;">Managed by LDAP</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
@@ -59,19 +65,24 @@
|
||||
<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>
|
||||
<label for="role">Role</label>
|
||||
<input type="text" id="role" disabled>
|
||||
<small class="text-muted">Role is assigned by your administrator</small>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<div class="form-group">
|
||||
<label for="manager">Manager</label>
|
||||
<input type="text" id="manager" disabled>
|
||||
<small class="text-muted">Your manager is assigned by the administrator</small>
|
||||
</div>
|
||||
<div class="form-actions" id="profileActions">
|
||||
<button type="submit" class="btn btn-dark">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<!-- Password section - hidden for LDAP users -->
|
||||
<div class="card" id="passwordCard">
|
||||
<div class="card-header">
|
||||
<h3>Change Password</h3>
|
||||
</div>
|
||||
@@ -104,36 +115,37 @@
|
||||
<script src="/js/nav.js"></script>
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let isLdapUser = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!api.requireAuth()) return;
|
||||
|
||||
currentUser = await api.getCurrentUser();
|
||||
currentUser = await api.requireAuth();
|
||||
if (!currentUser) return;
|
||||
|
||||
await loadOffices();
|
||||
populateForm();
|
||||
await loadProfile();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
async function loadOffices() {
|
||||
const response = await api.get('/api/offices');
|
||||
async function loadProfile() {
|
||||
const response = await api.get('/api/users/me/profile');
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
const profile = await response.json();
|
||||
isLdapUser = profile.is_ldap_user;
|
||||
|
||||
function populateForm() {
|
||||
document.getElementById('name').value = currentUser.name || '';
|
||||
document.getElementById('email').value = currentUser.email;
|
||||
document.getElementById('office').value = currentUser.office_id || '';
|
||||
// Populate form
|
||||
document.getElementById('name').value = profile.name || '';
|
||||
document.getElementById('email').value = profile.email;
|
||||
document.getElementById('role').value = profile.role;
|
||||
document.getElementById('manager').value = profile.manager_name || 'None';
|
||||
|
||||
// LDAP mode adjustments
|
||||
if (isLdapUser) {
|
||||
document.getElementById('ldapNotice').style.display = 'block';
|
||||
document.getElementById('name').disabled = true;
|
||||
document.getElementById('nameHelp').style.display = 'block';
|
||||
document.getElementById('profileActions').style.display = 'none';
|
||||
document.getElementById('passwordCard').style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
@@ -141,15 +153,21 @@
|
||||
document.getElementById('profileForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isLdapUser) {
|
||||
utils.showMessage('Profile is managed by LDAP', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('name').value,
|
||||
office_id: document.getElementById('office').value || null
|
||||
name: document.getElementById('name').value
|
||||
};
|
||||
|
||||
const response = await api.put('/api/users/me/profile', data);
|
||||
if (response && response.ok) {
|
||||
utils.showMessage('Profile updated successfully', 'success');
|
||||
currentUser = await api.getCurrentUser();
|
||||
// Update nav display
|
||||
const nameEl = document.getElementById('userName');
|
||||
if (nameEl) nameEl.textContent = data.name;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
utils.showMessage(error.detail || 'Failed to update profile', 'error');
|
||||
|
||||
@@ -31,12 +31,6 @@
|
||||
<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>
|
||||
|
||||
@@ -53,39 +47,17 @@
|
||||
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);
|
||||
const result = await api.register(email, password, name);
|
||||
|
||||
if (result.success) {
|
||||
window.location.href = '/presence';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Office Rules - Parking Manager</title>
|
||||
<title>Team Rules - Parking Manager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
</head>
|
||||
@@ -39,9 +39,9 @@
|
||||
|
||||
<main class="main-content">
|
||||
<header class="page-header">
|
||||
<h2>Office Rules</h2>
|
||||
<h2>Team Rules</h2>
|
||||
<div class="header-actions">
|
||||
<select id="officeSelect" class="form-select">
|
||||
<select id="managerSelect" class="form-select">
|
||||
<option value="">Select Manager</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -54,7 +54,7 @@
|
||||
<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>
|
||||
<p class="text-muted" style="margin-bottom: 1rem;">Days of the week parking is unavailable</p>
|
||||
<div class="weekday-checkboxes" id="weeklyClosingDays">
|
||||
<label><input type="checkbox" data-weekday="0"> Sunday</label>
|
||||
<label><input type="checkbox" data-weekday="1"> Monday</label>
|
||||
@@ -74,7 +74,7 @@
|
||||
<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>
|
||||
<p class="text-muted">Specific dates when parking is unavailable (holidays, etc.)</p>
|
||||
<div id="closingDaysList" class="rules-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,10 +104,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper" id="noOfficeMessage">
|
||||
<div class="content-wrapper" id="noManagerMessage">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<p>Select a manager to manage their office rules</p>
|
||||
<p>Select a manager to manage their parking rules</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,6 +210,6 @@
|
||||
<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>
|
||||
<script src="/js/team-rules.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
main.py
10
main.py
@@ -11,7 +11,6 @@ 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
|
||||
@@ -39,7 +38,6 @@ app.add_middleware(
|
||||
# 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)
|
||||
@@ -86,10 +84,10 @@ async def 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("/team-rules")
|
||||
async def team_rules_page():
|
||||
"""Team Rules page"""
|
||||
return FileResponse(config.FRONTEND_DIR / "pages" / "team-rules.html")
|
||||
|
||||
|
||||
@app.get("/admin/users")
|
||||
|
||||
@@ -61,14 +61,14 @@ def authenticate_user(db: Session, email: str, password: str) -> User | None:
|
||||
return user
|
||||
|
||||
|
||||
def create_user(db: Session, email: str, password: str, name: str, office_id: str = None, role: str = "employee") -> User:
|
||||
def create_user(db: Session, email: str, password: str, name: str, manager_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,
|
||||
manager_id=manager_id,
|
||||
role=role,
|
||||
created_at=datetime.utcnow().isoformat(),
|
||||
updated_at=datetime.utcnow().isoformat()
|
||||
|
||||
@@ -21,7 +21,7 @@ import uuid
|
||||
|
||||
from database.models import (
|
||||
User, UserPresence, DailyParkingAssignment,
|
||||
NotificationLog, NotificationQueue, OfficeMembership
|
||||
NotificationLog, NotificationQueue
|
||||
)
|
||||
from services.parking import get_spot_display_name
|
||||
|
||||
|
||||
@@ -6,31 +6,19 @@ 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
|
||||
- Fairness: users with lowest parking_days/presence_days ratio get priority
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
from database.models import (
|
||||
DailyParkingAssignment, User, OfficeMembership, UserPresence,
|
||||
DailyParkingAssignment, User, 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:
|
||||
@@ -136,23 +124,16 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
|
||||
|
||||
def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
|
||||
"""
|
||||
Calculate user's parking ratio: parking_days / office_days
|
||||
Calculate user's parking ratio: parking_days / presence_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(
|
||||
# Count days user was present
|
||||
presence_days = db.query(UserPresence).filter(
|
||||
UserPresence.user_id == user_id,
|
||||
UserPresence.status == "present"
|
||||
).count()
|
||||
|
||||
if office_days == 0:
|
||||
if presence_days == 0:
|
||||
return 0.0 # New user, highest priority
|
||||
|
||||
# Count days user got parking
|
||||
@@ -161,7 +142,7 @@ def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
|
||||
DailyParkingAssignment.manager_id == manager_id
|
||||
).count()
|
||||
|
||||
return parking_days / office_days
|
||||
return parking_days / presence_days
|
||||
|
||||
|
||||
def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> bool:
|
||||
@@ -206,19 +187,16 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
|
||||
"""
|
||||
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
|
||||
Note: Manager is part of their own team and can get parking from their pool.
|
||||
"""
|
||||
# Get users who marked "present" for this date:
|
||||
# - Users managed by this manager (User.manager_id == manager_id)
|
||||
# - The manager themselves (User.id == manager_id)
|
||||
present_users = db.query(UserPresence).join(User).filter(
|
||||
UserPresence.date == date,
|
||||
UserPresence.status == "present",
|
||||
User.office_id.in_(managed_office_ids)
|
||||
or_(User.manager_id == manager_id, User.id == manager_id)
|
||||
).all()
|
||||
|
||||
candidates = []
|
||||
@@ -316,18 +294,19 @@ def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) ->
|
||||
return True
|
||||
|
||||
|
||||
def handle_presence_change(user_id: str, date: str, old_status: str, new_status: str, office_id: str, db: Session):
|
||||
def handle_presence_change(user_id: str, date: str, old_status: str, new_status: str, manager_id: str, db: Session):
|
||||
"""
|
||||
Handle presence status change and update parking accordingly.
|
||||
Uses fairness algorithm for assignment.
|
||||
manager_id is the user's manager (from User.manager_id).
|
||||
"""
|
||||
# Don't process past dates
|
||||
target_date = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
if target_date < datetime.now().date():
|
||||
return
|
||||
|
||||
# Find manager for this office
|
||||
manager = get_manager_for_office(office_id, db)
|
||||
# Get manager
|
||||
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
|
||||
if not manager or not manager.manager_parking_quota:
|
||||
return
|
||||
|
||||
|
||||
@@ -16,13 +16,9 @@ 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 is_admin_from_groups(groups: list[str]) -> bool:
|
||||
"""Check if user is admin based on Authelia groups"""
|
||||
return config.AUTHELIA_ADMIN_GROUP in groups
|
||||
|
||||
|
||||
def get_or_create_authelia_user(
|
||||
@@ -31,14 +27,28 @@ def get_or_create_authelia_user(
|
||||
groups: list[str],
|
||||
db: Session
|
||||
) -> User:
|
||||
"""Get existing user or create from Authelia headers"""
|
||||
"""
|
||||
Get existing user or create from Authelia headers.
|
||||
|
||||
Role logic:
|
||||
- If user is in parking_admins group -> admin role (always synced from LLDAP)
|
||||
- Otherwise -> keep existing role, or 'employee' for new users
|
||||
|
||||
Admin assigns manager role and user assignments via the UI.
|
||||
"""
|
||||
user = get_user_by_email(db, email)
|
||||
role = get_role_from_groups(groups)
|
||||
is_admin = is_admin_from_groups(groups)
|
||||
|
||||
if user:
|
||||
# Update role if changed in LLDAP
|
||||
if user.role != role:
|
||||
user.role = role
|
||||
# Only sync admin status from LLDAP, other roles managed by app admin
|
||||
if is_admin and user.role != "admin":
|
||||
user.role = "admin"
|
||||
user.updated_at = datetime.utcnow().isoformat()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
elif not is_admin and user.role == "admin":
|
||||
# Removed from parking_admins group -> demote to employee
|
||||
user.role = "employee"
|
||||
user.updated_at = datetime.utcnow().isoformat()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
@@ -55,7 +65,7 @@ def get_or_create_authelia_user(
|
||||
id=str(uuid.uuid4()),
|
||||
email=email,
|
||||
name=name or email.split("@")[0],
|
||||
role=role,
|
||||
role="admin" if is_admin else "employee",
|
||||
password_hash=None, # No password for Authelia users
|
||||
created_at=datetime.utcnow().isoformat(),
|
||||
updated_at=datetime.utcnow().isoformat()
|
||||
@@ -82,6 +92,8 @@ def get_current_user(
|
||||
remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "")
|
||||
remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "")
|
||||
|
||||
print(f"[Authelia] Headers: user={remote_user}, email={remote_email}, name={remote_name}, groups={remote_groups}")
|
||||
|
||||
if not remote_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -149,18 +161,17 @@ def require_manager_or_admin(user=Depends(get_current_user)):
|
||||
def check_manager_access_to_user(current_user, target_user, db: Session) -> bool:
|
||||
"""
|
||||
Check if current_user (manager) has access to target_user.
|
||||
Admins always have access. Managers can only access users in their managed offices.
|
||||
Admins always have access. Managers can only access users they manage.
|
||||
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:
|
||||
if target_user.manager_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User not in your managed offices"
|
||||
detail="User is not managed by you"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user