fix scheduler orario start, immagine parcheggio, added more logs function
This commit is contained in:
@@ -26,6 +26,9 @@ router = APIRouter(prefix="/api/offices", tags=["offices"])
|
|||||||
class ValidOfficeCreate(BaseModel):
|
class ValidOfficeCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
parking_quota: int = 0
|
parking_quota: int = 0
|
||||||
|
booking_window_enabled: bool = True
|
||||||
|
booking_window_end_hour: int = 18
|
||||||
|
booking_window_end_minute: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ClosingDayCreate(BaseModel):
|
class ClosingDayCreate(BaseModel):
|
||||||
@@ -121,6 +124,9 @@ def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=D
|
|||||||
name=data.name,
|
name=data.name,
|
||||||
parking_quota=data.parking_quota,
|
parking_quota=data.parking_quota,
|
||||||
spot_prefix=get_next_available_prefix(db),
|
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()
|
created_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
db.add(office)
|
db.add(office)
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- org-network
|
- org-network
|
||||||
labels:
|
labels:
|
||||||
- "caddy=parking.lvh.me"
|
- "caddy=parcheggio.lvh.me"
|
||||||
- "caddy.reverse_proxy={{upstreams 8000}}"
|
- "caddy.reverse_proxy={{upstreams 8000}}"
|
||||||
- "caddy.forward_auth=authelia:9091"
|
- "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"
|
- "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email"
|
||||||
# cambiare l'url delle label per il reverse proxy
|
# cambiare l'url delle label per il reverse proxy
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
class OfficeSpot(Base):
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 786 KiB After Width: | Height: | Size: 829 KiB |
@@ -17,8 +17,36 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
await loadOffices();
|
await loadOffices();
|
||||||
setupEventListeners();
|
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() {
|
async function loadOffices() {
|
||||||
const response = await api.get('/api/offices');
|
const response = await api.get('/api/offices');
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
@@ -70,6 +98,11 @@ async function editOffice(officeId) {
|
|||||||
document.getElementById('officeName').value = office.name;
|
document.getElementById('officeName').value = office.name;
|
||||||
document.getElementById('officeQuota').value = office.parking_quota;
|
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');
|
openModal('Modifica Ufficio');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +156,10 @@ async function handleOfficeSubmit(e) {
|
|||||||
const officeId = document.getElementById('officeId').value;
|
const officeId = document.getElementById('officeId').value;
|
||||||
const data = {
|
const data = {
|
||||||
name: document.getElementById('officeName').value,
|
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);
|
console.log('Payload:', data);
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ function populateHourSelect() {
|
|||||||
option.textContent = h.toString().padStart(2, '0');
|
option.textContent = h.toString().padStart(2, '0');
|
||||||
select.appendChild(option);
|
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) {
|
async function loadOfficeSettings(id) {
|
||||||
|
|||||||
@@ -93,6 +93,27 @@
|
|||||||
<small class="text-muted">Numero totale di posti auto assegnati a questo ufficio</small>
|
<small class="text-muted">Numero totale di posti auto assegnati a questo ufficio</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Orario di Cut-off (Giorno Precedente)</label>
|
||||||
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
|
<select id="officeCutoffHour" class="form-control" style="width: 80px;">
|
||||||
|
<!-- Hours 0-23 generated by JS -->
|
||||||
|
</select>
|
||||||
|
<span>:</span>
|
||||||
|
<select id="officeCutoffMinute" class="form-control" style="width: 80px;">
|
||||||
|
<option value="0">00</option>
|
||||||
|
<option value="15">15</option>
|
||||||
|
<option value="30">30</option>
|
||||||
|
<option value="45">45</option>
|
||||||
|
</select>
|
||||||
|
<label style="margin-left: 10px; display: flex; align-items: center; gap: 5px;">
|
||||||
|
<input type="checkbox" id="officeWindowEnabled">
|
||||||
|
Abilita Assegnazione Automatica
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Orario limite per la prenotazione del giorno successivo</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
|
|||||||
@@ -88,10 +88,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<span>:</span>
|
<span>:</span>
|
||||||
<select id="bookingWindowMinute" style="width: 80px;">
|
<select id="bookingWindowMinute" style="width: 80px;">
|
||||||
<option value="0">00</option>
|
<!-- Populated by JS -->
|
||||||
<option value="15">15</option>
|
|
||||||
<option value="30">30</option>
|
|
||||||
<option value="45">45</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">Le presenze inserite prima di questo orario saranno messe in
|
<small class="text-muted">Le presenze inserite prima di questo orario saranno messe in
|
||||||
@@ -159,7 +156,8 @@
|
|||||||
<button id="testEmailBtn" class="btn btn-secondary">
|
<button id="testEmailBtn" class="btn btn-secondary">
|
||||||
Test (Solo a Me)
|
Test (Solo a Me)
|
||||||
</button>
|
</button>
|
||||||
<button id="bulkEmailBtn" class="btn btn-warning" title="Invia mail reale a tutti gli assegnatari">
|
<button id="bulkEmailBtn" class="btn btn-warning"
|
||||||
|
title="Invia mail reale a tutti gli assegnatari">
|
||||||
Test (A Tutti)
|
Test (A Tutti)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
2
main.py
2
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 app.routes.parking import router as parking_router
|
||||||
from database.connection import init_db, get_db_session
|
from database.connection import init_db, get_db_session
|
||||||
from services.notifications import run_scheduled_notifications
|
from services.notifications import run_scheduled_notifications
|
||||||
|
from services.parking import process_daily_allocations
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
# Rate limiter setup
|
# Rate limiter setup
|
||||||
@@ -37,6 +38,7 @@ async def scheduler_task():
|
|||||||
try:
|
try:
|
||||||
with get_db_session() as db:
|
with get_db_session() as db:
|
||||||
run_scheduled_notifications(db)
|
run_scheduled_notifications(db)
|
||||||
|
process_daily_allocations(db)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
config.logger.error(f"Scheduler error: {e}")
|
config.logger.error(f"Scheduler error: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,10 @@ def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session")
|
|||||||
from database.models import DailyParkingAssignment, NotificationLog
|
from database.models import DailyParkingAssignment, NotificationLog
|
||||||
from services.parking import get_spot_display_name
|
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:
|
if not user.notify_daily_parking:
|
||||||
|
config.logger.debug(f"[SCHEDULER] User {user.email} has disabled daily parking notifications")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
date_str = date_obj.strftime("%Y-%m-%d")
|
date_str = date_obj.strftime("%Y-%m-%d")
|
||||||
@@ -308,10 +311,10 @@ def run_scheduled_notifications(db: "Session"):
|
|||||||
users = db.query(User).all()
|
users = db.query(User).all()
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
# Thursday at 12: Presence reminder
|
# Thursday Reminder: DISABLED as per user request
|
||||||
if current_weekday == 3 and current_hour == 12 and current_minute < 5:
|
# if current_weekday == 3 and current_hour == 12 and current_minute < 5:
|
||||||
next_week = get_next_week_dates(today_date)
|
# next_week = get_next_week_dates(today_date)
|
||||||
send_presence_reminder(user, next_week, db)
|
# send_presence_reminder(user, next_week, db)
|
||||||
|
|
||||||
# Daily parking reminder at user's preferred time
|
# Daily parking reminder at user's preferred time
|
||||||
user_hour = user.notify_daily_parking_hour or 8
|
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
|
# Check if it's the right time for this user
|
||||||
if current_hour == user_hour and abs(current_minute - user_minute) < 5:
|
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
|
# Check if Office is OPEN today
|
||||||
is_office_open = True
|
is_office_open = True
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from database.models import (
|
|||||||
)
|
)
|
||||||
from utils.helpers import generate_uuid
|
from utils.helpers import generate_uuid
|
||||||
from app import config
|
from app import config
|
||||||
|
from services.notifications import notify_parking_assigned
|
||||||
|
|
||||||
|
|
||||||
def get_spot_prefix(office: Office, db: Session) -> str:
|
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"])
|
waitlist.append(candidate["user_id"])
|
||||||
|
|
||||||
db.commit()
|
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}
|
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
|
# Check booking window
|
||||||
should_assign = True
|
should_assign = True
|
||||||
if office.booking_window_enabled:
|
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
|
# Allocation time is Day-1 at cutoff hour
|
||||||
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
|
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
|
||||||
cutoff_dt = cutoff_dt.replace(
|
cutoff_dt = cutoff_dt.replace(
|
||||||
hour=office.booking_window_end_hour,
|
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 now is before cutoff, do not assign yet (wait for batch job)
|
||||||
if datetime.utcnow() < cutoff_dt:
|
if now < cutoff_dt:
|
||||||
should_assign = False
|
should_assign = False
|
||||||
config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})")
|
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
|
# 2. Run fair allocation
|
||||||
return assign_parking_fairly(office_id, pool_date, db)
|
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}")
|
||||||
|
|
||||||
|
|||||||
48
upgrade_db_v1.py
Normal file
48
upgrade_db_v1.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user