Compare commits
1 Commits
main
...
versione_2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f4ef6faee |
@@ -1,13 +0,0 @@
|
||||
parking.lvh.me {
|
||||
# Integrazione Authelia per autenticazione
|
||||
forward_auth authelia:9091 {
|
||||
uri /api/verify?rd=https://parking.lvh.me/
|
||||
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||
}
|
||||
|
||||
# Proxy inverso verso il container parking sulla porta 8000
|
||||
reverse_proxy parking:8000
|
||||
|
||||
# Usa certificati gestiti internamente per lvh.me (locale)
|
||||
tls internal
|
||||
}
|
||||
13
README.md
13
README.md
@@ -119,6 +119,10 @@ Gestione utenti e profili.
|
||||
- `GET /me/settings`: Ottieni le proprie impostazioni.
|
||||
- `PUT /me/settings`: Aggiorna le proprie impostazioni.
|
||||
- `POST /me/change-password`: Modifica la propria password.
|
||||
- `GET /me/exclusion`: Lista delle proprie auto-esclusioni.
|
||||
- `POST /me/exclusion`: Crea una nuova auto-esclusione.
|
||||
- `PUT /me/exclusion/{exclusion_id}`: Modifica una auto-esclusione.
|
||||
- `DELETE /me/exclusion/{exclusion_id}`: Elimina una auto-esclusione.
|
||||
|
||||
### Offices (`/api/offices`)
|
||||
Gestione uffici, regole di chiusura e quote.
|
||||
@@ -151,7 +155,8 @@ Gestione presenze giornaliere.
|
||||
- `POST /admin/mark`: Segna presenza per un altro utente (Manager/Admin).
|
||||
- `DELETE /admin/{user_id}/{date}`: Rimuovi presenza di un altro utente (Manager/Admin).
|
||||
- `GET /team`: Visualizza presenze e stato parcheggio del team.
|
||||
- `GET /admin/{user_id}`: Storico presenze di un utente.
|
||||
- `GET /admin/{user_id}`: Storico presenze di un utente (Manager/Admin).
|
||||
- `POST /admin/clear-office-presence`: Pulisce presenze e parcheggi di un ufficio per un range di date (Test/Admin).
|
||||
|
||||
### Parking (`/api/parking`)
|
||||
Gestione assegnazioni posti auto.
|
||||
@@ -165,6 +170,12 @@ Gestione assegnazioni posti auto.
|
||||
- `POST /reassign-spot`: Riassegna o libera un posto già assegnato.
|
||||
- `POST /release-my-spot/{id}`: Rilascia il proprio posto assegnato.
|
||||
- `GET /eligible-users/{id}`: Lista utenti idonei a ricevere un posto riassegnato.
|
||||
- `POST /test-email`: Invia email di test (Test Tool).
|
||||
|
||||
### Reports (`/api/reports`)
|
||||
Esportazione dati.
|
||||
|
||||
- `GET /team-export`: Esporta dati presenze e parcheggi del team in Excel.
|
||||
|
||||
## Utilizzo con AUTHELIA
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins")
|
||||
# External URLs for Authelia mode
|
||||
# When AUTHELIA_ENABLED, login redirects to Authelia and register to external portal
|
||||
AUTHELIA_LOGIN_URL = os.getenv("AUTHELIA_LOGIN_URL", "") # e.g., https://auth.rocketscale.it
|
||||
AUTHELIA_LOGOUT_URL = os.getenv("AUTHELIA_LOGOUT_URL", "") # e.g., https://auth.rocketscale.it/logout
|
||||
REGISTRATION_URL = os.getenv("REGISTRATION_URL", "") # e.g., https://register.rocketscale.it
|
||||
|
||||
# Email configuration (following org-stack pattern)
|
||||
|
||||
@@ -145,6 +145,7 @@ def get_auth_config():
|
||||
return {
|
||||
"authelia_enabled": config.AUTHELIA_ENABLED,
|
||||
"login_url": config.AUTHELIA_LOGIN_URL if config.AUTHELIA_ENABLED else None,
|
||||
"logout_url": config.AUTHELIA_LOGOUT_URL if config.AUTHELIA_ENABLED else None,
|
||||
"registration_url": config.REGISTRATION_URL if config.AUTHELIA_ENABLED else None
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,11 @@ def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=D
|
||||
)
|
||||
db.add(office)
|
||||
db.commit()
|
||||
|
||||
# Sync spots
|
||||
from services.offices import sync_office_spots
|
||||
sync_office_spots(office.id, office.parking_quota, office.spot_prefix, db)
|
||||
|
||||
return office
|
||||
|
||||
@router.get("/{office_id}")
|
||||
@@ -186,6 +191,10 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi
|
||||
office.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
# Sync spots
|
||||
from services.offices import sync_office_spots
|
||||
sync_office_spots(office.id, office.parking_quota, office.spot_prefix, db)
|
||||
|
||||
return {
|
||||
"id": office.id,
|
||||
"name": office.name,
|
||||
@@ -459,16 +468,20 @@ def add_office_exclusion(office_id: str, data: ExclusionCreate, db: Session = De
|
||||
if not db.query(User).filter(User.id == data.user_id).first():
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
existing = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.office_id == office_id,
|
||||
ParkingExclusion.user_id == data.user_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
|
||||
# Relaxed unique check - user can have multiple exclusions (different periods)
|
||||
# existing = db.query(ParkingExclusion).filter(
|
||||
# ParkingExclusion.office_id == office_id,
|
||||
# ParkingExclusion.user_id == data.user_id
|
||||
# ).first()
|
||||
# if existing:
|
||||
# raise HTTPException(status_code=400, detail="User already has a parking exclusion")
|
||||
|
||||
if data.start_date and data.end_date and data.end_date < data.start_date:
|
||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||
|
||||
if data.end_date and not data.start_date:
|
||||
raise HTTPException(status_code=400, detail="Start date is required if an end date is specified")
|
||||
|
||||
exclusion = ParkingExclusion(
|
||||
id=generate_uuid(),
|
||||
office_id=office_id,
|
||||
|
||||
@@ -23,7 +23,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import DailyParkingAssignment, User, UserRole, Office
|
||||
from database.models import DailyParkingAssignment, User, UserRole, Office, OfficeSpot
|
||||
from utils.auth_middleware import get_current_user, require_manager_or_admin
|
||||
from services.parking import (
|
||||
initialize_parking_pool, get_spot_display_name, release_user_spot,
|
||||
@@ -91,35 +91,70 @@ def init_office_pool(request: InitPoolRequest, db: Session = Depends(get_db), cu
|
||||
|
||||
|
||||
@router.get("/assignments/{date_val}", response_model=List[AssignmentResponse])
|
||||
def get_assignments(date_val: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get parking assignments for a date, optionally filtered by office"""
|
||||
def get_assignments(date_val: date, office_id: str | None = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get parking assignments for a date, merging active assignments with empty spots"""
|
||||
query_date = date_val
|
||||
|
||||
# Defaults to user's office if not specified
|
||||
target_office_id = office_id or current_user.office_id
|
||||
|
||||
if not target_office_id:
|
||||
# Admin looking at all? Or error?
|
||||
# If no office_id, we might fetch all spots from all offices?
|
||||
# Let's support specific office filtering primarily as per UI use case
|
||||
# If office_id is None, we proceed with caution (maybe all offices)
|
||||
pass
|
||||
|
||||
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date)
|
||||
if office_id:
|
||||
query = query.filter(DailyParkingAssignment.office_id == office_id)
|
||||
# 1. Get ALL spots for the target office(s)
|
||||
# Note: Sorting by spot_number for consistent display order
|
||||
spot_query = db.query(OfficeSpot).filter(OfficeSpot.is_unavailable == False)
|
||||
if target_office_id:
|
||||
spot_query = spot_query.filter(OfficeSpot.office_id == target_office_id)
|
||||
spots = spot_query.order_by(OfficeSpot.spot_number).all()
|
||||
|
||||
assignments = query.all()
|
||||
# 2. Get EXISTING assignments
|
||||
assign_query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date)
|
||||
if target_office_id:
|
||||
assign_query = assign_query.filter(DailyParkingAssignment.office_id == target_office_id)
|
||||
active_assignments = assign_query.all()
|
||||
|
||||
# Map assignment by spot_id for O(1) lookup
|
||||
assignment_map = {a.spot_id: a for a in active_assignments}
|
||||
|
||||
results = []
|
||||
|
||||
for assignment in assignments:
|
||||
# Get display name using office's spot prefix
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||
|
||||
result = AssignmentResponse(
|
||||
id=assignment.id,
|
||||
date=assignment.date,
|
||||
spot_id=assignment.spot_id,
|
||||
spot_display_name=spot_display_name,
|
||||
user_id=assignment.user_id,
|
||||
office_id=assignment.office_id
|
||||
)
|
||||
|
||||
if assignment.user_id:
|
||||
user = db.query(User).filter(User.id == assignment.user_id).first()
|
||||
if user:
|
||||
result.user_name = user.name
|
||||
result.user_email = user.email
|
||||
|
||||
# 3. Merge
|
||||
for spot in spots:
|
||||
assignment = assignment_map.get(spot.id)
|
||||
|
||||
if assignment:
|
||||
# Active assignment
|
||||
result = AssignmentResponse(
|
||||
id=assignment.id,
|
||||
date=assignment.date,
|
||||
spot_id=spot.id, # The FK
|
||||
spot_display_name=spot.name,
|
||||
user_id=assignment.user_id,
|
||||
office_id=spot.office_id
|
||||
)
|
||||
|
||||
if assignment.user_id:
|
||||
user = db.query(User).filter(User.id == assignment.user_id).first()
|
||||
if user:
|
||||
result.user_name = user.name
|
||||
result.user_email = user.email
|
||||
else:
|
||||
# Empty spot (Virtual assignment response)
|
||||
# We use "virtual" ID or just None? Schema says ID is str.
|
||||
# Frontend might need an ID for keys. Let's use "virtual-{spot.id}"
|
||||
result = AssignmentResponse(
|
||||
id=f"virtual-{spot.id}",
|
||||
date=query_date,
|
||||
spot_id=spot.id,
|
||||
spot_display_name=spot.name,
|
||||
user_id=None,
|
||||
office_id=spot.office_id
|
||||
)
|
||||
|
||||
results.append(result)
|
||||
|
||||
@@ -158,9 +193,6 @@ def get_my_assignments(start_date: date = None, end_date: date = None, db: Sessi
|
||||
return results
|
||||
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/run-allocation")
|
||||
def run_fair_allocation(data: RunAllocationRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Manually trigger fair allocation for a date (Test Tool)"""
|
||||
@@ -203,32 +235,43 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr
|
||||
if current_user.role != UserRole.ADMIN and not is_manager:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this office")
|
||||
|
||||
# Check if spot exists and is free
|
||||
spot = db.query(DailyParkingAssignment).filter(
|
||||
# Check if spot exists (OfficeSpot)
|
||||
spot_def = db.query(OfficeSpot).filter(OfficeSpot.id == data.spot_id).first()
|
||||
if not spot_def:
|
||||
raise HTTPException(status_code=404, detail="Spot definition not found")
|
||||
|
||||
# Check if spot is already assigned
|
||||
existing_assignment = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.office_id == data.office_id,
|
||||
DailyParkingAssignment.date == assign_date,
|
||||
DailyParkingAssignment.spot_id == data.spot_id
|
||||
).first()
|
||||
|
||||
if not spot:
|
||||
raise HTTPException(status_code=404, detail="Spot not found")
|
||||
if spot.user_id:
|
||||
if existing_assignment:
|
||||
raise HTTPException(status_code=400, detail="Spot already assigned")
|
||||
|
||||
# Check if user already has a spot for this date (from any manager)
|
||||
existing = db.query(DailyParkingAssignment).filter(
|
||||
user_has_spot = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.date == assign_date,
|
||||
DailyParkingAssignment.user_id == data.user_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
if user_has_spot:
|
||||
raise HTTPException(status_code=400, detail="User already has a spot for this date")
|
||||
|
||||
spot.user_id = data.user_id
|
||||
# Create Assignment
|
||||
new_assignment = DailyParkingAssignment(
|
||||
id=generate_uuid(),
|
||||
date=assign_date,
|
||||
spot_id=data.spot_id,
|
||||
user_id=data.user_id,
|
||||
office_id=data.office_id,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(new_assignment)
|
||||
db.commit()
|
||||
|
||||
spot_display_name = get_spot_display_name(data.spot_id, data.office_id, db)
|
||||
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name}
|
||||
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_def.name}
|
||||
|
||||
|
||||
@router.post("/release-my-spot/{assignment_id}")
|
||||
@@ -245,15 +288,16 @@ def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_u
|
||||
raise HTTPException(status_code=403, detail="You can only release your own parking spot")
|
||||
|
||||
# Get spot display name for notification
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||
spot_name = assignment.spot.name if assignment.spot else "Unknown"
|
||||
|
||||
assignment.user_id = None
|
||||
# Delete assignment (Release)
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
|
||||
# Send notification (self-release, so just confirmation)
|
||||
notify_parking_released(current_user, assignment.date, spot_display_name)
|
||||
notify_parking_released(current_user, assignment.date, spot_name)
|
||||
|
||||
config.logger.info(f"User {current_user.email} released parking spot {spot_display_name} on {assignment.date}")
|
||||
config.logger.info(f"User {current_user.email} released parking spot {spot_name} on {assignment.date}")
|
||||
return {"message": "Parking spot released"}
|
||||
|
||||
|
||||
@@ -282,7 +326,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
||||
old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None
|
||||
|
||||
# Get spot display name for notifications
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||
spot_name = assignment.spot.name if assignment.spot else "Unknown"
|
||||
|
||||
if data.new_user_id == "auto":
|
||||
# "Auto assign" means releasing the spot so the system picks the next person
|
||||
@@ -308,46 +352,49 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already has a spot for this date")
|
||||
|
||||
# Update assignment to new user
|
||||
assignment.user_id = data.new_user_id
|
||||
|
||||
# Send notifications
|
||||
# Notify old user that spot was reassigned
|
||||
if old_user and old_user.id != new_user.id:
|
||||
notify_parking_reassigned(old_user, assignment.date, spot_display_name, new_user.name)
|
||||
notify_parking_reassigned(old_user, assignment.date, spot_name, new_user.name)
|
||||
# Notify new user that spot was assigned
|
||||
notify_parking_assigned(new_user, assignment.date, spot_display_name)
|
||||
notify_parking_assigned(new_user, assignment.date, spot_name)
|
||||
|
||||
config.logger.info(f"Parking spot {spot_display_name} on {assignment.date} reassigned from {old_user.email if old_user else 'unassigned'} to {new_user.email}")
|
||||
config.logger.info(f"Parking spot {spot_name} on {assignment.date} reassigned from {old_user.email if old_user else 'unassigned'} to {new_user.email}")
|
||||
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
|
||||
result = AssignmentResponse(
|
||||
id=assignment.id,
|
||||
date=assignment.date,
|
||||
spot_id=assignment.spot_id,
|
||||
spot_display_name=spot_name,
|
||||
user_id=assignment.user_id,
|
||||
office_id=assignment.office_id
|
||||
)
|
||||
|
||||
if assignment.user_id:
|
||||
result.user_name = new_user.name
|
||||
result.user_email = new_user.email
|
||||
|
||||
return result
|
||||
|
||||
else:
|
||||
assignment.user_id = None
|
||||
# Release (Delete assignment)
|
||||
db.delete(assignment)
|
||||
|
||||
# Notify old user that spot was released
|
||||
if old_user:
|
||||
notify_parking_released(old_user, assignment.date, spot_display_name)
|
||||
notify_parking_released(old_user, assignment.date, spot_name)
|
||||
|
||||
config.logger.info(f"Parking spot {spot_display_name} on {assignment.date} released by {old_user.email if old_user else 'unknown'}")
|
||||
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
|
||||
# Build response
|
||||
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||
|
||||
result = AssignmentResponse(
|
||||
id=assignment.id,
|
||||
date=assignment.date,
|
||||
spot_id=assignment.spot_id,
|
||||
spot_display_name=spot_display_name,
|
||||
user_id=assignment.user_id,
|
||||
office_id=assignment.office_id
|
||||
)
|
||||
|
||||
if assignment.user_id:
|
||||
user = db.query(User).filter(User.id == assignment.user_id).first()
|
||||
if user:
|
||||
result.user_name = user.name
|
||||
result.user_email = user.email
|
||||
|
||||
return result
|
||||
config.logger.info(f"Parking spot {spot_name} on {assignment.date} released by {old_user.email if old_user else 'unknown'}")
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": "Spot released"}
|
||||
|
||||
|
||||
@router.get("/eligible-users/{assignment_id}")
|
||||
@@ -394,3 +441,91 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class TestEmailRequest(BaseModel):
|
||||
date: date = None
|
||||
office_id: str
|
||||
|
||||
|
||||
@router.post("/test-email")
|
||||
def send_test_email_tool(data: TestEmailRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Send a test email to the current user (Test Tool)"""
|
||||
from services.notifications import send_email
|
||||
from database.models import OfficeClosingDay, OfficeWeeklyClosingDay
|
||||
from datetime import timedelta
|
||||
|
||||
# Verify office access
|
||||
if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized for this office")
|
||||
|
||||
target_date = data.date
|
||||
if not target_date:
|
||||
# Find next open day
|
||||
# Start from tomorrow (or today? Prompt says "dopo il giorno corrente" -> after today)
|
||||
check_date = date.today() + timedelta(days=1)
|
||||
|
||||
# Load closing rules
|
||||
weekly_closed = db.query(OfficeWeeklyClosingDay.weekday).filter(
|
||||
OfficeWeeklyClosingDay.office_id == data.office_id
|
||||
).all()
|
||||
weekly_closed_set = {w[0] for w in weekly_closed}
|
||||
|
||||
specific_closed = db.query(OfficeClosingDay).filter(
|
||||
OfficeClosingDay.office_id == data.office_id,
|
||||
OfficeClosingDay.date >= check_date
|
||||
).all()
|
||||
|
||||
# Max lookahead 30 days to avoid infinite loop
|
||||
found = False
|
||||
for _ in range(30):
|
||||
# Check weekly
|
||||
if check_date.weekday() in weekly_closed_set:
|
||||
check_date += timedelta(days=1)
|
||||
continue
|
||||
|
||||
# Check specific
|
||||
is_specific = False
|
||||
for d in specific_closed:
|
||||
s = d.date
|
||||
e = d.end_date or d.date
|
||||
if s <= check_date <= e:
|
||||
is_specific = True
|
||||
break
|
||||
|
||||
if is_specific:
|
||||
check_date += timedelta(days=1)
|
||||
continue
|
||||
|
||||
found = True
|
||||
break
|
||||
|
||||
if found:
|
||||
target_date = check_date
|
||||
else:
|
||||
# Fallback
|
||||
target_date = date.today() + timedelta(days=1)
|
||||
|
||||
# Send Email
|
||||
subject = f"Test Email - Parking System ({target_date})"
|
||||
body_html = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Parking System Test Email</h2>
|
||||
<p>Hi {current_user.name},</p>
|
||||
<p>This is a test email triggered from the Parking Manager Test Tools.</p>
|
||||
<p><strong>Selected Date:</strong> {target_date}</p>
|
||||
<p><strong>SMTP Status:</strong> {'Enabled' if config.SMTP_ENABLED else 'Disabled (File Logging)'}</p>
|
||||
<p>If you received this, the notification system is working.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
success = send_email(current_user.email, subject, body_html)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"mode": "SMTP" if config.SMTP_ENABLED else "FILE",
|
||||
"date": target_date,
|
||||
"message": "Email sent successfully" if success else "Failed to send email"
|
||||
}
|
||||
|
||||
@@ -329,3 +329,49 @@ def get_user_presences(user_id: str, start_date: date = None, end_date: date = N
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ClearOfficePresenceRequest(BaseModel):
|
||||
start_date: date
|
||||
end_date: date
|
||||
office_id: str
|
||||
|
||||
|
||||
@router.post("/admin/clear-office-presence")
|
||||
def clear_office_presence(data: ClearOfficePresenceRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
|
||||
"""Clear all presence and parking for an office in a date range (Test Tool)"""
|
||||
|
||||
# Verify office access
|
||||
if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized for this office")
|
||||
|
||||
# Get all users in the office
|
||||
users = db.query(User).filter(User.office_id == data.office_id).all()
|
||||
user_ids = [u.id for u in users]
|
||||
|
||||
if not user_ids:
|
||||
return {"message": "No users in office", "count_presence": 0, "count_parking": 0}
|
||||
|
||||
# 1. Delete Parking Assignments
|
||||
parking_delete = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.user_id.in_(user_ids),
|
||||
DailyParkingAssignment.date >= data.start_date,
|
||||
DailyParkingAssignment.date <= data.end_date
|
||||
)
|
||||
parking_count = parking_delete.delete(synchronize_session=False)
|
||||
|
||||
# 2. Delete Presence
|
||||
presence_delete = db.query(UserPresence).filter(
|
||||
UserPresence.user_id.in_(user_ids),
|
||||
UserPresence.date >= data.start_date,
|
||||
UserPresence.date <= data.end_date
|
||||
)
|
||||
presence_count = presence_delete.delete(synchronize_session=False)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Cleared office presence and parking",
|
||||
"count_presence": presence_count,
|
||||
"count_parking": parking_count
|
||||
}
|
||||
|
||||
195
app/routes/reports.py
Normal file
195
app/routes/reports.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from io import BytesIO
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import User, UserPresence, DailyParkingAssignment, UserRole, Office
|
||||
from utils.auth_middleware import require_manager_or_admin
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@router.get("/team-export")
|
||||
def export_team_data(
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
office_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_manager_or_admin)
|
||||
):
|
||||
"""
|
||||
Export team presence and parking data to Excel.
|
||||
"""
|
||||
# 1. Determine Scope (Admin vs Manager)
|
||||
target_office_id = office_id
|
||||
if current_user.role == UserRole.MANAGER:
|
||||
# Manager is restricted to their own office
|
||||
if office_id and office_id != current_user.office_id:
|
||||
raise HTTPException(status_code=403, detail="Cannot export data for other offices")
|
||||
target_office_id = current_user.office_id
|
||||
|
||||
# 2. Fetch Users
|
||||
query = db.query(User)
|
||||
if target_office_id:
|
||||
query = query.filter(User.office_id == target_office_id)
|
||||
users = query.all()
|
||||
user_ids = [u.id for u in users]
|
||||
|
||||
# Map users for quick lookup
|
||||
user_map = {u.id: u for u in users}
|
||||
|
||||
# 3. Fetch Presences
|
||||
presences = db.query(UserPresence).filter(
|
||||
UserPresence.user_id.in_(user_ids),
|
||||
UserPresence.date >= start_date,
|
||||
UserPresence.date <= end_date
|
||||
).all()
|
||||
|
||||
# 4. Fetch Parking Assignments
|
||||
assignments = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.user_id.in_(user_ids),
|
||||
DailyParkingAssignment.date >= start_date,
|
||||
DailyParkingAssignment.date <= end_date
|
||||
).all()
|
||||
|
||||
# Organize data by Date -> User -> Info
|
||||
# Structure: data_map[date_str][user_id] = { presence: ..., parking: ... }
|
||||
data_map = {}
|
||||
|
||||
for p in presences:
|
||||
d_str = p.date.isoformat()
|
||||
if d_str not in data_map: data_map[d_str] = {}
|
||||
if p.user_id not in data_map[d_str]: data_map[d_str][p.user_id] = {}
|
||||
data_map[d_str][p.user_id]['presence'] = p.status.value # 'present', 'remote', etc.
|
||||
|
||||
for a in assignments:
|
||||
d_str = a.date.isoformat()
|
||||
if d_str not in data_map: data_map[d_str] = {}
|
||||
if a.user_id not in data_map[d_str]: data_map[d_str][a.user_id] = {}
|
||||
if a.spot:
|
||||
data_map[d_str][a.user_id]['parking'] = a.spot.name
|
||||
else:
|
||||
data_map[d_str][a.user_id]['parking'] = "Unknown"
|
||||
|
||||
# 5. Generate Excel
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Report Presenze Matrix"
|
||||
|
||||
# --- Header Row (Dates) ---
|
||||
# Column A: "Utente"
|
||||
ws.cell(row=1, column=1, value="Utente")
|
||||
|
||||
# Generate date range
|
||||
from datetime import timedelta
|
||||
date_cols = {} # date_str -> col_index
|
||||
col_idx = 2
|
||||
|
||||
curr = start_date
|
||||
while curr <= end_date:
|
||||
d_str = curr.isoformat()
|
||||
# Header: DD/MM
|
||||
header_val = f"{curr.day}/{curr.month}"
|
||||
|
||||
cell = ws.cell(row=1, column=col_idx, value=header_val)
|
||||
date_cols[d_str] = col_idx
|
||||
|
||||
# Style Header
|
||||
cell.font = Font(bold=True)
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
|
||||
col_idx += 1
|
||||
curr += timedelta(days=1)
|
||||
|
||||
# Style First Header (Utente)
|
||||
first_header = ws.cell(row=1, column=1)
|
||||
first_header.font = Font(bold=True)
|
||||
first_header.alignment = Alignment(horizontal="left")
|
||||
|
||||
# Define Fills
|
||||
fill_present = PatternFill(start_color="dcfce7", end_color="dcfce7", fill_type="solid") # Light Green
|
||||
fill_remote = PatternFill(start_color="dbeafe", end_color="dbeafe", fill_type="solid") # Light Blue
|
||||
fill_absent = PatternFill(start_color="fee2e2", end_color="fee2e2", fill_type="solid") # Light Red
|
||||
fill_trip = PatternFill(start_color="fef3c7", end_color="fef3c7", fill_type="solid") # Light Orange (matching frontend warning-bg)
|
||||
|
||||
# --- User Rows ---
|
||||
row_idx = 2
|
||||
for user in users:
|
||||
# User Name in Col 1
|
||||
name_cell = ws.cell(row=row_idx, column=1, value=user.name)
|
||||
name_cell.font = Font(bold=True)
|
||||
|
||||
# Determine Office label (optional append?)
|
||||
# name_cell.value = f"{user.name} ({user.office.name})" if user.office else user.name
|
||||
|
||||
# Fill Dates
|
||||
curr = start_date
|
||||
while curr <= end_date:
|
||||
d_str = curr.isoformat()
|
||||
if d_str in date_cols:
|
||||
c_idx = date_cols[d_str]
|
||||
|
||||
# Get Data
|
||||
u_data = data_map.get(d_str, {}).get(user.id, {})
|
||||
presence = u_data.get('presence', '')
|
||||
parking = u_data.get('parking', '')
|
||||
|
||||
cell_val = ""
|
||||
fill = None
|
||||
|
||||
if presence == 'present':
|
||||
cell_val = "In Sede"
|
||||
fill = fill_present
|
||||
elif presence == 'remote':
|
||||
cell_val = "Remoto"
|
||||
fill = fill_remote
|
||||
elif presence == 'absent':
|
||||
cell_val = "Ferie"
|
||||
fill = fill_absent
|
||||
elif presence == 'business_trip':
|
||||
cell_val = "Trasferta"
|
||||
fill = fill_trip
|
||||
|
||||
# Append Parking info if present
|
||||
if parking:
|
||||
if cell_val:
|
||||
cell_val += f" ({parking})"
|
||||
else:
|
||||
cell_val = f"({parking})" # Parking without presence? Unusual but possible
|
||||
|
||||
cell = ws.cell(row=row_idx, column=c_idx, value=cell_val)
|
||||
if fill:
|
||||
cell.fill = fill
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
|
||||
curr += timedelta(days=1)
|
||||
|
||||
row_idx += 1
|
||||
|
||||
# Adjust column widths
|
||||
ws.column_dimensions['A'].width = 25 # User name column
|
||||
|
||||
# Auto-width for date columns (approx)
|
||||
for i in range(2, col_idx):
|
||||
col_letter = ws.cell(row=1, column=i).column_letter
|
||||
ws.column_dimensions[col_letter].width = 12
|
||||
|
||||
# Save to buffer
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
filename = f"report_parking_matrix_{start_date}_{end_date}.xlsx"
|
||||
|
||||
headers = {
|
||||
'Content-Disposition': f'attachment; filename="{filename}"'
|
||||
}
|
||||
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers=headers
|
||||
)
|
||||
@@ -2,13 +2,13 @@
|
||||
User Management Routes
|
||||
Admin user CRUD and user self-service (profile, settings, password)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database.connection import get_db
|
||||
from database.models import User, UserRole, Office
|
||||
from database.models import User, UserRole, Office, ParkingExclusion
|
||||
from utils.auth_middleware import get_current_user, require_admin
|
||||
from utils.helpers import (
|
||||
generate_uuid, is_ldap_user, is_ldap_admin,
|
||||
@@ -54,6 +54,20 @@ class ChangePasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class UserExclusionCreate(BaseModel):
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class UserExclusionResponse(BaseModel):
|
||||
id: str
|
||||
start_date: date | None
|
||||
end_date: date | None
|
||||
notes: str | None
|
||||
is_excluded: bool = True
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
@@ -332,3 +346,105 @@ def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db),
|
||||
db.commit()
|
||||
config.logger.info(f"User {current_user.email} changed password")
|
||||
return {"message": "Password changed"}
|
||||
|
||||
|
||||
# Exclusion Management (Self-Service)
|
||||
# Exclusion Management (Self-Service)
|
||||
@router.get("/me/exclusion", response_model=list[UserExclusionResponse])
|
||||
def get_my_exclusions(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Get current user's parking exclusions"""
|
||||
if not current_user.office_id:
|
||||
return []
|
||||
|
||||
exclusions = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.user_id == current_user.id,
|
||||
ParkingExclusion.office_id == current_user.office_id
|
||||
).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": e.id,
|
||||
"start_date": e.start_date,
|
||||
"end_date": e.end_date,
|
||||
"notes": e.notes,
|
||||
"is_excluded": True
|
||||
}
|
||||
for e in exclusions
|
||||
]
|
||||
|
||||
|
||||
@router.post("/me/exclusion")
|
||||
def create_my_exclusion(data: UserExclusionCreate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Create new parking exclusion for current user"""
|
||||
if not current_user.office_id:
|
||||
raise HTTPException(status_code=400, detail="User is not assigned to an office")
|
||||
|
||||
if data.start_date and data.end_date and data.end_date < data.start_date:
|
||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||
|
||||
if data.end_date and not data.start_date:
|
||||
raise HTTPException(status_code=400, detail="Start date is required if an end date is specified")
|
||||
|
||||
exclusion = ParkingExclusion(
|
||||
id=generate_uuid(),
|
||||
office_id=current_user.office_id,
|
||||
user_id=current_user.id,
|
||||
start_date=data.start_date,
|
||||
end_date=data.end_date,
|
||||
notes=data.notes or "Auto-esclusione utente",
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(exclusion)
|
||||
db.commit()
|
||||
return {"message": "Exclusion created", "id": exclusion.id}
|
||||
|
||||
|
||||
@router.put("/me/exclusion/{exclusion_id}")
|
||||
def update_my_exclusion(exclusion_id: str, data: UserExclusionCreate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Update specific parking exclusion for current user"""
|
||||
if not current_user.office_id:
|
||||
raise HTTPException(status_code=400, detail="User is not assigned to an office")
|
||||
|
||||
exclusion = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.id == exclusion_id,
|
||||
ParkingExclusion.user_id == current_user.id,
|
||||
ParkingExclusion.office_id == current_user.office_id
|
||||
).first()
|
||||
|
||||
if not exclusion:
|
||||
raise HTTPException(status_code=404, detail="Exclusion not found")
|
||||
|
||||
if data.start_date and data.end_date and data.end_date < data.start_date:
|
||||
raise HTTPException(status_code=400, detail="End date must be after start date")
|
||||
|
||||
if data.end_date and not data.start_date:
|
||||
raise HTTPException(status_code=400, detail="Start date is required if an end date is specified")
|
||||
|
||||
exclusion.start_date = data.start_date
|
||||
exclusion.end_date = data.end_date
|
||||
if data.notes is not None:
|
||||
exclusion.notes = data.notes
|
||||
|
||||
exclusion.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return {"message": "Exclusion updated", "id": exclusion.id}
|
||||
|
||||
|
||||
@router.delete("/me/exclusion/{exclusion_id}")
|
||||
def delete_my_exclusion(exclusion_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
"""Remove specific parking exclusion for current user"""
|
||||
if not current_user.office_id:
|
||||
raise HTTPException(status_code=400, detail="User is not assigned to an office")
|
||||
|
||||
exclusion = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.id == exclusion_id,
|
||||
ParkingExclusion.user_id == current_user.id,
|
||||
ParkingExclusion.office_id == current_user.office_id
|
||||
).first()
|
||||
|
||||
if not exclusion:
|
||||
raise HTTPException(status_code=404, detail="Exclusion not found")
|
||||
|
||||
db.delete(exclusion)
|
||||
db.commit()
|
||||
return {"message": "Exclusion removed"}
|
||||
|
||||
@@ -5,6 +5,12 @@ services:
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./app:/app/app
|
||||
- ./database:/app/database
|
||||
- ./services:/app/services
|
||||
- ./utils:/app/utils
|
||||
- ./frontend:/app/frontend
|
||||
- ./main.py:/app/main.py
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -29,5 +35,4 @@ services:
|
||||
|
||||
networks:
|
||||
org-network:
|
||||
external: true
|
||||
name: org-stack_org-network
|
||||
external: true
|
||||
@@ -20,6 +20,8 @@ class PresenceStatus(str, enum.Enum):
|
||||
PRESENT = "present"
|
||||
REMOTE = "remote"
|
||||
ABSENT = "absent"
|
||||
BUSINESS_TRIP = "business_trip"
|
||||
|
||||
|
||||
|
||||
class NotificationType(str, enum.Enum):
|
||||
@@ -67,6 +69,7 @@ class Office(Base):
|
||||
users = relationship("User", back_populates="office")
|
||||
closing_days = relationship("OfficeClosingDay", back_populates="office", cascade="all, delete-orphan")
|
||||
weekly_closing_days = relationship("OfficeWeeklyClosingDay", back_populates="office", cascade="all, delete-orphan")
|
||||
spots = relationship("OfficeSpot", back_populates="office", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class User(Base):
|
||||
@@ -130,7 +133,7 @@ class DailyParkingAssignment(Base):
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
date = Column(Date, nullable=False)
|
||||
spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from office)
|
||||
spot_id = Column(Text, ForeignKey("office_spots.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Text, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) # Office that owns the spot
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -138,6 +141,7 @@ class DailyParkingAssignment(Base):
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="assignments", foreign_keys=[user_id])
|
||||
office = relationship("Office")
|
||||
spot = relationship("OfficeSpot", back_populates="assignments")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_assignment_office_date', 'office_id', 'date'),
|
||||
@@ -218,7 +222,7 @@ class ParkingExclusion(Base):
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_exclusion_office_user', 'office_id', 'user_id', unique=True),
|
||||
Index('idx_exclusion_office_user', 'office_id', 'user_id'),
|
||||
)
|
||||
|
||||
|
||||
@@ -237,12 +241,6 @@ class NotificationLog(Base):
|
||||
)
|
||||
|
||||
|
||||
class NotificationQueue(Base):
|
||||
"""Queue for pending notifications (for immediate parking change notifications)"""
|
||||
__tablename__ = "notification_queue"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # parking_change
|
||||
subject = Column(Text, nullable=False)
|
||||
body = Column(Text, nullable=False)
|
||||
@@ -252,3 +250,23 @@ class NotificationQueue(Base):
|
||||
__table_args__ = (
|
||||
Index('idx_queue_pending', 'sent_at'),
|
||||
)
|
||||
|
||||
|
||||
class OfficeSpot(Base):
|
||||
"""Specific parking spot definitions (e.g., A1, A2) linked to an office"""
|
||||
__tablename__ = "office_spots"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(Text, nullable=False) # Display name: A1, A2, etc.
|
||||
spot_number = Column(Integer, nullable=False) # Numeric part for sorting/filtering (1, 2, 3...)
|
||||
is_unavailable = Column(Boolean, default=False) # If spot is temporarily out of service
|
||||
|
||||
# Relationships
|
||||
office = relationship("Office", back_populates="spots")
|
||||
assignments = relationship("DailyParkingAssignment", back_populates="spot", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_office_spot_number', 'office_id', 'spot_number', unique=True),
|
||||
Index('idx_office_spot_name', 'office_id', 'name', unique=True),
|
||||
)
|
||||
|
||||
61
fix_db_index.py
Normal file
61
fix_db_index.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import time
|
||||
|
||||
# Function to find the db file
|
||||
def find_db():
|
||||
candidates = [
|
||||
'data/parking.db',
|
||||
'/home/ssalemi/org-parking/data/parking.db',
|
||||
'./data/parking.db'
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
db_path = find_db()
|
||||
|
||||
if not db_path:
|
||||
print("Error: Could not find data/parking.db. Make sure you are in the project root.")
|
||||
exit(1)
|
||||
|
||||
print(f"Target Database: {db_path}")
|
||||
print("Attempting to fix 'parking_exclusions' index...")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Turn off foreign keys temporarily to avoid issues during schema modification if needed
|
||||
cursor.execute("PRAGMA foreign_keys=OFF")
|
||||
|
||||
# 1. Drop the existing unique index
|
||||
print("Dropping index idx_exclusion_office_user...")
|
||||
try:
|
||||
cursor.execute("DROP INDEX IF EXISTS idx_exclusion_office_user")
|
||||
print("Index dropped successfully.")
|
||||
except Exception as e:
|
||||
print(f"Warning during drop: {e}")
|
||||
|
||||
# 2. Recreate it as non-unique
|
||||
print("Creating non-unique index idx_exclusion_office_user...")
|
||||
try:
|
||||
cursor.execute("CREATE INDEX idx_exclusion_office_user ON parking_exclusions (office_id, user_id)")
|
||||
print("Index created successfully.")
|
||||
except Exception as e:
|
||||
print(f"Error creating index: {e}")
|
||||
exit(1)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("\nSUCCESS: Database updated. You can now define multiple exclusions per user.")
|
||||
|
||||
except sqlite3.OperationalError as e:
|
||||
if "locked" in str(e):
|
||||
print("\nERROR: Database is LOCKED.")
|
||||
print("Please STOP the running application (Docker) and try again.")
|
||||
else:
|
||||
print(f"\nError: {e}")
|
||||
except Exception as e:
|
||||
print(f"\nUnexpected Error: {e}")
|
||||
@@ -652,6 +652,11 @@ textarea {
|
||||
border-color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.status-business_trip {
|
||||
background: var(--warning-bg) !important;
|
||||
border-color: var(--warning) !important;
|
||||
}
|
||||
|
||||
.status-nodata {
|
||||
background: white;
|
||||
}
|
||||
@@ -1788,6 +1793,7 @@ textarea {
|
||||
transform: translate(-50%, 100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(-50%, 0);
|
||||
opacity: 1;
|
||||
@@ -1798,6 +1804,7 @@ textarea {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -185,9 +185,23 @@ const api = {
|
||||
* Logout
|
||||
*/
|
||||
async logout() {
|
||||
// Fetch config to check for external logout URL
|
||||
let logoutUrl = '/login';
|
||||
try {
|
||||
const configRes = await this.get('/api/auth/config');
|
||||
if (configRes && configRes.ok) {
|
||||
const config = await configRes.json();
|
||||
if (config.authelia_enabled && config.logout_url) {
|
||||
logoutUrl = config.logout_url;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching logout config', e);
|
||||
}
|
||||
|
||||
await this.post('/api/auth/logout', {});
|
||||
this.clearToken();
|
||||
window.location.href = '/login';
|
||||
window.location.href = logoutUrl;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -87,9 +87,6 @@ async function initNav() {
|
||||
// Get user info (works with both JWT and Authelia)
|
||||
const currentUser = await api.checkAuth();
|
||||
|
||||
// Render navigation
|
||||
navContainer.innerHTML = renderNav(currentPath, currentUser?.role);
|
||||
|
||||
// Update user info in sidebar
|
||||
if (currentUser) {
|
||||
const userNameEl = document.getElementById('userName');
|
||||
@@ -98,11 +95,55 @@ async function initNav() {
|
||||
if (userRoleEl) userRoleEl.textContent = currentUser.role || '-';
|
||||
}
|
||||
|
||||
// Setup user menu
|
||||
// Setup user menu (logout) & mobile menu
|
||||
setupUserMenu();
|
||||
|
||||
// Setup mobile menu
|
||||
setupMobileMenu();
|
||||
|
||||
// CHECK: Block access if user has no office (and is not admin)
|
||||
// Admins are allowed to access "Gestione Uffici" even without an office
|
||||
if (currentUser && !currentUser.office_id && currentUser.role !== 'admin') {
|
||||
navContainer.innerHTML = ''; // Clear nav
|
||||
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
if (mainContent) {
|
||||
mainContent.innerHTML = `
|
||||
<div style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
">
|
||||
<div class="card" style="max-width: 500px; padding: 2.5rem; border-top: 4px solid #ef4444;">
|
||||
<div style="color: #ef4444; margin-bottom: 1.5rem;">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 style="margin-bottom: 1rem;">Ufficio non assegnato</h2>
|
||||
<p class="text-secondary" style="margin-bottom: 1.5rem; line-height: 1.6;">
|
||||
Il tuo account <strong>${currentUser.email}</strong> è attivo, ma non sei ancora stato assegnato a nessuno ufficio.
|
||||
</p>
|
||||
<div style="padding: 1.5rem; background: #f8fafc; border-radius: 8px; text-align: left;">
|
||||
<div style="font-weight: 600; margin-bottom: 0.5rem; color: var(--text);">Cosa fare?</div>
|
||||
<div style="font-size: 0.95rem; color: var(--text-secondary);">
|
||||
Contatta l'amministratore di sistema per richiedere l'assegnazione al tuo ufficio di competenza.<br>
|
||||
<a href="mailto:s.salemi@sielte.it" style="color: var(--primary); text-decoration: none; font-weight: 500; margin-top: 0.5rem; display: inline-block;">s.salemi@sielte.it</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return; // STOP rendering nav
|
||||
}
|
||||
|
||||
// Render navigation (Normal Flow)
|
||||
navContainer.innerHTML = renderNav(currentPath, currentUser?.role);
|
||||
}
|
||||
|
||||
function setupMobileMenu() {
|
||||
|
||||
@@ -143,6 +143,7 @@ function setupEventListeners() {
|
||||
}
|
||||
});
|
||||
|
||||
// Test Tools
|
||||
// Test Tools
|
||||
document.getElementById('runAllocationBtn').addEventListener('click', async () => {
|
||||
if (!confirm('Sei sicuro di voler avviare l\'assegnazione ORA? Questo potrebbe sovrascrivere le assegnazioni esistenti per la data selezionata.')) return;
|
||||
@@ -166,7 +167,7 @@ function setupEventListeners() {
|
||||
utils.showMessage('Avvio assegnazione...', 'success');
|
||||
|
||||
while (current <= end) {
|
||||
const dateStr = current.toISOString().split('T')[0];
|
||||
const dateStr = utils.formatDate(current);
|
||||
try {
|
||||
await api.post('/api/parking/run-allocation', {
|
||||
date: dateStr,
|
||||
@@ -207,20 +208,110 @@ function setupEventListeners() {
|
||||
|
||||
utils.showMessage('Rimozione in corso...', 'warning');
|
||||
|
||||
// Loop is fine, but maybe redundant if we could batch clean?
|
||||
// Backend clear-assignments is per day.
|
||||
while (current <= end) {
|
||||
const dateStr = current.toISOString().split('T')[0];
|
||||
const dateStr = utils.formatDate(current);
|
||||
try {
|
||||
const res = await api.post('/api/parking/clear-assignments', {
|
||||
date: dateStr,
|
||||
office_id: currentOffice.id
|
||||
});
|
||||
totalRemoved += (res.count || 0);
|
||||
if (res && res.ok) {
|
||||
const data = await res.json();
|
||||
totalRemoved += (data.count || 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error clearing ${dateStr}`, e);
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
utils.showMessage(`Operazione eseguita. ${totalRemoved} assegnazioni rimosse in totale.`, 'warning');
|
||||
utils.showMessage(`Operazione eseguita.`, 'warning');
|
||||
});
|
||||
|
||||
const clearPresenceBtn = document.getElementById('clearPresenceBtn');
|
||||
if (clearPresenceBtn) {
|
||||
clearPresenceBtn.addEventListener('click', async () => {
|
||||
if (!confirm('ATTENZIONE: Stai per eliminare TUTTI GLI STATI (Presente/Assente/ecc) e relative assegnazioni per tutti gli utenti dell\'ufficio nel periodo selezionato. \n\nQuesta azione è irreversibile. Procedere?')) return;
|
||||
|
||||
const dateStart = document.getElementById('testDateStart').value;
|
||||
const dateEnd = document.getElementById('testDateEnd').value;
|
||||
|
||||
if (!dateStart) return utils.showMessage('Seleziona una data di inizio', 'error');
|
||||
|
||||
// Validate office
|
||||
if (!currentOffice || !currentOffice.id) {
|
||||
return utils.showMessage('Errore: Nessun ufficio selezionato', 'error');
|
||||
}
|
||||
|
||||
const endDateVal = dateEnd || dateStart;
|
||||
|
||||
utils.showMessage('Rimozione stati in corso...', 'warning');
|
||||
|
||||
try {
|
||||
const res = await api.post('/api/presence/admin/clear-office-presence', {
|
||||
start_date: dateStart,
|
||||
end_date: endDateVal,
|
||||
office_id: currentOffice.id
|
||||
});
|
||||
|
||||
if (res && res.ok) {
|
||||
const data = await res.json();
|
||||
utils.showMessage(`Operazione completata. Rimossi ${data.count_presence} stati e ${data.count_parking} assegnazioni.`, 'success');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
utils.showMessage('Errore: ' + (err.detail || 'Operazione fallita'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
utils.showMessage('Errore di comunicazione col server', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const testEmailBtn = document.getElementById('testEmailBtn');
|
||||
if (testEmailBtn) {
|
||||
testEmailBtn.addEventListener('click', async () => {
|
||||
const dateVal = document.getElementById('testEmailDate').value;
|
||||
|
||||
// Validate office
|
||||
if (!currentOffice || !currentOffice.id) {
|
||||
return utils.showMessage('Errore: Nessun ufficio selezionato', 'error');
|
||||
}
|
||||
|
||||
utils.showMessage('Invio mail di test in corso...', 'warning');
|
||||
|
||||
try {
|
||||
const res = await api.post('/api/parking/test-email', {
|
||||
date: dateVal || null,
|
||||
office_id: currentOffice.id
|
||||
});
|
||||
|
||||
if (res && res.status !== 403 && res.status !== 500 && res.ok !== false) {
|
||||
// API wrapper usually returns response object or parses JSON?
|
||||
// api.post returns response object if 200-299, but wrapper handles some.
|
||||
// Let's assume standard fetch response or check wrapper.
|
||||
// api.js Wrapper returns fetch Response.
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
let msg = `Email inviata con successo per la data: ${data.date}.`;
|
||||
if (data.mode === 'FILE') {
|
||||
msg += ' (SMTP disabilitato: Loggato su file)';
|
||||
}
|
||||
utils.showMessage(msg, 'success');
|
||||
} else {
|
||||
utils.showMessage('Invio fallito. Controlla i log del server.', 'error');
|
||||
}
|
||||
} else {
|
||||
const err = res ? await res.json() : {};
|
||||
utils.showMessage('Errore: ' + (err.detail || 'Invio fallito'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
utils.showMessage('Errore di comunicazione col server', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize Parking Status
|
||||
initParkingStatus();
|
||||
setupStatusListeners();
|
||||
|
||||
// Initialize Exclusion Logic
|
||||
initExclusionLogic();
|
||||
});
|
||||
|
||||
async function loadPresences() {
|
||||
@@ -337,19 +340,57 @@ function setupEventListeners() {
|
||||
const promises = [];
|
||||
let current = new Date(startDate);
|
||||
|
||||
// Validate filtering
|
||||
let skippedCount = 0;
|
||||
|
||||
while (current <= endDate) {
|
||||
const dStr = current.toISOString().split('T')[0];
|
||||
if (status === 'clear') {
|
||||
promises.push(api.delete(`/api/presence/${dStr}`));
|
||||
|
||||
// Create local date for rules check (matches renderCalendar logic)
|
||||
const localCurrent = new Date(dStr + 'T00:00:00');
|
||||
const dayOfWeek = localCurrent.getDay(); // 0-6
|
||||
|
||||
// Check closing days
|
||||
// Only enforce rules if we are not clearing (or should we enforce for clearing too?
|
||||
// Usually clearing is allowed always, but "Inserimento" implies adding.
|
||||
// Ensuring we don't ADD presence on closed days is the main goal.)
|
||||
let isClosed = false;
|
||||
|
||||
if (status !== 'clear') {
|
||||
const isWeeklyClosed = weeklyClosingDays.includes(dayOfWeek);
|
||||
const isSpecificClosed = specificClosingDays.some(d => {
|
||||
const start = new Date(d.date);
|
||||
const end = d.end_date ? new Date(d.end_date) : start;
|
||||
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
// localCurrent is already set to 00:00:00 local
|
||||
return localCurrent >= start && localCurrent <= end;
|
||||
});
|
||||
|
||||
if (isWeeklyClosed || isSpecificClosed) isClosed = true;
|
||||
}
|
||||
|
||||
if (isClosed) {
|
||||
skippedCount++;
|
||||
} else {
|
||||
promises.push(api.post('/api/presence/mark', { date: dStr, status: status }));
|
||||
if (status === 'clear') {
|
||||
promises.push(api.delete(`/api/presence/${dStr}`));
|
||||
} else {
|
||||
promises.push(api.post('/api/presence/mark', { date: dStr, status: status }));
|
||||
}
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
utils.showMessage('Inserimento completato!', 'success');
|
||||
if (skippedCount > 0) {
|
||||
utils.showMessage(`Inserimento completato! (${skippedCount} giorni chiusi ignorati)`, 'warning');
|
||||
} else {
|
||||
utils.showMessage('Inserimento completato!', 'success');
|
||||
}
|
||||
await Promise.all([loadPresences(), loadParkingAssignments()]);
|
||||
renderCalendar();
|
||||
} catch (err) {
|
||||
@@ -531,3 +572,211 @@ function setupStatusListeners() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Exclusion Logic
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async function initExclusionLogic() {
|
||||
await loadExclusionStatus();
|
||||
setupExclusionListeners();
|
||||
}
|
||||
|
||||
async function loadExclusionStatus() {
|
||||
try {
|
||||
const response = await api.get('/api/users/me/exclusion');
|
||||
if (response && response.ok) {
|
||||
const data = await response.json();
|
||||
updateExclusionUI(data); // data is now a list
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error loading exclusion status", e);
|
||||
}
|
||||
}
|
||||
|
||||
function updateExclusionUI(exclusions) {
|
||||
const statusDiv = document.getElementById('exclusionStatusDisplay');
|
||||
const manageBtn = document.getElementById('manageExclusionBtn');
|
||||
|
||||
// Always show manage button as "Aggiungi Esclusione"
|
||||
manageBtn.textContent = 'Aggiungi Esclusione';
|
||||
// Clear previous binding to avoid duplicates or simply use a new function
|
||||
// But specific listeners are set in setupExclusionListeners.
|
||||
// Actually, manageBtn logic was resetting UI.
|
||||
|
||||
if (exclusions && exclusions.length > 0) {
|
||||
statusDiv.style.display = 'block';
|
||||
|
||||
let html = '<div style="display:flex; flex-direction:column; gap:0.5rem;">';
|
||||
|
||||
exclusions.forEach(ex => {
|
||||
let period = 'Tempo Indeterminato';
|
||||
if (ex.start_date && ex.end_date) {
|
||||
period = `${utils.formatDate(new Date(ex.start_date))} - ${utils.formatDate(new Date(ex.end_date))}`;
|
||||
} else if (ex.start_date) {
|
||||
period = `Dal ${utils.formatDate(new Date(ex.start_date))}`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div style="background: white; border: 1px solid #e5e7eb; padding: 0.75rem; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<div style="font-weight: 500; font-size: 0.9rem;">${period}</div>
|
||||
${ex.notes ? `<div style="font-size: 0.8rem; color: #6b7280;">${ex.notes}</div>` : ''}
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn-icon" onclick='openEditMyExclusion("${ex.id}", ${JSON.stringify(ex).replace(/'/g, "'")})' title="Modifica">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon btn-danger" onclick="deleteMyExclusion('${ex.id}')" title="Rimuovi">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
statusDiv.innerHTML = html;
|
||||
|
||||
// Update container style for list
|
||||
statusDiv.style.backgroundColor = '#f9fafb';
|
||||
statusDiv.style.color = 'inherit';
|
||||
statusDiv.style.border = 'none'; // remove border from container, items have border
|
||||
statusDiv.style.padding = '0'; // reset padding
|
||||
|
||||
} else {
|
||||
statusDiv.style.display = 'none';
|
||||
statusDiv.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Global for edit
|
||||
let myEditingExclusionId = null;
|
||||
|
||||
function openEditMyExclusion(id, data) {
|
||||
myEditingExclusionId = id;
|
||||
const modal = document.getElementById('userExclusionModal');
|
||||
const radioForever = document.querySelector('input[name="exclusionType"][value="forever"]');
|
||||
const radioRange = document.querySelector('input[name="exclusionType"][value="range"]');
|
||||
const rangeDiv = document.getElementById('exclusionDateRange');
|
||||
const deleteBtn = document.getElementById('deleteExclusionBtn'); // Hide in edit mode (we have icon) or keep?
|
||||
// User requested "matita a destra per la modifica ed eliminazione".
|
||||
// I added trash icon to the list. So modal "Rimuovi" is redundant but harmless.
|
||||
// I'll hide it for clarity.
|
||||
if (deleteBtn) deleteBtn.style.display = 'none';
|
||||
|
||||
if (data.start_date || data.end_date) {
|
||||
radioRange.checked = true;
|
||||
rangeDiv.style.display = 'block';
|
||||
if (data.start_date) document.getElementById('ueStartDate').value = data.start_date;
|
||||
if (data.end_date) document.getElementById('ueEndDate').value = data.end_date;
|
||||
} else {
|
||||
radioForever.checked = true;
|
||||
rangeDiv.style.display = 'none';
|
||||
document.getElementById('ueStartDate').value = '';
|
||||
document.getElementById('ueEndDate').value = '';
|
||||
}
|
||||
document.getElementById('ueNotes').value = data.notes || '';
|
||||
|
||||
document.querySelector('#userExclusionModal h3').textContent = 'Modifica Esclusione';
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
async function deleteMyExclusion(id) {
|
||||
if (!confirm('Rimuovere questa esclusione?')) return;
|
||||
const response = await api.delete(`/api/users/me/exclusion/${id}`);
|
||||
if (response && response.ok) {
|
||||
utils.showMessage('Esclusione rimossa con successo', 'success');
|
||||
loadExclusionStatus();
|
||||
} else {
|
||||
const err = await response.json();
|
||||
utils.showMessage(err.detail || 'Errore rimozione', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function resetMyExclusionForm() {
|
||||
document.getElementById('userExclusionForm').reset();
|
||||
myEditingExclusionId = null;
|
||||
document.querySelector('#userExclusionModal h3').textContent = 'Nuova Esclusione';
|
||||
|
||||
const rangeDiv = document.getElementById('exclusionDateRange');
|
||||
rangeDiv.style.display = 'none';
|
||||
document.querySelector('input[name="exclusionType"][value="forever"]').checked = true;
|
||||
|
||||
// Hide delete btn in modal (using list icon instead)
|
||||
const deleteBtn = document.getElementById('deleteExclusionBtn');
|
||||
if (deleteBtn) deleteBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
function setupExclusionListeners() {
|
||||
const modal = document.getElementById('userExclusionModal');
|
||||
const manageBtn = document.getElementById('manageExclusionBtn');
|
||||
const closeBtn = document.getElementById('closeUserExclusionModal');
|
||||
const cancelBtn = document.getElementById('cancelUserExclusion');
|
||||
const form = document.getElementById('userExclusionForm');
|
||||
|
||||
const radioForever = document.querySelector('input[name="exclusionType"][value="forever"]');
|
||||
const radioRange = document.querySelector('input[name="exclusionType"][value="range"]');
|
||||
const rangeDiv = document.getElementById('exclusionDateRange');
|
||||
|
||||
if (manageBtn) {
|
||||
manageBtn.addEventListener('click', () => {
|
||||
resetMyExclusionForm();
|
||||
modal.style.display = 'flex';
|
||||
});
|
||||
}
|
||||
|
||||
if (closeBtn) closeBtn.addEventListener('click', () => modal.style.display = 'none');
|
||||
if (cancelBtn) cancelBtn.addEventListener('click', () => modal.style.display = 'none');
|
||||
|
||||
// Radio logic
|
||||
radioForever.addEventListener('change', () => rangeDiv.style.display = 'none');
|
||||
radioRange.addEventListener('change', () => rangeDiv.style.display = 'block');
|
||||
|
||||
// Save
|
||||
if (form) {
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const type = document.querySelector('input[name="exclusionType"]:checked').value;
|
||||
const payload = {
|
||||
notes: document.getElementById('ueNotes').value
|
||||
};
|
||||
|
||||
if (type === 'range') {
|
||||
const start = document.getElementById('ueStartDate').value;
|
||||
const end = document.getElementById('ueEndDate').value;
|
||||
|
||||
if (start) payload.start_date = start;
|
||||
if (end) payload.end_date = end;
|
||||
|
||||
if (start && end && new Date(end) < new Date(start)) {
|
||||
return utils.showMessage('La data di fine deve essere dopo la data di inizio', 'error');
|
||||
}
|
||||
} else {
|
||||
payload.start_date = null;
|
||||
payload.end_date = null;
|
||||
}
|
||||
|
||||
let response;
|
||||
if (myEditingExclusionId) {
|
||||
response = await api.put(`/api/users/me/exclusion/${myEditingExclusionId}`, payload);
|
||||
} else {
|
||||
response = await api.post('/api/users/me/exclusion', payload);
|
||||
}
|
||||
|
||||
if (response && response.ok) {
|
||||
utils.showMessage('Esclusione salvata', 'success');
|
||||
modal.style.display = 'none';
|
||||
loadExclusionStatus();
|
||||
} else {
|
||||
const err = await response.json();
|
||||
utils.showMessage(err.detail || 'Errore salvataggio', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Globals
|
||||
window.openEditMyExclusion = openEditMyExclusion;
|
||||
window.deleteMyExclusion = deleteMyExclusion;
|
||||
|
||||
@@ -113,6 +113,77 @@ async function loadOffices() {
|
||||
|
||||
// Initial update of office display
|
||||
updateOfficeDisplay();
|
||||
|
||||
// Show export card for Admin/Manager
|
||||
if (['admin', 'manager'].includes(currentUser.role)) {
|
||||
const exportCard = document.getElementById('exportCard');
|
||||
if (exportCard) {
|
||||
exportCard.style.display = 'block';
|
||||
|
||||
// Set defaults (current month)
|
||||
const today = new Date();
|
||||
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
|
||||
document.getElementById('exportStartDate').valueAsDate = firstDay;
|
||||
document.getElementById('exportEndDate').valueAsDate = lastDay;
|
||||
|
||||
document.getElementById('exportBtn').addEventListener('click', handleExport);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const startStr = document.getElementById('exportStartDate').value;
|
||||
const endStr = document.getElementById('exportEndDate').value;
|
||||
const officeId = document.getElementById('officeFilter').value;
|
||||
|
||||
if (!startStr || !endStr) {
|
||||
alert('Seleziona le date di inizio e fine');
|
||||
return;
|
||||
}
|
||||
|
||||
// Construct URL
|
||||
let url = `/api/reports/team-export?start_date=${startStr}&end_date=${endStr}`;
|
||||
if (officeId) {
|
||||
url += `&office_id=${officeId}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const btn = document.getElementById('exportBtn');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Generazione...';
|
||||
|
||||
// Use fetch directly to handle blob
|
||||
const token = api.getToken();
|
||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = `report_presenze_${startStr}_${endStr}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
a.remove();
|
||||
} else {
|
||||
const err = await response.json();
|
||||
alert('Errore export: ' + (err.detail || 'Sconosciuto'));
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Errore durante l\'export');
|
||||
document.getElementById('exportBtn').disabled = false;
|
||||
document.getElementById('exportBtn').textContent = 'Esporta Excel';
|
||||
}
|
||||
}
|
||||
|
||||
function getDateRange() {
|
||||
|
||||
@@ -267,26 +267,70 @@ async function loadExclusions(officeId) {
|
||||
</span>
|
||||
${e.notes ? `<span class="rule-note">${e.notes}</span>` : ''}
|
||||
</div>
|
||||
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')">
|
||||
×
|
||||
</button>
|
||||
<div class="rule-actions" style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<button class="btn-icon" onclick='openEditExclusion("${e.id}", ${JSON.stringify(e).replace(/'/g, "'")})' title="Modifica">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon btn-danger" onclick="deleteExclusion('${e.id}')" title="Elimina">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
async function addExclusion(data) {
|
||||
const response = await api.post(`/api/offices/${currentOfficeId}/exclusions`, data);
|
||||
// Global variable to track edit mode
|
||||
let editingExclusionId = null;
|
||||
|
||||
async function openEditExclusion(id, data) {
|
||||
editingExclusionId = id;
|
||||
|
||||
// Populate form
|
||||
populateUserSelects();
|
||||
document.getElementById('exclusionUser').value = data.user_id;
|
||||
// Disable user select in edit mode usually? Or allow change? API allows it.
|
||||
|
||||
document.getElementById('exclusionStartDate').value = data.start_date || '';
|
||||
document.getElementById('exclusionEndDate').value = data.end_date || '';
|
||||
document.getElementById('exclusionNotes').value = data.notes || '';
|
||||
|
||||
// Change modal title/button
|
||||
document.querySelector('#exclusionModal h3').textContent = 'Modifica Esclusione';
|
||||
document.querySelector('#exclusionForm button[type="submit"]').textContent = 'Salva Modifiche';
|
||||
|
||||
document.getElementById('exclusionModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function saveExclusion(data) {
|
||||
let response;
|
||||
if (editingExclusionId) {
|
||||
response = await api.put(`/api/offices/${currentOfficeId}/exclusions/${editingExclusionId}`, data);
|
||||
} else {
|
||||
response = await api.post(`/api/offices/${currentOfficeId}/exclusions`, data);
|
||||
}
|
||||
|
||||
if (response && response.ok) {
|
||||
await loadExclusions(currentOfficeId);
|
||||
document.getElementById('exclusionModal').style.display = 'none';
|
||||
document.getElementById('exclusionForm').reset();
|
||||
resetExclusionForm();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.detail || 'Impossibile aggiungere l\'esclusione');
|
||||
alert(error.detail || 'Impossibile salvare l\'esclusione');
|
||||
}
|
||||
}
|
||||
|
||||
function resetExclusionForm() {
|
||||
document.getElementById('exclusionForm').reset();
|
||||
editingExclusionId = null;
|
||||
document.querySelector('#exclusionModal h3').textContent = 'Aggiungi Esclusione Parcheggio';
|
||||
document.querySelector('#exclusionForm button[type="submit"]').textContent = 'Aggiungi';
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function deleteExclusion(id) {
|
||||
if (!confirm('Eliminare questa esclusione?')) return;
|
||||
const response = await api.delete(`/api/offices/${currentOfficeId}/exclusions/${id}`);
|
||||
@@ -335,6 +379,10 @@ function setupEventListeners() {
|
||||
modals.forEach(m => {
|
||||
document.getElementById(m.btn).addEventListener('click', () => {
|
||||
if (m.id !== 'closingDayModal') populateUserSelects();
|
||||
|
||||
// Special handling for exclusion to reset edit mode
|
||||
if (m.id === 'exclusionModal') resetExclusionForm();
|
||||
|
||||
document.getElementById(m.id).style.display = 'flex';
|
||||
});
|
||||
document.getElementById(m.close).addEventListener('click', () => {
|
||||
@@ -368,7 +416,7 @@ function setupEventListeners() {
|
||||
|
||||
document.getElementById('exclusionForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
addExclusion({
|
||||
saveExclusion({
|
||||
user_id: document.getElementById('exclusionUser').value,
|
||||
start_date: document.getElementById('exclusionStartDate').value || null,
|
||||
end_date: document.getElementById('exclusionEndDate').value || null,
|
||||
@@ -380,4 +428,7 @@ function setupEventListeners() {
|
||||
// Global functions
|
||||
window.deleteClosingDay = deleteClosingDay;
|
||||
window.deleteGuarantee = deleteGuarantee;
|
||||
window.deleteClosingDay = deleteClosingDay;
|
||||
window.deleteGuarantee = deleteGuarantee;
|
||||
window.deleteExclusion = deleteExclusion;
|
||||
window.openEditExclusion = openEditExclusion;
|
||||
|
||||
@@ -138,6 +138,28 @@
|
||||
<button id="clearAssignmentsBtn" class="btn btn-danger">
|
||||
Elimina Tutte le Assegnazioni
|
||||
</button>
|
||||
<button id="clearPresenceBtn" class="btn btn-danger"
|
||||
title="Elimina stati e assegnazioni per i giorni selezionati">
|
||||
Elimina Stati
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 1.5rem 0; border: 0; border-top: 1px solid #e5e7eb;">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Test Invio Email</label>
|
||||
<div style="display: flex; gap: 1rem; align-items: flex-end;">
|
||||
<div style="flex: 1;">
|
||||
<small>Data di Riferimento (Opzionale):</small>
|
||||
<input type="date" id="testEmailDate" class="form-control">
|
||||
<small class="text-muted" style="display: block; margin-top: 0.25rem;">
|
||||
Se non specificata, verrà usato il primo giorno lavorativo disponibile.
|
||||
</small>
|
||||
</div>
|
||||
<button id="testEmailBtn" class="btn btn-secondary">
|
||||
Test Invio Mail
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,11 @@
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color status-absent"></div>
|
||||
<span>Assente</span>
|
||||
<span>Ferie</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color status-business_trip"></div>
|
||||
<span>Trasferta</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,11 +159,35 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Exclusion Card -->
|
||||
<div class="card" id="exclusionCard" style="margin-top: 2rem;">
|
||||
<div class="card-header">
|
||||
<h3>Esclusione Assegnazione</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted" style="margin-bottom: 1rem;">
|
||||
Puoi decidere di escluderti automaticamente dalla logica di assegnazione dei posti auto.
|
||||
Le richieste di esclusione sono visibili agli amministratori.
|
||||
</p>
|
||||
|
||||
<div id="exclusionStatusDisplay"
|
||||
style="display: none; padding: 1rem; border-radius: 6px; margin-bottom: 1rem; background-color: #f3f4f6; border: 1px solid #e5e7eb;">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn btn-dark" id="manageExclusionBtn">Gestisci Esclusione</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card parking-map-card" style="margin-top: 2rem;">
|
||||
<h3>Mappa Parcheggio</h3>
|
||||
<img src="/assets/parking-map.png" alt="Mappa Parcheggio"
|
||||
style="width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--border);">
|
||||
</div>
|
||||
</div> <!-- End parking-map-card -->
|
||||
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -199,7 +227,11 @@
|
||||
</button>
|
||||
<button type="button" class="status-btn qe-status-btn" data-status="absent">
|
||||
<div class="status-icon status-absent"></div>
|
||||
<span>Assente</span>
|
||||
<span>Ferie</span>
|
||||
</button>
|
||||
<button type="button" class="status-btn qe-status-btn" data-status="business_trip">
|
||||
<div class="status-icon status-business_trip"></div>
|
||||
<span>Trasferta</span>
|
||||
</button>
|
||||
<button type="button" class="status-btn qe-status-btn" data-status="clear">
|
||||
<div class="status-icon"
|
||||
@@ -241,7 +273,11 @@
|
||||
</button>
|
||||
<button class="status-btn" data-status="absent">
|
||||
<div class="status-icon status-absent"></div>
|
||||
<span>Assente</span>
|
||||
<span>Ferie</span>
|
||||
</button>
|
||||
<button class="status-btn" data-status="business_trip">
|
||||
<div class="status-icon status-business_trip"></div>
|
||||
<span>Trasferta</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -276,6 +312,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Exclusion Modal -->
|
||||
<div class="modal" id="userExclusionModal" style="display: none;">
|
||||
<div class="modal-content modal-small">
|
||||
<div class="modal-header">
|
||||
<h3>Gestisci Esclusione</h3>
|
||||
<button class="modal-close" id="closeUserExclusionModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="userExclusionForm">
|
||||
<div class="form-group">
|
||||
<label style="font-weight: 500; margin-bottom: 0.5rem; display: block;">Durata
|
||||
Esclusione</label>
|
||||
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="radio" name="exclusionType" value="forever" checked>
|
||||
<span>Tempo Indeterminato</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="radio" name="exclusionType" value="range">
|
||||
<span>Periodo Specifico</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="exclusionDateRange" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="ueStartDate">Data Inizio</label>
|
||||
<input type="date" id="ueStartDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ueEndDate">Data Fine</label>
|
||||
<input type="date" id="ueEndDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ueNotes">Motivo (opzionale)</label>
|
||||
<textarea id="ueNotes" class="form-control" rows="2"
|
||||
placeholder="Es. Lavoro da remoto per un mese..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-danger" id="deleteExclusionBtn"
|
||||
style="display: none; margin-right: auto;">Rimuovi</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancelUserExclusion">Annulla</button>
|
||||
<button type="submit" class="btn btn-dark">Salva</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@
|
||||
<small class="text-muted">Il ruolo è assegnato dal tuo amministratore</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="manager">Manager</label>
|
||||
<label for="manager">Ufficio</label>
|
||||
<input type="text" id="manager" disabled>
|
||||
<small class="text-muted">Il tuo manager è assegnato dall'amministratore</small>
|
||||
<small class="text-muted">Il tuo ufficio è assegnato dall'amministratore</small>
|
||||
</div>
|
||||
<div class="form-actions" id="profileActions">
|
||||
<button type="submit" class="btn btn-dark">Salva Modifiche</button>
|
||||
@@ -139,7 +139,7 @@
|
||||
document.getElementById('name').value = profile.name || '';
|
||||
document.getElementById('email').value = profile.email;
|
||||
document.getElementById('role').value = profile.role;
|
||||
document.getElementById('manager').value = profile.manager_name || 'Nessuno';
|
||||
document.getElementById('manager').value = profile.office_name || 'Nessuno';
|
||||
|
||||
// LDAP mode adjustments
|
||||
if (isLdapUser) {
|
||||
|
||||
@@ -54,17 +54,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="notificationForm">
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<span>Riepilogo Settimanale</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="notifyWeeklyParking">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</label>
|
||||
<small class="text-muted">Ricevi il riepilogo settimanale delle assegnazioni parcheggio ogni
|
||||
Venerdì alle 12:00</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<span>Promemoria Giornaliero</span>
|
||||
@@ -141,7 +131,7 @@
|
||||
// Notification settings
|
||||
|
||||
// Notification settings
|
||||
document.getElementById('notifyWeeklyParking').checked = currentUser.notify_weekly_parking !== 0;
|
||||
|
||||
document.getElementById('notifyDailyParking').checked = currentUser.notify_daily_parking !== 0;
|
||||
document.getElementById('notifyDailyHour').value = currentUser.notify_daily_parking_hour || 8;
|
||||
document.getElementById('notifyDailyMinute').value = currentUser.notify_daily_parking_minute || 0;
|
||||
@@ -164,7 +154,7 @@
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
notify_weekly_parking: document.getElementById('notifyWeeklyParking').checked ? 1 : 0,
|
||||
notify_weekly_parking: 0,
|
||||
notify_daily_parking: document.getElementById('notifyDailyParking').checked ? 1 : 0,
|
||||
notify_daily_parking_hour: parseInt(document.getElementById('notifyDailyHour').value),
|
||||
notify_daily_parking_minute: parseInt(document.getElementById('notifyDailyMinute').value),
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="office-display-header"
|
||||
style="margin-bottom: 1rem; font-weight: 600; font-size: 1.1rem; color: var(--text);">
|
||||
Ufficio: <span id="currentOfficeNameDisplay" style="color: var(--primary);">Loading...</span>
|
||||
@@ -100,8 +101,36 @@
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color status-absent"></div>
|
||||
<span>Assente</span>
|
||||
<span>Ferie</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color status-business_trip"></div>
|
||||
<span>Trasferta</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Card (Admin/Manager only) -->
|
||||
<div id="exportCard" class="card" style="display: none;">
|
||||
<h3 style="margin-top: 0; margin-bottom: 1rem; font-size: 1.1rem;">Esporta Report</h3>
|
||||
<div style="display: flex; gap: 1rem; align-items: flex-end;">
|
||||
<div>
|
||||
<small style="display: block; margin-bottom: 0.25rem;">Da:</small>
|
||||
<input type="date" id="exportStartDate" class="form-control">
|
||||
</div>
|
||||
<div>
|
||||
<small style="display: block; margin-bottom: 0.25rem;">A:</small>
|
||||
<input type="date" id="exportEndDate" class="form-control">
|
||||
</div>
|
||||
<button id="exportBtn" class="btn btn-dark" style="height: 38px;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" style="margin-right: 0.5rem;">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
Esporta Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,7 +156,11 @@
|
||||
</button>
|
||||
<button class="status-btn" data-status="absent">
|
||||
<div class="status-icon status-absent"></div>
|
||||
<span>Assente</span>
|
||||
<span>Ferie</span>
|
||||
</button>
|
||||
<button class="status-btn" data-status="business_trip">
|
||||
<div class="status-icon status-business_trip"></div>
|
||||
<span>Trasferta</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-full" id="clearDayBtn" style="margin-top: 1rem;">Cancella
|
||||
|
||||
2
main.py
2
main.py
@@ -97,6 +97,8 @@ app.include_router(users_router)
|
||||
app.include_router(offices_router)
|
||||
app.include_router(presence_router)
|
||||
app.include_router(parking_router)
|
||||
from app.routes.reports import router as reports_router
|
||||
app.include_router(reports_router)
|
||||
|
||||
# Static Files
|
||||
app.mount("/css", StaticFiles(directory=str(config.FRONTEND_DIR / "css")), name="css")
|
||||
|
||||
@@ -10,3 +10,4 @@ slowapi==0.1.9
|
||||
python-multipart==0.0.9
|
||||
idna<4,>=2.5
|
||||
email-validator>=2.1.0.post1
|
||||
openpyxl>=3.1.2
|
||||
|
||||
@@ -220,75 +220,7 @@ def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Sessi
|
||||
return False
|
||||
|
||||
|
||||
def send_weekly_parking_summary(user: "User", next_week_dates: List[date], db: "Session") -> bool:
|
||||
"""Send weekly parking assignment summary for next week (Friday at 12)"""
|
||||
from database.models import DailyParkingAssignment, NotificationLog
|
||||
from services.parking import get_spot_display_name
|
||||
|
||||
if not user.notify_weekly_parking:
|
||||
return False
|
||||
|
||||
week_ref = get_week_reference(next_week_dates[0])
|
||||
|
||||
# Check if already sent for this week
|
||||
existing = db.query(NotificationLog).filter(
|
||||
NotificationLog.user_id == user.id,
|
||||
NotificationLog.notification_type == NotificationType.WEEKLY_PARKING,
|
||||
NotificationLog.reference_date == week_ref
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return False
|
||||
|
||||
# Get parking assignments for next week
|
||||
assignments = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.user_id == user.id,
|
||||
DailyParkingAssignment.date.in_(next_week_dates)
|
||||
).all()
|
||||
|
||||
if not assignments:
|
||||
return False
|
||||
|
||||
# Build assignment list
|
||||
assignment_lines = []
|
||||
# a.date is now a date object
|
||||
for a in sorted(assignments, key=lambda x: x.date):
|
||||
day_name = a.date.strftime("%A")
|
||||
spot_name = get_spot_display_name(a.spot_id, a.office_id, db)
|
||||
assignment_lines.append(f"<li>{day_name}, {a.date.strftime('%B %d')}: Spot {spot_name}</li>")
|
||||
|
||||
start_date = next_week_dates[0].strftime("%B %d")
|
||||
end_date = next_week_dates[-1].strftime("%B %d, %Y")
|
||||
|
||||
subject = f"Your parking spots for {start_date} - {end_date}"
|
||||
body_html = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Weekly Parking Summary</h2>
|
||||
<p>Hi {user.name},</p>
|
||||
<p>Here are your parking spot assignments for the upcoming week:</p>
|
||||
<ul>
|
||||
{''.join(assignment_lines)}
|
||||
</ul>
|
||||
<p>Parking assignments are now frozen. You can still release or reassign your spots if needed.</p>
|
||||
<p>Best regards,<br>Parking Manager</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
if send_email(user.email, subject, body_html):
|
||||
log = NotificationLog(
|
||||
id=generate_uuid(),
|
||||
user_id=user.id,
|
||||
notification_type=NotificationType.WEEKLY_PARKING,
|
||||
reference_date=week_ref,
|
||||
sent_at=datetime.utcnow()
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session") -> bool:
|
||||
@@ -377,11 +309,6 @@ def run_scheduled_notifications(db: "Session"):
|
||||
next_week = get_next_week_dates(today_date)
|
||||
send_presence_reminder(user, next_week, db)
|
||||
|
||||
# Friday at 12: Weekly parking summary
|
||||
if current_weekday == 4 and current_hour == 12 and current_minute < 5:
|
||||
next_week = get_next_week_dates(today_date)
|
||||
send_weekly_parking_summary(user, next_week, db)
|
||||
|
||||
# Daily parking reminder at user's preferred time (working days only)
|
||||
if current_weekday < 5: # Monday to Friday
|
||||
user_hour = user.notify_daily_parking_hour or 8
|
||||
|
||||
52
services/offices.py
Normal file
52
services/offices.py
Normal file
@@ -0,0 +1,52 @@
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from database.models import OfficeSpot
|
||||
from utils.helpers import generate_uuid
|
||||
|
||||
def sync_office_spots(office_id: str, quota: int, prefix: str, db: Session):
|
||||
"""
|
||||
Synchronize OfficeSpot records with the office quota.
|
||||
- If active spots < quota: Create new spots
|
||||
- If active spots > quota: Remove highest numbered spots (Cascade handles assignments)
|
||||
- If prefix changes: Rename all spots
|
||||
"""
|
||||
# Get all current spots sorted by number
|
||||
current_spots = db.query(OfficeSpot).filter(
|
||||
OfficeSpot.office_id == office_id
|
||||
).order_by(OfficeSpot.spot_number).all()
|
||||
|
||||
# 1. Handle Prefix Change
|
||||
# If prefix changed, we need to update names of ALL existing spots
|
||||
# We do this first to ensure names are correct even if we don't add/remove
|
||||
if current_spots:
|
||||
first_spot = current_spots[0]
|
||||
# Check simple heuristic: does name start with prefix?
|
||||
# Better: we can't easily know old prefix from here without querying Office,
|
||||
# but we can just re-generate names for all valid spots.
|
||||
for spot in current_spots:
|
||||
expected_name = f"{prefix}{spot.spot_number}"
|
||||
if spot.name != expected_name:
|
||||
spot.name = expected_name
|
||||
|
||||
current_count = len(current_spots)
|
||||
|
||||
# 2. Add Spots
|
||||
if current_count < quota:
|
||||
for i in range(current_count + 1, quota + 1):
|
||||
new_spot = OfficeSpot(
|
||||
id=generate_uuid(),
|
||||
office_id=office_id,
|
||||
spot_number=i,
|
||||
name=f"{prefix}{i}",
|
||||
is_unavailable=False
|
||||
)
|
||||
db.add(new_spot)
|
||||
|
||||
# 3. Remove Spots
|
||||
elif current_count > quota:
|
||||
# Identify spots to remove (highest numbers)
|
||||
spots_to_remove = current_spots[quota:]
|
||||
for spot in spots_to_remove:
|
||||
db.delete(spot)
|
||||
|
||||
db.commit()
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
|
||||
from database.models import (
|
||||
DailyParkingAssignment, User, UserPresence, Office,
|
||||
DailyParkingAssignment, User, UserPresence, Office, OfficeSpot,
|
||||
ParkingGuarantee, ParkingExclusion, OfficeClosingDay, OfficeWeeklyClosingDay,
|
||||
UserRole, PresenceStatus
|
||||
)
|
||||
@@ -23,45 +23,20 @@ from app import config
|
||||
|
||||
def get_spot_prefix(office: Office, db: Session) -> str:
|
||||
"""Get the spot prefix for an office (from office.spot_prefix or auto-assign)"""
|
||||
# Logic moved to Office creation/update mostly, but keeping helper if needed
|
||||
if office.spot_prefix:
|
||||
return office.spot_prefix
|
||||
|
||||
# Auto-assign based on alphabetical order of offices without prefix
|
||||
offices = db.query(Office).filter(
|
||||
Office.spot_prefix == None
|
||||
).order_by(Office.name).all()
|
||||
|
||||
# Find existing prefixes
|
||||
existing_prefixes = set(
|
||||
o.spot_prefix for o in db.query(Office).filter(
|
||||
Office.spot_prefix != None
|
||||
).all()
|
||||
)
|
||||
|
||||
# Find first available letter
|
||||
office_index = next((i for i, o in enumerate(offices) if o.id == office.id), 0)
|
||||
letter = 'A'
|
||||
count = 0
|
||||
while letter in existing_prefixes or count < office_index:
|
||||
if letter not in existing_prefixes:
|
||||
count += 1
|
||||
letter = chr(ord(letter) + 1)
|
||||
if ord(letter) > ord('Z'):
|
||||
letter = 'A'
|
||||
break
|
||||
|
||||
return letter
|
||||
return "A" # Fallback
|
||||
|
||||
|
||||
def get_spot_display_name(spot_id: str, office_id: str, db: Session) -> str:
|
||||
"""Get display name for a spot (e.g., 'A3' instead of 'spot-3')"""
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office:
|
||||
return spot_id
|
||||
|
||||
prefix = get_spot_prefix(office, db)
|
||||
spot_number = spot_id.replace("spot-", "")
|
||||
return f"{prefix}{spot_number}"
|
||||
"""Get display name for a spot"""
|
||||
# Now easy: fetch from OfficeSpot
|
||||
# But wait: spot_id in assignment IS the OfficeSpot.id
|
||||
spot = db.query(OfficeSpot).filter(OfficeSpot.id == spot_id).first()
|
||||
if spot:
|
||||
return spot.name
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
|
||||
@@ -95,35 +70,36 @@ def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
|
||||
|
||||
|
||||
def initialize_parking_pool(office_id: str, quota: int, pool_date: date, db: Session) -> int:
|
||||
"""Initialize empty parking spots for an office's pool on a given date.
|
||||
Returns 0 if it's a closing day (no parking available).
|
||||
"""
|
||||
# Don't create pool on closing days
|
||||
Get total capacity for the date.
|
||||
(Legacy name kept for compatibility, but now it just returns count).
|
||||
"""
|
||||
if is_closing_day(office_id, pool_date, db):
|
||||
return 0
|
||||
|
||||
existing = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.office_id == office_id,
|
||||
DailyParkingAssignment.date == pool_date
|
||||
|
||||
return db.query(OfficeSpot).filter(
|
||||
OfficeSpot.office_id == office_id,
|
||||
OfficeSpot.is_unavailable == False
|
||||
).count()
|
||||
|
||||
if existing > 0:
|
||||
return existing
|
||||
|
||||
for i in range(1, quota + 1):
|
||||
spot = DailyParkingAssignment(
|
||||
id=generate_uuid(),
|
||||
date=pool_date,
|
||||
spot_id=f"spot-{i}",
|
||||
user_id=None,
|
||||
office_id=office_id,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
db.add(spot)
|
||||
|
||||
db.commit()
|
||||
config.logger.debug(f"Initialized {quota} parking spots for office {office_id} on {pool_date}")
|
||||
return quota
|
||||
def get_available_spots(office_id: str, pool_date: date, db: Session) -> list[OfficeSpot]:
|
||||
"""Get list of unassigned OfficeSpots for a date"""
|
||||
# 1. Get all active spots
|
||||
all_spots = db.query(OfficeSpot).filter(
|
||||
OfficeSpot.office_id == office_id,
|
||||
OfficeSpot.is_unavailable == False
|
||||
).all()
|
||||
|
||||
# 2. Get assigned spot IDs
|
||||
assigned_ids = db.query(DailyParkingAssignment.spot_id).filter(
|
||||
DailyParkingAssignment.office_id == office_id,
|
||||
DailyParkingAssignment.date == pool_date
|
||||
).all()
|
||||
assigned_set = {a[0] for a in assigned_ids}
|
||||
|
||||
# 3. Filter
|
||||
return [s for s in all_spots if s.id not in assigned_set]
|
||||
|
||||
|
||||
def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
|
||||
@@ -151,21 +127,31 @@ def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
|
||||
|
||||
def is_user_excluded(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
|
||||
"""Check if user is excluded from parking for this date"""
|
||||
exclusion = db.query(ParkingExclusion).filter(
|
||||
exclusions = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.office_id == office_id,
|
||||
ParkingExclusion.user_id == user_id
|
||||
).first()
|
||||
).all()
|
||||
|
||||
if not exclusion:
|
||||
if not exclusions:
|
||||
return False
|
||||
|
||||
# Check date range
|
||||
if exclusion.start_date and check_date < exclusion.start_date:
|
||||
return False
|
||||
if exclusion.end_date and check_date > exclusion.end_date:
|
||||
return False
|
||||
# Check against all exclusions
|
||||
for exclusion in exclusions:
|
||||
# If any exclusion covers this date, user is excluded
|
||||
|
||||
# Check date range
|
||||
start_ok = True
|
||||
if exclusion.start_date and check_date < exclusion.start_date:
|
||||
start_ok = False
|
||||
|
||||
end_ok = True
|
||||
if exclusion.end_date and check_date > exclusion.end_date:
|
||||
end_ok = False
|
||||
|
||||
if start_ok and end_ok:
|
||||
return True
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_guarantee(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
|
||||
@@ -227,47 +213,45 @@ def get_users_wanting_parking(office_id: str, pool_date: date, db: Session) -> l
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
|
||||
"""
|
||||
Assign parking spots fairly based on parking ratio.
|
||||
Called after presence is set for a date.
|
||||
Returns {assigned: [...], waitlist: [...]}
|
||||
Creates new DailyParkingAssignment rows only for assigned users.
|
||||
"""
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office or not office.parking_quota:
|
||||
return {"assigned": [], "waitlist": []}
|
||||
|
||||
# No parking on closing days
|
||||
if is_closing_day(office_id, pool_date, db):
|
||||
return {"assigned": [], "waitlist": [], "closed": True}
|
||||
|
||||
# Initialize pool
|
||||
initialize_parking_pool(office_id, office.parking_quota, pool_date, db)
|
||||
return {"assigned": [], "waitlist": [], "closed": True}
|
||||
|
||||
# Get candidates sorted by fairness
|
||||
candidates = get_users_wanting_parking(office_id, pool_date, db)
|
||||
|
||||
# Get available spots
|
||||
free_spots = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.office_id == office_id,
|
||||
DailyParkingAssignment.date == pool_date,
|
||||
DailyParkingAssignment.user_id == None
|
||||
).all()
|
||||
|
||||
# Get available spots (OfficeSpots not yet in assignments table)
|
||||
free_spots = get_available_spots(office_id, pool_date, db)
|
||||
|
||||
assigned = []
|
||||
waitlist = []
|
||||
|
||||
for candidate in candidates:
|
||||
if free_spots:
|
||||
# Sort spots by number to fill A1, A2... order
|
||||
free_spots.sort(key=lambda s: s.spot_number)
|
||||
|
||||
spot = free_spots.pop(0)
|
||||
spot.user_id = candidate["user_id"]
|
||||
|
||||
# Create assignment
|
||||
assignment = DailyParkingAssignment(
|
||||
id=generate_uuid(),
|
||||
date=pool_date,
|
||||
spot_id=spot.id,
|
||||
user_id=candidate["user_id"],
|
||||
office_id=office_id,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
db.add(assignment)
|
||||
assigned.append(candidate["user_id"])
|
||||
else:
|
||||
waitlist.append(candidate["user_id"])
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"assigned": assigned, "waitlist": waitlist}
|
||||
|
||||
|
||||
@@ -281,15 +265,29 @@ def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session
|
||||
|
||||
if not assignment:
|
||||
return False
|
||||
|
||||
# Capture spot ID before deletion
|
||||
spot_id = assignment.spot_id
|
||||
|
||||
# Release the spot
|
||||
assignment.user_id = None
|
||||
# Release the spot (Delete the row)
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
|
||||
# Try to assign to next user in fairness queue
|
||||
candidates = get_users_wanting_parking(office_id, pool_date, db)
|
||||
if candidates:
|
||||
assignment.user_id = candidates[0]["user_id"]
|
||||
top_candidate = candidates[0]
|
||||
|
||||
# Create new assignment for top candidate
|
||||
new_assignment = DailyParkingAssignment(
|
||||
id=generate_uuid(),
|
||||
date=pool_date,
|
||||
spot_id=spot_id,
|
||||
user_id=top_candidate["user_id"],
|
||||
office_id=office_id,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
db.add(new_assignment)
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
@@ -309,8 +307,7 @@ def handle_presence_change(user_id: str, change_date: date, old_status: Presence
|
||||
if not office or not office.parking_quota:
|
||||
return
|
||||
|
||||
# Initialize pool if needed
|
||||
initialize_parking_pool(office.id, office.parking_quota, change_date, db)
|
||||
# No initialization needed for sparse model
|
||||
|
||||
if old_status == PresenceStatus.PRESENT and new_status in [PresenceStatus.REMOTE, PresenceStatus.ABSENT]:
|
||||
# User no longer coming - release their spot (will auto-reassign)
|
||||
@@ -344,13 +341,12 @@ def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Sessi
|
||||
"""
|
||||
assignments = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.office_id == office_id,
|
||||
DailyParkingAssignment.date == pool_date,
|
||||
DailyParkingAssignment.user_id != None
|
||||
DailyParkingAssignment.date == pool_date
|
||||
).all()
|
||||
|
||||
count = len(assignments)
|
||||
for a in assignments:
|
||||
a.user_id = None
|
||||
db.delete(a)
|
||||
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
@@ -18,7 +18,15 @@ security = HTTPBearer(auto_error=False)
|
||||
|
||||
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
|
||||
admin_group = config.AUTHELIA_ADMIN_GROUP
|
||||
is_admin = admin_group in groups
|
||||
|
||||
# Case-insensitive check fallback (just in case)
|
||||
if not is_admin:
|
||||
is_admin = admin_group.lower() in [g.lower() for g in groups]
|
||||
|
||||
print(f"[Authelia] Admin Check: User Groups={groups}, Configured Admin Group='{admin_group}' -> Is Admin? {is_admin}")
|
||||
return is_admin
|
||||
|
||||
|
||||
def get_or_create_authelia_user(
|
||||
@@ -43,19 +51,19 @@ def get_or_create_authelia_user(
|
||||
# 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()
|
||||
user.updated_at = datetime.utcnow()
|
||||
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()
|
||||
user.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
# Update name if changed
|
||||
if user.name != name and name:
|
||||
user.name = name
|
||||
user.updated_at = datetime.utcnow().isoformat()
|
||||
user.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
@@ -67,8 +75,8 @@ def get_or_create_authelia_user(
|
||||
name=name or email.split("@")[0],
|
||||
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()
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user