[31988f42] Feat: Implemented live calendar check (race-condition prevention) and iframe-ready HTML responses for WP integration
This commit is contained in:
@@ -11,6 +11,7 @@ from threading import Thread, Lock
|
||||
import uvicorn
|
||||
import logging
|
||||
from fastapi import FastAPI, Response, BackgroundTasks
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
@@ -44,6 +45,8 @@ SessionLocal = init_db(f"sqlite:///{DB_FILE_PATH}")
|
||||
# --- Config ---
|
||||
TEAMS_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "")
|
||||
FEEDBACK_SERVER_BASE_URL = os.getenv("FEEDBACK_SERVER_BASE_URL", "http://localhost:8004")
|
||||
WORDPRESS_BOOKING_URL = os.getenv("WORDPRESS_BOOKING_URL", "")
|
||||
MS_BOOKINGS_URL = os.getenv("MS_BOOKINGS_URL", "https://outlook.office365.com/owa/calendar/IHR_BOOKINGS_NAME@robo-planet.de/bookings/")
|
||||
DEFAULT_WAIT_MINUTES = 5
|
||||
SENDER_EMAIL = os.getenv("SENDER_EMAIL", "info@robo-planet.de")
|
||||
TEST_RECEIVER_EMAIL = "floke.com@gmail.com"
|
||||
@@ -128,6 +131,36 @@ def find_slots(start, view, interval):
|
||||
break
|
||||
return slots
|
||||
|
||||
def is_slot_free(target_email: str, app_creds: tuple, target_time: datetime) -> bool:
|
||||
"""Checks via MS Graph API if a specific 15-minute slot is still free."""
|
||||
logging.info(f"Live-Checking slot availability for {target_email} at {target_time}")
|
||||
token = get_access_token(*app_creds)
|
||||
if not token:
|
||||
logging.warning("Failed to get token for live-check. Proceeding anyway.")
|
||||
return True # Fallback: assume free to not block booking
|
||||
|
||||
end_time = target_time + timedelta(minutes=15)
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Prefer": 'outlook.timezone="Europe/Berlin"'}
|
||||
payload = {
|
||||
"schedules": [target_email],
|
||||
"startTime": {"dateTime": target_time.isoformat(), "timeZone": "Europe/Berlin"},
|
||||
"endTime": {"dateTime": end_time.isoformat(), "timeZone": "Europe/Berlin"},
|
||||
"availabilityViewInterval": 15
|
||||
}
|
||||
try:
|
||||
url = f"{GRAPH_API_ENDPOINT}/users/{target_email}/calendar/getSchedule"
|
||||
r = requests.post(url, headers=headers, json=payload)
|
||||
if r.status_code == 200:
|
||||
view = r.json().get('value', [{}])[0].get('availabilityView', '')
|
||||
if view:
|
||||
logging.info(f"Live-Check view for {target_time}: '{view}'")
|
||||
return view[0] == '0' # '0' means free
|
||||
else:
|
||||
logging.error(f"Live-check failed with status {r.status_code}: {r.text}")
|
||||
except Exception as e:
|
||||
logging.error(f"Live-check threw an exception: {e}")
|
||||
return True # Fallback
|
||||
|
||||
def create_calendar_invite(lead_email, company, start_time):
|
||||
catchall = os.getenv("EMAIL_CATCHALL"); lead_email = catchall if catchall else lead_email
|
||||
token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
|
||||
@@ -182,14 +215,84 @@ def send_now(job_uuid: str):
|
||||
if job: job.status = "send_now"; db.commit(); db.close(); return Response("Wird gesendet.")
|
||||
db.close(); return Response("Not Found", 404)
|
||||
|
||||
SUCCESS_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; text-align: center; color: #333; padding: 40px 20px; background: transparent; }
|
||||
.icon { font-size: 48px; margin-bottom: 20px; }
|
||||
h2 { margin-bottom: 10px; color: #10b981; }
|
||||
p { font-size: 16px; line-height: 1.5; color: #4b5563; }
|
||||
.details { font-weight: bold; margin-top: 15px; color: #111827; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="icon">✅</div>
|
||||
<h2>Termin erfolgreich gebucht!</h2>
|
||||
<p>Vielen Dank für die Terminbuchung.</p>
|
||||
<p class="details">Wir bestätigen Ihren Termin am {date} um {time} Uhr.</p>
|
||||
<p>Sie erhalten in Kürze eine separate Kalendereinladung inkl. Microsoft Teams-Link an Ihre E-Mail-Adresse.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
FALLBACK_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; text-align: center; color: #333; margin: 0; padding: 0; background: transparent; }
|
||||
.message-box { padding: 20px; background-color: #fef3c7; color: #92400e; border-bottom: 1px solid #fcd34d; margin-bottom: 20px; }
|
||||
h3 { margin: 0 0 10px 0; font-size: 18px; }
|
||||
p { margin: 0; font-size: 14px; }
|
||||
.iframe-container { width: 100%; height: 800px; border: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="message-box">
|
||||
<h3>⚠️ Termin leider nicht mehr verfügbar</h3>
|
||||
<p>Der von Ihnen gewählte Termin wurde in der Zwischenzeit leider anderweitig vergeben.<br>
|
||||
Bitte wählen Sie direkt hier einen neuen, passenden Termin aus unserem Kalender:</p>
|
||||
</div>
|
||||
<!-- BITTE DEN ECHTEN MS BOOKINGS LINK EINTRAGEN -->
|
||||
<iframe class="iframe-container" src="{ms_bookings_url}" scrolling="yes"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@app.get("/book_slot/{job_uuid}/{ts}")
|
||||
def book_slot(job_uuid: str, ts: int):
|
||||
slot_time = datetime.fromtimestamp(ts, tz=TZ_BERLIN)
|
||||
db = SessionLocal(); job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first()
|
||||
if not job or job.status == "booked": db.close(); return Response("Fehler.", 400)
|
||||
db = SessionLocal()
|
||||
job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first()
|
||||
|
||||
if not job or job.status == "booked":
|
||||
db.close()
|
||||
# If job doesn't exist or is already booked, show the fallback calendar to allow a new booking safely
|
||||
return HTMLResponse(content=FALLBACK_HTML.format(ms_bookings_url=MS_BOOKINGS_URL))
|
||||
|
||||
# LIVE CHECK: Is the slot still free in the calendar?
|
||||
app_creds = (CAL_APPID, CAL_SECRET, CAL_TENNANT_ID)
|
||||
if not is_slot_free("e.melcer@robo-planet.de", app_creds, slot_time):
|
||||
logging.warning(f"RACE CONDITION PREVENTED: Slot {slot_time} for job {job_uuid} is taken!")
|
||||
db.close()
|
||||
return HTMLResponse(content=FALLBACK_HTML.format(ms_bookings_url=MS_BOOKINGS_URL))
|
||||
|
||||
if create_calendar_invite(job.customer_email, job.customer_company, slot_time):
|
||||
job.status = "booked"; db.commit(); db.close(); return Response(f"Gebucht!")
|
||||
db.close(); return Response("Fehler bei Kalender.", 500)
|
||||
job.status = "booked"
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
date_str = slot_time.strftime('%d.%m.%Y')
|
||||
time_str = slot_time.strftime('%H:%M')
|
||||
html_content = SUCCESS_HTML.format(date=date_str, time=time_str)
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
db.close()
|
||||
return HTMLResponse(content="<p>Es gab einen internen Fehler bei der Kalenderbuchung. Bitte versuchen Sie es später erneut.</p>", status_code=500)
|
||||
|
||||
# --- Workflow Logic ---
|
||||
def send_email(subject, body, to_email):
|
||||
@@ -301,7 +404,11 @@ def process_lead(request_id, company, opener, receiver, name):
|
||||
logging.info(f"Timeout reached or 'Send Now' clicked for job {request_id}. Proceeding to send email.")
|
||||
booking_html = "<ul>"
|
||||
for s in suggestions:
|
||||
booking_html += f'<li><a href="{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{int(s.timestamp())}">{format_date_for_email(s)}</a></li>'
|
||||
if WORDPRESS_BOOKING_URL:
|
||||
link = f"{WORDPRESS_BOOKING_URL}?job_uuid={request_id}&ts={int(s.timestamp())}"
|
||||
else:
|
||||
link = f"{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{int(s.timestamp())}"
|
||||
booking_html += f'<li><a href="{link}">{format_date_for_email(s)}</a></li>'
|
||||
booking_html += "</ul>"
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user