fix landing page

This commit is contained in:
Stefano Manfredi
2025-12-02 23:18:43 +00:00
parent 7168fa4b72
commit ce9e2fdf2a
17 changed files with 727 additions and 457 deletions

View File

@@ -1,105 +1,170 @@
"""
Notification Service
Handles email notifications for presence reminders and parking assignments
TODO: This service is NOT YET ACTIVE. To enable notifications:
1. Add APScheduler or similar to run run_scheduled_notifications() periodically
2. Configure SMTP environment variables (SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM)
3. Notifications will be sent for:
- Presence reminders (Thursday at 12:00)
- Weekly parking summary (Friday at 12:00)
- Daily parking reminders (at user's preferred time)
- Immediate parking change notifications (via queue)
Handles email notifications for presence reminders and parking assignments.
Follows org-stack pattern: direct SMTP send with file fallback when disabled.
"""
import smtplib
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
import uuid
from typing import TYPE_CHECKING
from database.models import (
User, UserPresence, DailyParkingAssignment,
NotificationLog, NotificationQueue
)
from services.parking import get_spot_display_name
from app import config
from utils.helpers import generate_uuid
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from database.models import User
# Email configuration (from environment variables)
SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM = os.getenv("SMTP_FROM", "noreply@parkingmanager.local")
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
def log_email_to_file(to: str, subject: str, body: str):
"""Log email to file when SMTP is disabled (org-stack pattern)"""
timestamp = datetime.now().isoformat()
try:
with open(config.EMAIL_LOG_FILE, 'a') as f:
f.write(f'\n{"="*80}\n')
f.write(f'Timestamp: {timestamp}\n')
f.write(f'To: {to}\n')
f.write(f'Subject: {subject}\n')
f.write(f'Body:\n{body}\n')
f.write(f'{"="*80}\n')
config.logger.info(f"[EMAIL] Logged to {config.EMAIL_LOG_FILE}: {to} - {subject}")
except Exception as e:
config.logger.error(f"[EMAIL] Failed to log email: {e}")
def send_email(to_email: str, subject: str, body_html: str, body_text: str = None):
"""Send an email"""
if not SMTP_USER or not SMTP_PASSWORD:
print(f"[NOTIFICATION] Email not configured. Would send to {to_email}: {subject}")
return False
def send_email(to_email: str, subject: str, body_html: str, body_text: str = None) -> bool:
"""
Send an email via SMTP or log to file if SMTP disabled.
Returns True if sent/logged successfully, False otherwise.
"""
# Extract plain text from HTML if not provided
if not body_text:
import re
body_text = re.sub('<[^<]+?>', '', body_html)
if not config.SMTP_ENABLED:
log_email_to_file(to_email, subject, body_text)
return True
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["From"] = config.SMTP_FROM
msg["To"] = to_email
if body_text:
msg.attach(MIMEText(body_text, "plain"))
msg.attach(MIMEText(body_text, "plain"))
msg.attach(MIMEText(body_html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
if SMTP_USE_TLS:
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT) as server:
if config.SMTP_USE_TLS:
server.starttls()
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(SMTP_FROM, to_email, msg.as_string())
if config.SMTP_USER and config.SMTP_PASSWORD:
server.login(config.SMTP_USER, config.SMTP_PASSWORD)
server.send_message(msg)
print(f"[NOTIFICATION] Email sent to {to_email}: {subject}")
config.logger.info(f"[EMAIL] Sent to {to_email}: {subject}")
return True
except Exception as e:
print(f"[NOTIFICATION] Failed to send email to {to_email}: {e}")
config.logger.error(f"[EMAIL] Failed to send to {to_email}: {e}")
# Fallback to file logging
log_email_to_file(to_email, subject, body_text)
return False
def get_week_dates(reference_date: datetime):
def get_week_dates(reference_date: datetime) -> list[datetime]:
"""Get Monday-Sunday dates for the week containing reference_date"""
# Find Monday of this week
monday = reference_date - timedelta(days=reference_date.weekday())
return [monday + timedelta(days=i) for i in range(7)]
def get_next_week_dates(reference_date: datetime):
def get_next_week_dates(reference_date: datetime) -> list[datetime]:
"""Get Monday-Sunday dates for the week after reference_date"""
# Find Monday of next week
days_until_next_monday = 7 - reference_date.weekday()
next_monday = reference_date + timedelta(days=days_until_next_monday)
return [next_monday + timedelta(days=i) for i in range(7)]
def check_week_presence_compiled(user_id: str, week_dates: list, db: Session) -> bool:
"""Check if user has filled presence for all working days in a week"""
date_strs = [d.strftime("%Y-%m-%d") for d in week_dates]
presences = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.date.in_(date_strs)
).all()
# Consider week compiled if at least 5 days have presence marked
# (allowing for weekends or holidays)
return len(presences) >= 5
def get_week_reference(date: datetime) -> str:
"""Get ISO week reference string (e.g., 2024-W48)"""
return date.strftime("%Y-W%W")
def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bool:
# =============================================================================
# Notification sending functions
# =============================================================================
def notify_parking_assigned(user: "User", date: str, spot_name: str):
"""Send notification when parking spot is assigned"""
if not user.notify_parking_changes:
return
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
subject = f"Parking spot assigned for {day_name}"
body_html = f"""
<html>
<body>
<h2>Parking Spot Assigned</h2>
<p>Hi {user.name},</p>
<p>You have been assigned a parking spot for {day_name}:</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
send_email(user.email, subject, body_html)
def notify_parking_released(user: "User", date: str, spot_name: str):
"""Send notification when parking spot is released"""
if not user.notify_parking_changes:
return
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
subject = f"Parking spot released for {day_name}"
body_html = f"""
<html>
<body>
<h2>Parking Spot Released</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been released.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
send_email(user.email, subject, body_html)
def notify_parking_reassigned(user: "User", date: str, spot_name: str, new_user_name: str):
"""Send notification when parking spot is reassigned to someone else"""
if not user.notify_parking_changes:
return
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
subject = f"Parking spot reassigned for {day_name}"
body_html = f"""
<html>
<body>
<h2>Parking Spot Reassigned</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been reassigned to {new_user_name}.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
send_email(user.email, subject, body_html)
def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "Session") -> bool:
"""Send presence compilation reminder for next week"""
from database.models import UserPresence, NotificationLog
week_ref = get_week_reference(next_week_dates[0])
# Check if already sent today for this week
@@ -112,11 +177,17 @@ def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bo
).first()
if existing:
return False # Already sent today
return False
# Check if week is compiled
if check_week_presence_compiled(user.id, next_week_dates, db):
return False # Already compiled
# Check if week is compiled (at least 5 days marked)
date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates]
presences = db.query(UserPresence).filter(
UserPresence.user_id == user.id,
UserPresence.date.in_(date_strs)
).all()
if len(presences) >= 5:
return False
# Send reminder
start_date = next_week_dates[0].strftime("%B %d")
@@ -137,9 +208,8 @@ def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bo
"""
if send_email(user.email, subject, body_html):
# Log the notification
log = NotificationLog(
id=str(uuid.uuid4()),
id=generate_uuid(),
user_id=user.id,
notification_type="presence_reminder",
reference_date=week_ref,
@@ -152,8 +222,11 @@ def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bo
return False
def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session) -> bool:
def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], 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
@@ -177,7 +250,7 @@ def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session)
).all()
if not assignments:
return False # No assignments, no need to notify
return False
# Build assignment list
assignment_lines = []
@@ -208,7 +281,7 @@ def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session)
if send_email(user.email, subject, body_html):
log = NotificationLog(
id=str(uuid.uuid4()),
id=generate_uuid(),
user_id=user.id,
notification_type="weekly_parking",
reference_date=week_ref,
@@ -221,8 +294,11 @@ def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session)
return False
def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool:
def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") -> bool:
"""Send daily parking reminder for a specific date"""
from database.models import DailyParkingAssignment, NotificationLog
from services.parking import get_spot_display_name
if not user.notify_daily_parking:
return False
@@ -245,10 +321,9 @@ def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool
).first()
if not assignment:
return False # No assignment today
return False
spot_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
day_name = date.strftime("%A, %B %d")
subject = f"Parking reminder for {day_name}"
@@ -266,7 +341,7 @@ def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool
if send_email(user.email, subject, body_html):
log = NotificationLog(
id=str(uuid.uuid4()),
id=generate_uuid(),
user_id=user.id,
notification_type="daily_parking",
reference_date=date_str,
@@ -279,92 +354,18 @@ def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool
return False
def queue_parking_change_notification(
user: User,
date: str,
change_type: str, # "assigned", "released", "reassigned"
spot_name: str,
new_user_name: str = None,
db: Session = None
):
"""Queue an immediate notification for a parking assignment change"""
if not user.notify_parking_changes:
return
def run_scheduled_notifications(db: "Session"):
"""
Run all scheduled notifications - called by a scheduler/cron job.
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
Schedule:
- Thursday at 12:00: Presence reminder for next week
- Friday at 12:00: Weekly parking summary
- Daily at user's preferred time: Daily parking reminder (Mon-Fri)
"""
from database.models import User
if change_type == "assigned":
subject = f"Parking spot assigned for {day_name}"
body = f"""
<html>
<body>
<h2>Parking Spot Assigned</h2>
<p>Hi {user.name},</p>
<p>You have been assigned a parking spot for {day_name}:</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
elif change_type == "released":
subject = f"Parking spot released for {day_name}"
body = f"""
<html>
<body>
<h2>Parking Spot Released</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been released.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
elif change_type == "reassigned":
subject = f"Parking spot reassigned for {day_name}"
body = f"""
<html>
<body>
<h2>Parking Spot Reassigned</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been reassigned to {new_user_name}.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
else:
return
# Add to queue
notification = NotificationQueue(
id=str(uuid.uuid4()),
user_id=user.id,
notification_type="parking_change",
subject=subject,
body=body,
created_at=datetime.now().isoformat()
)
db.add(notification)
db.commit()
def process_notification_queue(db: Session):
"""Process and send all pending notifications in the queue"""
pending = db.query(NotificationQueue).filter(
NotificationQueue.sent_at.is_(None)
).all()
for notification in pending:
user = db.query(User).filter(User.id == notification.user_id).first()
if user and send_email(user.email, notification.subject, notification.body):
notification.sent_at = datetime.now().isoformat()
db.commit()
def run_scheduled_notifications(db: Session):
"""Run all scheduled notifications - called by a scheduler/cron job"""
now = datetime.now()
today = now.date()
current_hour = now.hour
current_minute = now.minute
current_weekday = now.weekday() # 0=Monday, 6=Sunday
@@ -372,7 +373,7 @@ def run_scheduled_notifications(db: Session):
users = db.query(User).all()
for user in users:
# Thursday at 12: Presence reminder (unmanageable)
# Thursday at 12: Presence reminder
if current_weekday == 3 and current_hour == 12 and current_minute < 5:
next_week = get_next_week_dates(now)
send_presence_reminder(user, next_week, db)
@@ -388,6 +389,3 @@ def run_scheduled_notifications(db: Session):
user_minute = user.notify_daily_parking_minute or 0
if current_hour == user_hour and abs(current_minute - user_minute) < 5:
send_daily_parking_reminder(user, now, db)
# Process queued notifications
process_notification_queue(db)

View File

@@ -8,15 +8,16 @@ Key concepts:
- Spots are named like A1, A2, B1, B2 based on manager prefix
- Fairness: users with lowest parking_days/presence_days ratio get priority
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from sqlalchemy import or_
from database.models import (
DailyParkingAssignment, User, UserPresence,
ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay
)
from utils.helpers import generate_uuid
from app import config
def get_spot_prefix(manager: User, db: Session) -> str:
@@ -109,7 +110,7 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
for i in range(1, quota + 1):
spot = DailyParkingAssignment(
id=str(uuid.uuid4()),
id=generate_uuid(),
date=date,
spot_id=f"spot-{i}",
user_id=None,
@@ -119,6 +120,7 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
db.add(spot)
db.commit()
config.logger.debug(f"Initialized {quota} parking spots for manager {manager_id} on {date}")
return quota