aggiunti trasferte, export excel, miglioramenti generali
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user