[30388f42] Infrastructure Hardening: Repaired CE/Connector DB schema, fixed frontend styling build, implemented robust echo shield in worker v2.1.1, and integrated Lead Engine into gateway.

This commit is contained in:
2026-03-07 14:08:42 +00:00
parent efcaa57cf0
commit ae2303b733
404 changed files with 24100 additions and 13301 deletions

View File

View File

@@ -0,0 +1,58 @@
from flask import Flask, request, jsonify, render_template_string
from .manager import TradingTwinsManager
app = Flask(__name__)
manager = TradingTwinsManager()
# Einfaches HTML-Template für Feedback
HTML_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<title>Trading Twins Status</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 50px; }
.success { color: green; font-size: 24px; }
.cancelled { color: red; font-size: 24px; }
.info { color: gray; margin-top: 20px; }
</style>
</head>
<body>
<h1>{title}</h1>
<p class="{status_class}">{message}</p>
<p class="info">Job ID: {job_uuid}</p>
</body>
</html>
"""
@app.route('/action/approve/<job_uuid>', methods=['GET'])
def approve_job(job_uuid):
current_status = manager.get_job_status(job_uuid)
if not current_status:
return render_template_string(HTML_TEMPLATE, title="Fehler", status_class="cancelled", message="Job nicht gefunden.", job_uuid=job_uuid), 404
if current_status == 'pending':
manager.update_job_status(job_uuid, 'approved')
# TODO: Hier würde der E-Mail-Versand sofort getriggert werden (Phase 3)
return render_template_string(HTML_TEMPLATE, title="Erfolg", status_class="success", message="✅ E-Mail wird jetzt versendet!", job_uuid=job_uuid)
elif current_status == 'approved':
return render_template_string(HTML_TEMPLATE, title="Info", status_class="success", message="⚠️ Job wurde bereits genehmigt.", job_uuid=job_uuid)
else:
return render_template_string(HTML_TEMPLATE, title="Info", status_class="cancelled", message=f"Job-Status ist bereits: {current_status}", job_uuid=job_uuid)
@app.route('/action/cancel/<job_uuid>', methods=['GET'])
def cancel_job(job_uuid):
current_status = manager.get_job_status(job_uuid)
if not current_status:
return render_template_string(HTML_TEMPLATE, title="Fehler", status_class="cancelled", message="Job nicht gefunden.", job_uuid=job_uuid), 404
if current_status == 'pending':
manager.update_job_status(job_uuid, 'cancelled')
return render_template_string(HTML_TEMPLATE, title="Abbruch", status_class="cancelled", message="❌ E-Mail-Versand gestoppt.", job_uuid=job_uuid)
else:
return render_template_string(HTML_TEMPLATE, title="Info", status_class="info", message=f"Job konnte nicht gestoppt werden (Status: {current_status}).", job_uuid=job_uuid)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8004)

View File

@@ -0,0 +1,85 @@
import os
import msal
import requests
import base64
# Graph API Konfiguration (aus .env laden)
CLIENT_ID = os.getenv("AZURE_CLIENT_ID")
CLIENT_SECRET = os.getenv("AZURE_CLIENT_SECRET")
TENANT_ID = os.getenv("AZURE_TENANT_ID")
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
SCOPE = ["https://graph.microsoft.com/.default"]
SENDER_EMAIL = "info@robo-planet.de"
def get_access_token():
"""Holt ein Token für die Graph API."""
app = msal.ConfidentialClientApplication(
CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET
)
result = app.acquire_token_for_client(scopes=SCOPE)
if "access_token" in result:
return result["access_token"]
else:
raise Exception(f"Fehler beim Abrufen des Tokens: {result.get('error_description')}")
def send_email_via_graph(to_email, subject, body_html, banner_path=None):
"""
Sendet eine E-Mail über die Microsoft Graph API.
"""
token = get_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
# E-Mail Struktur für Graph API
email_msg = {
"message": {
"subject": subject,
"body": {
"contentType": "HTML",
"content": body_html
},
"toRecipients": [
{
"emailAddress": {
"address": to_email
}
}
],
"from": {
"emailAddress": {
"address": SENDER_EMAIL
}
}
},
"saveToSentItems": "true"
}
# Optional: Banner-Bild als Inline-Attachment einfügen
if banner_path and os.path.exists(banner_path):
with open(banner_path, "rb") as f:
content_bytes = f.read()
content_b64 = base64.b64encode(content_bytes).decode("utf-8")
email_msg["message"]["attachments"] = [
{
"@odata.type": "#microsoft.graph.fileAttachment",
"name": "banner.png",
"contentBytes": content_b64,
"isInline": True,
"contentId": "banner_image"
}
]
endpoint = f"https://graph.microsoft.com/v1.0/users/{SENDER_EMAIL}/sendMail"
response = requests.post(endpoint, headers=headers, json=email_msg)
if response.status_code == 202:
print(f"E-Mail erfolgreich an {to_email} gesendet.")
return True
else:
print(f"Fehler beim Senden: {response.status_code} - {response.text}")
return False

View File

@@ -0,0 +1,318 @@
# lead-engine/trading_twins/manager.py
from email.mime.text import MIMEText
import base64
import requests
import json
import os
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from threading import Thread, Lock
import uvicorn
from fastapi import FastAPI, Response
import msal
# --- Zeitzonen-Konfiguration ---
TZ_BERLIN = ZoneInfo("Europe/Berlin")
# --- Konfiguration ---
TEAMS_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "https://wacklergroup.webhook.office.com/webhookb2/fe728cde-790c-4190-b1d3-be393ca0f9bd@6d85a9ef-3878-420b-8f43-38d6cb12b665/IncomingWebhook/e9a8ee6157594a6cab96048cf2ea2232/d26033cd-a81f-41a6-8cd2-b4a3ba0b5a01/V2WFmjcbkMzSU4f6lDSdUOM9VNm7F7n1Th4YDiu3fLZ_Y1")
# Öffentliche URL für Feedback-Links
FEEDBACK_SERVER_BASE_URL = os.getenv("FEEDBACK_SERVER_BASE_URL", "https://floke-ai.duckdns.org/feedback")
DEFAULT_WAIT_MINUTES = 5
SENDER_EMAIL = os.getenv("SENDER_EMAIL", "info@robo-planet.de")
TEST_RECEIVER_EMAIL = "floke.com@gmail.com" # Für E2E Tests
SIGNATURE_FILE_PATH = "/app/trading_twins/signature.html"
# Credentials für die Haupt-App (E-Mail & Kalender info@)
AZURE_CLIENT_ID = os.getenv("INFO_Application_ID")
AZURE_CLIENT_SECRET = os.getenv("INFO_Secret")
AZURE_TENANT_ID = os.getenv("INFO_Tenant_ID")
# Credentials für die Kalender-Lese-App (e.melcer)
CAL_APPID = os.getenv("CAL_APPID")
CAL_SECRET = os.getenv("CAL_SECRET")
CAL_TENNANT_ID = os.getenv("CAL_TENNANT_ID")
GRAPH_API_ENDPOINT = "https://graph.microsoft.com/v1.0"
# --- In-Memory-Speicher ---
# Wir speichern hier Details zu jeder Anfrage, um beim Klick auf den Slot reagieren zu können.
request_status_storage = {}
_lock = Lock()
# --- Auth Helper ---
def get_access_token(client_id, client_secret, tenant_id):
if not all([client_id, client_secret, tenant_id]):
return None
authority = f"https://login.microsoftonline.com/{tenant_id}"
app = msal.ConfidentialClientApplication(client_id=client_id, authority=authority, client_credential=client_secret)
result = app.acquire_token_silent(["https://graph.microsoft.com/.default"], account=None)
if not result:
result = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
return result.get('access_token')
# --- KALENDER LOGIK ---
def get_availability(target_email: str, app_creds: tuple) -> tuple:
"""Holt die Verfügbarkeit für eine E-Mail über die angegebene App."""
token = get_access_token(*app_creds)
if not token: return None
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Prefer": 'outlook.timezone="Europe/Berlin"'}
# Basis: Heute 00:00 Uhr
start_time = datetime.now(TZ_BERLIN).replace(hour=0, minute=0, second=0, microsecond=0)
end_time = start_time + timedelta(days=3) # 3 Tage Vorschau
payload = {
"schedules": [target_email],
"startTime": {"dateTime": start_time.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin"},
"endTime": {"dateTime": end_time.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin"},
"availabilityViewInterval": 60
}
try:
response = requests.post(f"{GRAPH_API_ENDPOINT}/users/{target_email}/calendar/getSchedule", headers=headers, json=payload)
if response.status_code == 200:
view = response.json()['value'][0].get('availabilityView', '')
# start_time ist wichtig für die Berechnung in find_slots
return start_time, view, 60
except: pass
return None
def round_to_next_quarter_hour(dt: datetime) -> datetime:
"""Rundet eine Zeit auf die nächste volle Viertelstunde auf."""
minutes = (dt.minute // 15 + 1) * 15
rounded = dt.replace(minute=0, second=0, microsecond=0) + timedelta(minutes=minutes)
return rounded
def find_slots(start_time_base: datetime, view: str, interval: int) -> list:
"""
Findet zwei intelligente Slots basierend auf der Verfügbarkeit.
start_time_base: Der Beginn der availabilityView (meist 00:00 Uhr heute)
"""
suggestions = []
now = datetime.now(TZ_BERLIN)
# Frühestmöglicher Termin: Jetzt + 15 Min Puffer, gerundet auf Viertelstunde
earliest_possible = round_to_next_quarter_hour(now + timedelta(minutes=15))
def is_slot_free(dt: datetime):
"""Prüft, ob der 60-Minuten-Block, der diesen Zeitpunkt enthält, frei ist."""
# Index in der View berechnen
offset = dt - start_time_base
hours_offset = int(offset.total_seconds() // 3600)
if 0 <= hours_offset < len(view):
return view[hours_offset] == '0' # '0' bedeutet Free
return False
# 1. Slot 1: Nächstmöglicher freier Termin
current_search = earliest_possible
while len(suggestions) < 1 and (current_search - now).days < 3:
# Nur Werktags (Mo-Fr), zwischen 09:00 und 17:00
if current_search.weekday() < 5 and 9 <= current_search.hour < 17:
if is_slot_free(current_search):
suggestions.append(current_search)
break
# Weiterspringen
current_search += timedelta(minutes=15)
# Wenn wir 17 Uhr erreichen, springe zum nächsten Tag 09:00
if current_search.hour >= 17:
current_search += timedelta(days=1)
current_search = current_search.replace(hour=9, minute=0)
if not suggestions:
return []
first_slot = suggestions[0]
# 2. Slot 2: Alternative (Nachmittag oder Folgetag)
# Ziel: 2-3 Stunden später
target_slot_2 = first_slot + timedelta(hours=2.5)
target_slot_2 = round_to_next_quarter_hour(target_slot_2)
# Suchstart für Slot 2
current_search = target_slot_2
while len(suggestions) < 2 and (current_search - now).days < 4:
# Kriterien für Slot 2:
# - Muss frei sein
# - Muss Werktag sein
# - Bevorzugt Nachmittag (13:00 - 16:30), außer wir sind schon am Folgetag, dann ab 9:00
is_working_hours = 9 <= current_search.hour < 17
is_afternoon = 13 <= current_search.hour < 17
is_next_day = current_search.date() > first_slot.date()
# Wir nehmen den Slot, wenn:
# a) Er am selben Tag nachmittags ist
# b) ODER er am nächsten Tag zu einer vernünftigen Zeit ist (falls wir heute zu spät sind)
valid_time = (current_search.date() == first_slot.date() and is_afternoon) or (is_next_day and is_working_hours)
if current_search.weekday() < 5 and valid_time:
if is_slot_free(current_search):
suggestions.append(current_search)
break
current_search += timedelta(minutes=15)
if current_search.hour >= 17:
current_search += timedelta(days=1)
current_search = current_search.replace(hour=9, minute=0)
return suggestions
def create_calendar_invite(lead_email: str, company_name: str, start_time: datetime):
"""Sendet eine echte Outlook-Kalendereinladung aus dem info@-Kalender."""
# Wir erstellen den Termin bei info@ (SENDER_EMAIL), da wir dort Schreibrechte haben sollten.
target_organizer = SENDER_EMAIL
print(f"INFO: Creating calendar invite for {lead_email} in {target_organizer}'s calendar")
token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
if not token: return False
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
end_time = start_time + timedelta(minutes=15)
event_payload = {
"subject": f"Kennenlerngespräch RoboPlanet <> {company_name}",
"body": {"contentType": "HTML", "content": f"Hallo, <br><br>vielen Dank für die Terminbuchung über unsere Lead-Engine. Wir freuen uns auf das Gespräch!<br><br>Beste Grüße,<br>RoboPlanet Team"},
"start": {"dateTime": start_time.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin"},
"end": {"dateTime": end_time.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin"},
"location": {"displayName": "Microsoft Teams Meeting"},
"attendees": [
{"emailAddress": {"address": lead_email, "name": "Interessent"}, "type": "required"},
{"emailAddress": {"address": "e.melcer@robo-planet.de", "name": "Elizabeta Melcer"}, "type": "required"}
],
"isOnlineMeeting": True,
"onlineMeetingProvider": "teamsForBusiness"
}
# URL zeigt auf info@ Kalender
url = f"{GRAPH_API_ENDPOINT}/users/{target_organizer}/calendar/events"
try:
resp = requests.post(url, headers=headers, json=event_payload)
if resp.status_code in [200, 201]:
print(f"SUCCESS: Calendar event created for {target_organizer}.")
return True
else:
print(f"ERROR: Failed to create event. HTTP {resp.status_code}: {resp.text}")
return False
except Exception as e:
print(f"EXCEPTION during event creation: {e}")
return False
# --- E-MAIL & WEB LOGIK ---
def generate_booking_html(request_id: str, suggestions: list) -> str:
html = "<p>Bitte wählen Sie einen passenden Termin für ein 15-minütiges Kennenlerngespräch:</p><ul>"
for slot in suggestions:
ts = int(slot.timestamp())
# Link zu unserem eigenen Bestätigungs-Endpunkt
link = f"{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{ts}"
html += f'<li><a href="{link}" style="font-weight: bold; color: #0078d4;">{slot.strftime("%d.%m. um %H:%M Uhr")}</a></li>'
html += "</ul><p>Mit Klick auf einen Termin wird automatisch eine Kalendereinladung an Sie versendet.</p>"
return html
# --- Server & API ---
app = FastAPI()
@app.get("/stop/{request_id}")
async def stop(request_id: str):
with _lock:
if request_id in request_status_storage:
request_status_storage[request_id]["status"] = "cancelled"
return Response("<html><body><h1>Versand gestoppt.</h1></body></html>", media_type="text/html")
return Response("Ungültig.", status_code=404)
@app.get("/send_now/{request_id}")
async def send_now(request_id: str):
with _lock:
if request_id in request_status_storage:
request_status_storage[request_id]["status"] = "send_now"
return Response("<html><body><h1>E-Mail wird sofort versendet.</h1></body></html>", media_type="text/html")
return Response("Ungültig.", status_code=404)
@app.get("/book_slot/{request_id}/{ts}")
async def book_slot(request_id: str, ts: int):
slot_time = datetime.fromtimestamp(ts, tz=TZ_BERLIN)
with _lock:
data = request_status_storage.get(request_id)
if not data: return Response("Anfrage nicht gefunden.", status_code=404)
if data.get("booked"): return Response("<html><body><h1>Termin wurde bereits bestätigt.</h1></body></html>", media_type="text/html")
data["booked"] = True
# Einladung senden
success = create_calendar_invite(data['receiver'], data['company'], slot_time)
if success:
return Response(f"<html><body><h1>Vielen Dank!</h1><p>Die Einladung für den <b>{slot_time.strftime('%d.%m. um %H:%M')}</b> wurde an {data['receiver']} versendet.</p></body></html>", media_type="text/html")
return Response("Fehler beim Erstellen des Termins.", status_code=500)
# --- Haupt Workflow ---
def send_email(subject, body, to_email, signature):
token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
payload = {"message": {"subject": subject, "body": {"contentType": "HTML", "content": body + signature}, "toRecipients": [{"emailAddress": {"address": to_email}}]}, "saveToSentItems": "true"}
requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload)
def process_lead(request_id: str, company: str, opener: str, receiver: str):
# 1. Freie Slots finden (Check bei e.melcer UND info)
print(f"INFO: Searching slots for {company}...")
# Wir nehmen hier e.melcer als Referenz für die Zeit
cal_data = get_availability("e.melcer@robo-planet.de", (CAL_APPID, CAL_SECRET, CAL_TENNANT_ID))
suggestions = find_slots(*cal_data) if cal_data else []
with _lock:
request_status_storage[request_id] = {"status": "pending", "company": company, "receiver": receiver, "slots": suggestions}
# 2. Teams Notification
send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
card = {
"type": "message", "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive", "content": {
"type": "AdaptiveCard", "version": "1.4", "body": [
{"type": "TextBlock", "text": f"🤖 E-Mail an {company} ({receiver}) geplant für {send_time.strftime('%H:%M')}", "weight": "Bolder"},
{"type": "TextBlock", "text": f"Vorgeschlagene Slots: {', '.join([s.strftime('%H:%M') for s in suggestions])}", "isSubtle": True}
],
"actions": [
{"type": "Action.OpenUrl", "title": "❌ STOP", "url": f"{FEEDBACK_SERVER_BASE_URL}/stop/{request_id}"},
{"type": "Action.OpenUrl", "title": "✅ JETZT", "url": f"{FEEDBACK_SERVER_BASE_URL}/send_now/{request_id}"}
]
}}]
}
requests.post(TEAMS_WEBHOOK_URL, json=card)
# 3. Warten
while datetime.now(TZ_BERLIN) < send_time:
with _lock:
if request_status_storage[request_id]["status"] in ["cancelled", "send_now"]:
break
time.sleep(5)
# 4. Senden
with _lock:
if request_status_storage[request_id]["status"] == "cancelled": return
print(f"INFO: Sending lead email to {receiver}...")
booking_html = generate_booking_html(request_id, suggestions)
with open(SIGNATURE_FILE_PATH, 'r') as f: sig = f.read()
body = f"<p>Sehr geehrte Damen und Herren,</p><p>{opener}</p>{booking_html}"
send_email(f"Ihr Kontakt mit RoboPlanet - {company}", body, receiver, sig)
if __name__ == "__main__":
# Starte den API-Server im Hintergrund
Thread(target=lambda: uvicorn.run(app, host="0.0.0.0", port=8004), daemon=True).start()
print("INFO: Trading Twins Feedback Server started on port 8004.")
time.sleep(2)
# Optional: E2E Test Lead auslösen
if os.getenv("RUN_TEST_LEAD") == "true":
print("\n--- Running E2E Test Lead ---")
process_lead(f"req_{int(time.time())}", "Testfirma GmbH", "Wir haben Ihre Anfrage erhalten.", TEST_RECEIVER_EMAIL)
print("\n[PROD] Manager is active and waiting for leads via import or API.")
try:
while True: time.sleep(1)
except KeyboardInterrupt:
print("Shutting down.")

View File

@@ -0,0 +1,45 @@
from sqlalchemy import create_engine, Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
from datetime import datetime
Base = declarative_base()
class ProposalJob(Base):
__tablename__ = 'proposal_jobs'
id = Column(Integer, primary_key=True)
job_uuid = Column(String, unique=True, nullable=False) # Für die API-Links
customer_email = Column(String, nullable=False)
customer_name = Column(String, nullable=True)
customer_company = Column(String, nullable=True)
# Status-Tracking
status = Column(String, default='pending') # pending, approved, rejected, sent, failed
# Audit-Trail
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
approved_at = Column(DateTime, nullable=True)
# Verknüpfung zu den vorgeschlagenen Slots
slots = relationship("ProposedSlot", back_populates="job")
class ProposedSlot(Base):
__tablename__ = 'proposed_slots'
id = Column(Integer, primary_key=True)
job_id = Column(Integer, ForeignKey('proposal_jobs.id'))
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=False)
# Wir brauchen kein 'is_blocked' Flag mehr, da wir dynamisch zählen,
# wie oft 'start_time' in den letzten 24h verwendet wurde.
job = relationship("ProposalJob", back_populates="slots")
# DB Setup Helper
def init_db(db_path='sqlite:///trading_twins/trading_twins.db'):
engine = create_engine(db_path)
Base.metadata.create_all(engine)
return sessionmaker(bind=engine)

View File

@@ -0,0 +1,130 @@
import time
import threading
import logging
import datetime
from .manager import TradingTwinsManager
from .teams_notification import send_approval_card
from .email_sender import send_email_via_graph
import os
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("TradingTwinsOrchestrator")
TIMEOUT_SECONDS = 300 # 5 Minuten
SIGNATURE_FILE = "trading_twins/signature.html"
BANNER_IMAGE = "trading_twins/RoboPlanetBannerWebinarEinladung.png"
class TradingTwinsOrchestrator:
def __init__(self):
self.manager = TradingTwinsManager()
def process_lead(self, customer_email, customer_name, customer_company):
"""
Startet den gesamten Prozess für einen Lead.
"""
logger.info(f"Neuer Lead eingegangen: {customer_email}")
# 1. Job und Slots erstellen (mit Faktor-3 Logik)
job_uuid, slots = self.manager.create_proposal_job(
customer_email, customer_name, customer_company
)
logger.info(f"Job erstellt: {job_uuid}. Slots: {slots}")
# 2. Teams Benachrichtigung senden
# Formatieren der Uhrzeit für die Teams-Nachricht
send_time = (datetime.datetime.now() + datetime.timedelta(seconds=TIMEOUT_SECONDS)).strftime("%H:%M")
success = send_approval_card(job_uuid, customer_name, send_time)
if not success:
logger.error("Konnte Teams-Benachrichtigung nicht senden!")
# Fallback? Trotzdem Timer starten oder abbrechen?
# Wir machen weiter, da E-Mail-Versand Priorität hat.
# 3. Timer starten für automatischen Versand
timer = threading.Timer(TIMEOUT_SECONDS, self._check_timeout, args=[job_uuid])
timer.start()
return job_uuid
def _check_timeout(self, job_uuid):
"""
Wird nach Ablauf des Timers aufgerufen.
Prüft den Status und sendet ggf. automatisch.
"""
logger.info(f"Timer abgelaufen für Job {job_uuid}. Prüfe Status...")
current_status = self.manager.get_job_status(job_uuid)
if current_status == 'pending':
logger.info(f"Job {job_uuid} ist noch 'pending'. Löse automatischen Versand aus.")
self._trigger_email_send(job_uuid)
elif current_status == 'approved':
logger.info(f"Job {job_uuid} wurde bereits manuell genehmigt.")
elif current_status == 'cancelled':
logger.info(f"Job {job_uuid} wurde manuell abgebrochen.")
else:
logger.warning(f"Unbekannter Status für Job {job_uuid}: {current_status}")
def _trigger_email_send(self, job_uuid):
"""
Hier wird der tatsächliche E-Mail-Versand angestoßen.
"""
# Job Details laden
job_details = self.manager.get_job_details(job_uuid)
if not job_details:
logger.error(f"Konnte Job {job_uuid} nicht finden!")
return
# E-Mail Body zusammenbauen
try:
with open(SIGNATURE_FILE, "r") as f:
signature_html = f.read()
except FileNotFoundError:
logger.warning("Signatur-Datei nicht gefunden!")
signature_html = "<br>Viele Grüße<br>Ihr RoboPlanet Team"
# Dynamische Terminvorschläge formatieren
slots_text = ""
for slot in job_details['slots']:
# Format: "Morgen, 14:00 Uhr" oder Datum
start = slot['start']
slots_text += f"<li>{start.strftime('%d.%m.%Y um %H:%M Uhr')}</li>"
email_body = f"""
<html>
<body>
<p>Hallo {job_details['name']},</p>
<p>vielen Dank für Ihr Interesse an Trading Twins.</p>
<p>Gerne würde ich Ihnen in einem kurzen Gespräch (ca. 15-30 Min) zeigen, wie wir Sie unterstützen können.</p>
<p>Hätten Sie an einem dieser Termine Zeit?</p>
<ul>
{slots_text}
</ul>
<p>Ich freue mich auf Ihre Rückmeldung.</p>
{signature_html}
</body>
</html>
"""
# Banner Check
banner_path = BANNER_IMAGE if os.path.exists(BANNER_IMAGE) else None
# Senden
success = send_email_via_graph(
to_email=job_details['email'],
subject="Ihr Termin für Trading Twins",
body_html=email_body,
banner_path=banner_path
)
if success:
logger.info(f"🚀 E-MAIL WURDE VERSENDET für Job {job_uuid}")
self.manager.update_job_status(job_uuid, 'sent')
else:
logger.error(f"❌ Fehler beim E-Mail-Versand für Job {job_uuid}")
self.manager.update_job_status(job_uuid, 'failed')
if __name__ == "__main__":
# Test-Lauf
orchestrator = TradingTwinsOrchestrator()
orchestrator.process_lead("test@example.com", "Max Mustermann", "Musterfirma GmbH")

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>E-Mail Signatur</title>
</head>
<body>
<!--
HINWEIS:
Dieser Inhalt wird von der IT-Abteilung bereitgestellt.
Bitte den finalen HTML-Code hier einfügen.
Das Bild 'RoboPlanetBannerWebinarEinladung.png' muss sich im selben Verzeichnis befinden.
[31988f42]
-->
<p>Freundliche Grüße</p>
<p>
<b>Elizabeta Melcer</b><br>
Inside Sales Managerin
</p>
<p>
<!-- Wackler Logo -->
<b>RoboPlanet GmbH</b><br>
Schatzbogen 39, 81829 München<br>
T: +49 89 420490-402 | M: +49 175 8334071<br>
<a href="mailto:e.melcer@robo-planet.de">e.melcer@robo-planet.de</a> | <a href="http://www.robo-planet.de">www.robo-planet.de</a>
</p>
<p>
<a href="#">LinkedIn</a> | <a href="#">Instagram</a> | <a href="#">Newsletteranmeldung</a>
</p>
<p style="font-size: smaller; color: grey;">
Sitz der Gesellschaft München | Geschäftsführung: Axel Banoth<br>
Registergericht AG München, HRB 296113 | USt.-IdNr. DE400464410<br>
<a href="#">Hinweispflichten zum Datenschutz</a>
</p>
<p>
<img src="RoboPlanetBannerWebinarEinladung.png" alt="RoboPlanet Webinar Einladung">
</p>
</body>
</html>

View File

@@ -0,0 +1,70 @@
import requests
import json
import os
from datetime import datetime
# Default-Webhook (Platzhalter) - sollte in .env stehen
DEFAULT_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "")
def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT_WEBHOOK_URL):
"""
Sendet eine Adaptive Card an Teams mit Approve/Deny Buttons.
"""
# Die URL unserer API (muss von außen erreichbar sein, z.B. via ngrok oder Server-IP)
api_base_url = os.getenv("API_BASE_URL", "http://localhost:8004")
card_payload = {
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"contentUrl": None,
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{
"type": "TextBlock",
"text": f"🤖 Automatisierte E-Mail an {customer_name}",
"weight": "Bolder",
"size": "Medium"
},
{
"type": "TextBlock",
"text": f"(via Trading Twins) wird um {time_string} Uhr ausgesendet.",
"isSubtle": True,
"wrap": True
},
{
"type": "TextBlock",
"text": "Wenn Du bis dahin NICHT reagierst, wird die E-Mail automatisch gesendet.",
"color": "Attention",
"wrap": True
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "✅ JETZT Aussenden",
"url": f"{api_base_url}/action/approve/{job_uuid}"
},
{
"type": "Action.OpenUrl",
"title": "❌ STOP Aussendung",
"url": f"{api_base_url}/action/cancel/{job_uuid}"
}
]
}
}
]
}
try:
response = requests.post(webhook_url, json=card_payload)
response.raise_for_status()
return True
except Exception as e:
print(f"Fehler beim Senden an Teams: {e}")
return False

View File

@@ -0,0 +1,139 @@
import unittest
from unittest.mock import patch, MagicMock
import time
import os
import logging
from datetime import datetime, timedelta
# Importiere unsere Module
from trading_twins.orchestrator import TradingTwinsOrchestrator
from trading_twins.manager import TradingTwinsManager
from trading_twins.models import init_db, ProposalJob, ProposedSlot
# Logging reduzieren
logging.basicConfig(level=logging.INFO)
class TestTradingTwinsDryRun(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Wir nutzen eine temporäre Test-Datenbank
cls.test_db_path = 'sqlite:///trading_twins/test_dry_run.db'
if os.path.exists("trading_twins/test_dry_run.db"):
os.remove("trading_twins/test_dry_run.db")
# Manager mit Test-DB initialisieren
cls.manager = TradingTwinsManager(db_path=cls.test_db_path)
def setUp(self):
# Orchestrator neu erstellen
self.orchestrator = TradingTwinsOrchestrator()
self.orchestrator.manager = self.manager # Inject Test Manager
# Timer drastisch verkürzen für Tests
self.orchestrator_timeout_patch = patch('trading_twins.orchestrator.TIMEOUT_SECONDS', 2)
self.orchestrator_timeout_patch.start()
def tearDown(self):
self.orchestrator_timeout_patch.stop()
@patch('trading_twins.orchestrator.send_email_via_graph')
@patch('trading_twins.orchestrator.send_approval_card')
def test_1_happy_path_timeout(self, mock_teams, mock_email):
"""Testet den automatischen Versand nach Timeout."""
print("\n--- TEST 1: Happy Path (Timeout -> Auto-Send) ---")
mock_teams.return_value = True
mock_email.return_value = True
# Lead verarbeiten
job_uuid = self.orchestrator.process_lead("test1@example.com", "Kunde Eins", "Firma A")
print(f"Job {job_uuid} gestartet. Warte auf Timeout (2s).")
time.sleep(3) # Warte länger als Timeout
# Prüfungen
mock_teams.assert_called_once()
mock_email.assert_called_once() # E-Mail muss versendet worden sein
status = self.manager.get_job_status(job_uuid)
self.assertEqual(status, 'sent')
print("✅ E-Mail wurde automatisch versendet.")
@patch('trading_twins.orchestrator.send_email_via_graph')
@patch('trading_twins.orchestrator.send_approval_card')
def test_2_manual_cancel(self, mock_teams, mock_email):
"""Testet den manuellen Abbruch."""
print("\n--- TEST 2: Manueller Abbruch (STOP) ---")
mock_teams.return_value = True
# Lead verarbeiten
job_uuid = self.orchestrator.process_lead("test2@example.com", "Kunde Zwei", "Firma B")
# Simuliere Klick auf "STOP" (direkter DB-Update wie API es tun würde)
print("Simuliere Klick auf 'STOP'...")
self.manager.update_job_status(job_uuid, 'cancelled')
print("Warte auf Timeout (2s).")
time.sleep(3)
# Prüfungen
mock_teams.assert_called_once()
mock_email.assert_not_called() # E-Mail darf NICHT versendet werden
status = self.manager.get_job_status(job_uuid)
self.assertEqual(status, 'cancelled')
print("✅ Abbruch erfolgreich, keine E-Mail gesendet.")
@patch('trading_twins.manager.TradingTwinsManager._mock_calendar_availability')
def test_3_overbooking_factor_3(self, mock_calendar):
"""
Testet die Faktor-3 Logik.
Wir stellen 4 Slots zur Verfügung.
Wir erzeugen 4 Leads.
Die ersten 3 sollten Slot A bekommen.
Der 4. Lead sollte Slot A NICHT mehr bekommen, sondern Slot B (oder andere).
"""
print("\n--- TEST 3: Überbuchungs-Logik (Faktor 3) ---")
# Setup Mock Calendar: Gibt immer dieselben 4 Slots zurück
tomorrow = datetime.now().date() + timedelta(days=1)
slot_a = {'start': datetime.combine(tomorrow, datetime.min.time().replace(hour=10)), 'end': datetime.combine(tomorrow, datetime.min.time().replace(hour=10, minute=45))}
slot_b = {'start': datetime.combine(tomorrow, datetime.min.time().replace(hour=11)), 'end': datetime.combine(tomorrow, datetime.min.time().replace(hour=11, minute=45))}
slot_c = {'start': datetime.combine(tomorrow, datetime.min.time().replace(hour=14)), 'end': datetime.combine(tomorrow, datetime.min.time().replace(hour=14, minute=45))}
# Mock gibt diese Liste zurück
mock_calendar.return_value = [slot_a, slot_b, slot_c]
# Wir feuern 4 Leads ab
uuids = []
for i in range(1, 5):
uuid, slots = self.manager.create_proposal_job(f"bulk{i}@test.com", f"Bulk {i}", "Bulk Corp")
uuids.append((uuid, slots))
# print(f"Lead {i} bekam Slots: {[s['start'].strftime('%H:%M') for s in slots]}")
# Analyse
# Lead 1: Bekommt A, B (A hat Count 0 -> 1)
# Lead 2: Bekommt A, B (A hat Count 1 -> 2)
# Lead 3: Bekommt A, B (A hat Count 2 -> 3 -> VOLL)
# Lead 4: Sollte A NICHT bekommen, sondern B, C
slots_lead_1 = uuids[0][1]
slots_lead_4 = uuids[3][1]
start_time_lead_1_first_slot = slots_lead_1[0]['start']
start_time_lead_4_first_slot = slots_lead_4[0]['start']
print(f"Lead 1 Slot 1: {start_time_lead_1_first_slot}")
print(f"Lead 4 Slot 1: {start_time_lead_4_first_slot}")
if start_time_lead_1_first_slot != start_time_lead_4_first_slot:
print("✅ Faktor-3 Logik greift: Lead 4 hat einen anderen Start-Slot bekommen!")
else:
print("❌ Faktor-3 Logik fehlgeschlagen: Lead 4 hat denselben Slot bekommen.")
# Debug
session = self.manager.Session()
count = session.query(ProposedSlot).filter(ProposedSlot.start_time == start_time_lead_1_first_slot).count()
print(f"Total entries for Slot A: {count}")
session.close()
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,50 @@
import requests
import json
import os
def send_teams_message(webhook_url, message):
"""
Sends a simple message to a Microsoft Teams channel using a webhook.
Args:
webhook_url (str): The URL of the incoming webhook.
message (str): The plain text message to send.
Returns:
bool: True if the message was sent successfully (HTTP 200), False otherwise.
"""
if not webhook_url:
print("Error: TEAMS_WEBHOOK_URL is not set.")
return False
headers = {
"Content-Type": "application/json"
}
payload = {
"text": message
}
try:
response = requests.post(webhook_url, headers=headers, data=json.dumps(payload), timeout=10)
if response.status_code == 200:
print("Message sent successfully to Teams.")
return True
else:
print(f"Failed to send message. Status code: {response.status_code}")
print(f"Response: {response.text}")
return False
except requests.exceptions.RequestException as e:
print(f"An error occurred while sending the request: {e}")
return False
if __name__ == "__main__":
# The webhook URL is taken directly from the project description for this test.
# In a real application, this should be loaded from an environment variable.
webhook_url = "https://wacklergroup.webhook.office.com/webhookb2/fe728cde-790c-4190-b1d3-be393ca0f9bd@6d85a9ef-3878-420b-8f43-38d6cb12b665/IncomingWebhook/e9a8ee6157594a6cab96048cf2ea2232/d26033cd-a81f-41a6-8cd2-b4a3ba0b5a01/V2WFmjcbkMzSU4f6lDSdUOM9VNm7F7n1Th4YDiu3fLZ_Y1"
test_message = "🤖 This is a test message from the Gemini Trading Twins Engine. If you see this, the webhook is working. [31988f42]"
send_teams_message(webhook_url, test_message)