diff --git a/app/routes/offices.py b/app/routes/offices.py index 553d9d1..3aa0a63 100644 --- a/app/routes/offices.py +++ b/app/routes/offices.py @@ -26,6 +26,9 @@ router = APIRouter(prefix="/api/offices", tags=["offices"]) class ValidOfficeCreate(BaseModel): name: str parking_quota: int = 0 + booking_window_enabled: bool = True + booking_window_end_hour: int = 18 + booking_window_end_minute: int = 0 class ClosingDayCreate(BaseModel): @@ -121,6 +124,9 @@ def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=D name=data.name, parking_quota=data.parking_quota, spot_prefix=get_next_available_prefix(db), + booking_window_enabled=data.booking_window_enabled, + booking_window_end_hour=data.booking_window_end_hour, + booking_window_end_minute=data.booking_window_end_minute, created_at=datetime.utcnow() ) db.add(office) @@ -180,12 +186,12 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi if data.booking_window_end_hour is not None: if not (0 <= data.booking_window_end_hour <= 23): - raise HTTPException(status_code=400, detail="Hour must be 0-23") + raise HTTPException(status_code=400, detail="Hour must be 0-23") office.booking_window_end_hour = data.booking_window_end_hour if data.booking_window_end_minute is not None: if not (0 <= data.booking_window_end_minute <= 59): - raise HTTPException(status_code=400, detail="Minute must be 0-59") + raise HTTPException(status_code=400, detail="Minute must be 0-59") office.booking_window_end_minute = data.booking_window_end_minute office.updated_at = datetime.utcnow() diff --git a/compose.yml b/compose.yml index db9b882..555614d 100644 --- a/compose.yml +++ b/compose.yml @@ -26,10 +26,10 @@ services: networks: - org-network labels: - - "caddy=parking.lvh.me" + - "caddy=parcheggio.lvh.me" - "caddy.reverse_proxy={{upstreams 8000}}" - "caddy.forward_auth=authelia:9091" - - "caddy.forward_auth.uri=/api/verify?rd=https://parking.lvh.me/" + - "caddy.forward_auth.uri=/api/verify?rd=https://parcheggio.lvh.me/" - "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email" # cambiare l'url delle label per il reverse proxy diff --git a/database/models.py b/database/models.py index 76d0494..8eae5cb 100644 --- a/database/models.py +++ b/database/models.py @@ -241,15 +241,7 @@ class NotificationLog(Base): ) - 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) - created_at = Column(DateTime, default=datetime.utcnow) - sent_at = Column(DateTime) # null = not sent yet - __table_args__ = ( - Index('idx_queue_pending', 'sent_at'), - ) class OfficeSpot(Base): diff --git a/frontend/assets/parking-map.png b/frontend/assets/parking-map.png index b4669c3..8793d17 100644 Binary files a/frontend/assets/parking-map.png and b/frontend/assets/parking-map.png differ diff --git a/frontend/js/admin-offices.js b/frontend/js/admin-offices.js index 87746d1..d0fda6e 100644 --- a/frontend/js/admin-offices.js +++ b/frontend/js/admin-offices.js @@ -17,8 +17,36 @@ document.addEventListener('DOMContentLoaded', async () => { await loadOffices(); setupEventListeners(); + populateTimeSelects(); }); +function populateTimeSelects() { + const hoursSelect = document.getElementById('officeCutoffHour'); + const minutesSelect = document.getElementById('officeCutoffMinute'); + + // Clear existing + hoursSelect.innerHTML = ''; + minutesSelect.innerHTML = ''; // Re-creating to allow 0-59 range + + // Populate Hours 0-23 + for (let i = 0; i < 24; i++) { + const val = i.toString().padStart(2, '0'); + const opt = document.createElement('option'); + opt.value = i; + opt.textContent = val; + hoursSelect.appendChild(opt); + } + + // Populate Minutes 0-59 + for (let i = 0; i < 60; i++) { + const val = i.toString().padStart(2, '0'); + const opt = document.createElement('option'); + opt.value = i; + opt.textContent = val; + minutesSelect.appendChild(opt); + } +} + async function loadOffices() { const response = await api.get('/api/offices'); if (response && response.ok) { @@ -70,6 +98,11 @@ async function editOffice(officeId) { document.getElementById('officeName').value = office.name; document.getElementById('officeQuota').value = office.parking_quota; + // Set booking window settings + document.getElementById('officeWindowEnabled').checked = office.booking_window_enabled !== false; + document.getElementById('officeCutoffHour').value = office.booking_window_end_hour != null ? office.booking_window_end_hour : 18; + document.getElementById('officeCutoffMinute').value = office.booking_window_end_minute != null ? office.booking_window_end_minute : 0; + openModal('Modifica Ufficio'); } @@ -123,7 +156,10 @@ async function handleOfficeSubmit(e) { const officeId = document.getElementById('officeId').value; const data = { name: document.getElementById('officeName').value, - parking_quota: parseInt(document.getElementById('officeQuota').value) || 0 + parking_quota: parseInt(document.getElementById('officeQuota').value) || 0, + booking_window_enabled: document.getElementById('officeWindowEnabled').checked, + booking_window_end_hour: parseInt(document.getElementById('officeCutoffHour').value), + booking_window_end_minute: parseInt(document.getElementById('officeCutoffMinute').value) }; console.log('Payload:', data); diff --git a/frontend/js/parking-settings.js b/frontend/js/parking-settings.js index d357d0d..b5396a1 100644 --- a/frontend/js/parking-settings.js +++ b/frontend/js/parking-settings.js @@ -72,6 +72,15 @@ function populateHourSelect() { option.textContent = h.toString().padStart(2, '0'); select.appendChild(option); } + + const minuteSelect = document.getElementById('bookingWindowMinute'); + minuteSelect.innerHTML = ''; + for (let m = 0; m < 60; m++) { + const option = document.createElement('option'); + option.value = m; + option.textContent = m.toString().padStart(2, '0'); + minuteSelect.appendChild(option); + } } async function loadOfficeSettings(id) { diff --git a/frontend/pages/admin-offices.html b/frontend/pages/admin-offices.html index f3b9eb4..282fa7b 100644 --- a/frontend/pages/admin-offices.html +++ b/frontend/pages/admin-offices.html @@ -93,6 +93,27 @@ Numero totale di posti auto assegnati a questo ufficio +
+ +
+ + : + + +
+ Orario limite per la prenotazione del giorno successivo +
+
diff --git a/frontend/pages/parking-settings.html b/frontend/pages/parking-settings.html index 67f0b18..7e3cc89 100644 --- a/frontend/pages/parking-settings.html +++ b/frontend/pages/parking-settings.html @@ -88,10 +88,7 @@ :
Le presenze inserite prima di questo orario saranno messe in @@ -159,7 +156,8 @@ - diff --git a/main.py b/main.py index 5f9d12c..948fdd9 100644 --- a/main.py +++ b/main.py @@ -24,6 +24,7 @@ from app.routes.parking import router as parking_router from app.routes.parking import router as parking_router from database.connection import init_db, get_db_session from services.notifications import run_scheduled_notifications +from services.parking import process_daily_allocations import asyncio # Rate limiter setup @@ -37,6 +38,7 @@ async def scheduler_task(): try: with get_db_session() as db: run_scheduled_notifications(db) + process_daily_allocations(db) except Exception as e: config.logger.error(f"Scheduler error: {e}") diff --git a/services/notifications.py b/services/notifications.py index f24c46b..dabe83d 100644 --- a/services/notifications.py +++ b/services/notifications.py @@ -228,7 +228,10 @@ def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session") from database.models import DailyParkingAssignment, NotificationLog from services.parking import get_spot_display_name + config.logger.info(f"[SCHEDULER] Checking daily parking reminder for user {user.email}") + if not user.notify_daily_parking: + config.logger.debug(f"[SCHEDULER] User {user.email} has disabled daily parking notifications") return False date_str = date_obj.strftime("%Y-%m-%d") @@ -308,10 +311,10 @@ def run_scheduled_notifications(db: "Session"): users = db.query(User).all() for user in users: - # Thursday at 12: Presence reminder - if current_weekday == 3 and current_hour == 12 and current_minute < 5: - next_week = get_next_week_dates(today_date) - send_presence_reminder(user, next_week, db) + # Thursday Reminder: DISABLED as per user request + # if current_weekday == 3 and current_hour == 12 and current_minute < 5: + # next_week = get_next_week_dates(today_date) + # send_presence_reminder(user, next_week, db) # Daily parking reminder at user's preferred time user_hour = user.notify_daily_parking_hour or 8 @@ -319,6 +322,7 @@ def run_scheduled_notifications(db: "Session"): # Check if it's the right time for this user if current_hour == user_hour and abs(current_minute - user_minute) < 5: + config.logger.info(f"[SCHEDULER] Triggering Daily Parking Reminder check for user {user.email} (Scheduled: {user_hour}:{user_minute})") # Check if Office is OPEN today is_office_open = True diff --git a/services/parking.py b/services/parking.py index 8eafe54..a14d763 100644 --- a/services/parking.py +++ b/services/parking.py @@ -19,6 +19,7 @@ from database.models import ( ) from utils.helpers import generate_uuid from app import config +from services.notifications import notify_parking_assigned def get_spot_prefix(office: Office, db: Session) -> str: @@ -252,6 +253,20 @@ def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict: waitlist.append(candidate["user_id"]) db.commit() + + # Send notifications to successful assignees + for user_id in assigned: + user = db.query(User).filter(User.id == user_id).first() + if user: + # Re-fetch the assignment to get the spot details + assignment = db.query(DailyParkingAssignment).filter( + DailyParkingAssignment.user_id == user_id, + DailyParkingAssignment.date == pool_date + ).first() + if assignment: + spot_name = get_spot_display_name(assignment.spot_id, office_id, db) + notify_parking_assigned(user, pool_date, spot_name) + return {"assigned": assigned, "waitlist": waitlist} @@ -317,15 +332,20 @@ def handle_presence_change(user_id: str, change_date: date, old_status: Presence # Check booking window should_assign = True if office.booking_window_enabled: + from zoneinfo import ZoneInfo + tz = ZoneInfo(config.TIMEZONE) + now = datetime.now(tz) + # Allocation time is Day-1 at cutoff hour cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time()) cutoff_dt = cutoff_dt.replace( hour=office.booking_window_end_hour, - minute=office.booking_window_end_minute + minute=office.booking_window_end_minute, + tzinfo=tz ) # If now is before cutoff, do not assign yet (wait for batch job) - if datetime.utcnow() < cutoff_dt: + if now < cutoff_dt: should_assign = False config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})") @@ -362,3 +382,35 @@ def run_batch_allocation(office_id: str, pool_date: date, db: Session) -> dict: # 2. Run fair allocation return assign_parking_fairly(office_id, pool_date, db) + + +def process_daily_allocations(db: Session): + """ + Check if any office's booking window has just closed and run batch allocation. + Run by scheduler every minute. + HALT: Checks if the cutoff time for TOMORROW has been reached. + """ + from zoneinfo import ZoneInfo + + # Use configured timezone + tz = ZoneInfo(config.TIMEZONE) + now = datetime.now(tz) + + # Check all offices with window enabled + offices = db.query(Office).filter(Office.booking_window_enabled == True).all() + + config.logger.debug(f"[SCHEDULER] Checking booking windows for {len(offices)} offices - Current Time: {now.strftime('%H:%M')}") + + for office in offices: + # Cutoff is defined as "Previous Day" (today) at Booking End Hour + # If NOW matches the cutoff time, we run allocation for TOMORROW + if now.hour == office.booking_window_end_hour and now.minute == office.booking_window_end_minute: + target_date = now.date() + timedelta(days=1) + config.logger.info(f"[SCHEDULER] CUTOFF REACHED for {office.name} (Cutoff: {office.booking_window_end_hour}:{office.booking_window_end_minute}). Starting Assegnazione Giornaliera parcheggi for {target_date}") + + try: + run_batch_allocation(office.id, target_date, db) + config.logger.info(f"[SCHEDULER] Assegnazione Giornaliera parcheggi completed for {office.name} on {target_date}") + except Exception as e: + config.logger.error(f"[SCHEDULER] Failed Assegnazione Giornaliera parcheggi for {office.name}: {e}") + diff --git a/upgrade_db_v1.py b/upgrade_db_v1.py new file mode 100644 index 0000000..501f762 --- /dev/null +++ b/upgrade_db_v1.py @@ -0,0 +1,48 @@ +import sqlite3 +import os +import sys + +# Default path inside Docker container +DEFAULT_DB_PATH = "/app/data/parking.db" + +def migrate(): + # Allow overriding DB path via env var or argument + db_path = os.getenv("DATABASE_PATH", DEFAULT_DB_PATH) + if len(sys.argv) > 1: + db_path = sys.argv[1] + + if not os.path.exists(db_path): + print(f"Error: Database file not found at {db_path}") + print("Usage: python upgrade_db_v1.py [path_to_db]") + sys.exit(1) + + print(f"Migrating database at: {db_path}") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + columns_to_add = [ + ("booking_window_enabled", "BOOLEAN", "0"), + ("booking_window_end_hour", "INTEGER", "18"), + ("booking_window_end_minute", "INTEGER", "0") + ] + + for col_name, col_type, default_val in columns_to_add: + try: + # Check if column exists + cursor.execute(f"SELECT {col_name} FROM offices LIMIT 1") + except sqlite3.OperationalError: + # Column doesn't exist, add it + print(f"Adding column: {col_name} ({col_type})") + try: + cursor.execute(f"ALTER TABLE offices ADD COLUMN {col_name} {col_type} DEFAULT {default_val}") + except Exception as e: + print(f"Failed to add column {col_name}: {e}") + else: + print(f"Column {col_name} already exists. Skipping.") + + conn.commit() + conn.close() + print("Migration complete.") + +if __name__ == "__main__": + migrate()