atlas-academy/backend/odoo_relay.py
Atlas Developer 03829a38b2 feat: Atlas Academy — Odoo eLearning platform, full fusion
9-course Dutch e-learning platform fused onto atlasacademy.nl via Caddy
reverse proxy. Odoo 19 Community as course engine (website_slides), static
HTML for marketing front. Single Odoo account system.

- static/: 8 marketing pages (index, pricing, coaching, leerpad, over, shop, bisl, esf)
- backend/odoo_relay.py: lead relay Flask service (POST /academy-lead → Odoo CRM)
- caddy/academy.caddyfile: full Caddy config with Odoo proxy + auth rewrites
- docs/: ARCHITECTURE, USERFLOW, COURSES, DEPLOYMENT, SECURITY, ODOO

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 00:48:45 +02:00

234 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""
Atlas Academy — Odoo Lead Relay
Listens on 127.0.0.1:7793, accepts POST /lead, creates crm.lead in Odoo.
Also handles admin actions via _action field for the admin portal.
"""
import datetime
import json
import logging
import os
import smtplib
import xmlrpc.client
from email.mime.text import MIMEText
from http.server import BaseHTTPRequestHandler, HTTPServer
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [relay] %(levelname)s %(message)s'
)
log = logging.getLogger('academy-relay')
ODOO_URL = "http://100.93.16.81:8069"
ODOO_DB = 'atlas'
ODOO_CREDS = [(os.environ['ODOO_RELAY_LOGIN'], os.environ['ODOO_RELAY_PASS'])]
UID_FILE = '/opt/atlas/academy/.odoo_uid'
# Admin config
ADMIN_PASS = os.environ.get('ACADEMY_ADMIN_PASS')
# Notification config
NOTIFY_SMTP_HOST = os.environ.get('LEAD_SMTP_HOST', 'smtp.gmail.com')
NOTIFY_SMTP_PORT = int(os.environ.get('LEAD_SMTP_PORT', '587'))
NOTIFY_SMTP_USER = os.environ.get('LEAD_SMTP_USER', 'chaibaarab98@gmail.com')
NOTIFY_SMTP_PASS = os.environ.get('LEAD_SMTP_PASS')
NOTIFY_SMTP_FROM = os.environ.get('LEAD_SMTP_FROM', 'chaibaarab98@gmail.com')
NOTIFY_TO = os.environ.get('LEAD_NOTIFY_TO', 'chaib@atlascorporation.nl')
def _notify_operator(name, email, interest, source, lead_id):
body = (
f"Nieuwe Academy aanmelding!\n\n"
f"Naam: {name}\n"
f"E-mail: {email}\n"
f"Interesse: {interest or '-'}\n"
f"Bron: {source or '-'}\n"
f"Lead ID: {lead_id or '-'}\n\n"
f"-- Atlas Academy Lead Relay"
)
try:
msg = MIMEText(body)
msg['Subject'] = f'Academy: {name} heeft zich aangemeld'
msg['From'] = NOTIFY_SMTP_FROM
msg['To'] = NOTIFY_TO
s = smtplib.SMTP(NOTIFY_SMTP_HOST, NOTIFY_SMTP_PORT, timeout=10)
s.starttls()
s.login(NOTIFY_SMTP_USER, NOTIFY_SMTP_PASS)
s.send_message(msg)
s.quit()
log.info('Notification sent to %s for lead %s', NOTIFY_TO, lead_id)
except Exception as e:
log.warning('Notification email failed: %s', e)
def _load_uid():
if os.path.exists(UID_FILE):
try:
with open(UID_FILE) as f:
data = json.load(f)
return data.get('uid'), data.get('password')
except Exception:
pass
return None, None
def _save_uid(uid, password):
with open(UID_FILE, 'w') as f:
json.dump({'uid': uid, 'password': password}, f)
def _get_odoo_uid():
uid, pw = _load_uid()
if uid:
return uid, pw
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common', allow_none=True)
for login, password in ODOO_CREDS:
try:
uid = common.authenticate(ODOO_DB, login, password, {})
if uid:
log.info('Odoo auth OK with %s (uid=%s)', login, uid)
_save_uid(uid, password)
return uid, password
except Exception as e:
log.warning('Odoo auth failed for %s: %s', login, e)
return None, None
def create_lead(name, email, interest, source):
uid, pw = _get_odoo_uid()
if not uid:
log.error('Odoo auth failed for all credentials -- lead not created')
return None, 'Odoo authentication failed'
try:
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object', allow_none=True)
lead_id = models.execute_kw(
ODOO_DB, uid, pw,
'crm.lead', 'create',
[{
'name': f'Academy Lead: {name}',
'contact_name': name,
'email_from': email,
'description': f'Interesse: {interest} | Bron: {source}',
}]
)
log.info('Created crm.lead id=%s for %s (%s)', lead_id, name, email)
_notify_operator(name, email, interest, source, lead_id)
return lead_id, None
except Exception as e:
if os.path.exists(UID_FILE):
os.remove(UID_FILE)
log.error('Odoo create_lead error: %s', e)
return None, str(e)
def _query_leads(limit=100, offset=0):
uid, pw = _get_odoo_uid()
if not uid:
return None, 'Odoo auth failed'
try:
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object', allow_none=True)
ids = models.execute_kw(ODOO_DB, uid, pw, 'crm.lead', 'search',
[[('name', 'ilike', 'Academy Lead%')]], {'limit': limit, 'offset': offset})
if not ids:
return [], None
fields = ['id', 'name', 'contact_name', 'email_from', 'description', 'create_date']
leads = models.execute_kw(ODOO_DB, uid, pw, 'crm.lead', 'read', [ids], {'fields': fields})
return sorted(leads, key=lambda l: l.get('id', 0), reverse=True), None
except Exception as e:
return None, str(e)
def _count_leads():
uid, pw = _get_odoo_uid()
if not uid:
return None, None, 'Odoo auth failed'
try:
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object', allow_none=True)
total = models.execute_kw(ODOO_DB, uid, pw, 'crm.lead', 'search_count',
[[('name', 'ilike', 'Academy Lead%')]])
today_start = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d 00:00:00')
today = models.execute_kw(ODOO_DB, uid, pw, 'crm.lead', 'search_count',
[[('name', 'ilike', 'Academy Lead%'),
('create_date', '>=', today_start)]])
return total, today, None
except Exception as e:
return None, None, str(e)
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
log.info(fmt, *args)
def _send_json(self, code, body):
data = json.dumps(body).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self.send_header('Access-Control-Allow-Origin', 'https://atlasacademy.nl')
self.end_headers()
self.wfile.write(data)
def do_OPTIONS(self):
self.send_response(204)
self.send_header('Access-Control-Allow-Origin', 'https://atlasacademy.nl')
self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
def _read_body(self, max_size=8192):
length = int(self.headers.get('Content-Length', 0))
if length > max_size:
return None, 'payload too large'
try:
return json.loads(self.rfile.read(length)), None
except Exception:
return None, 'invalid JSON'
def do_GET(self):
if self.path == '/health':
self._send_json(200, {'ok': True, 'service': 'academy-relay'})
else:
self._send_json(404, {'ok': False, 'err': 'not found'})
def do_POST(self):
path = self.path.rstrip('/')
if path not in ('/lead', '/academy-lead'):
self._send_json(404, {'ok': False, 'err': 'not found'})
return
body, err = self._read_body()
if err:
self._send_json(400, {'ok': False, 'err': err})
return
# Admin DATA actions are DISABLED on the public endpoint (PII protection).
# The gated admin portal (academy_admin.py, behind basic_auth on the atlas-01 hub)
# reads Odoo directly via XML-RPC and does NOT use this endpoint for lead data.
action = body.get('_action', '')
if action:
self._send_json(403, {'ok': False, 'err': 'admin actions are not available on the public endpoint'})
return
# Default: create lead
name = str(body.get('name', '')).strip()[:120]
email = str(body.get('email', '')).strip()[:200]
interest = str(body.get('interest', '')).strip()[:200]
source = str(body.get('source', '')).strip()[:200]
if not name or not email:
self._send_json(400, {'ok': False, 'err': 'name and email required'})
return
lead_id, err = create_lead(name, email, interest, source)
if lead_id:
self._send_json(200, {'ok': True, 'lead_id': lead_id})
else:
log.warning('Returning ok:true despite error (email fallback active): %s', err)
self._send_json(200, {'ok': True, 'lead_id': None, 'note': 'queued'})
if __name__ == '__main__':
host, port = '127.0.0.1', 7793
server = HTTPServer((host, port), Handler)
log.info('Atlas Academy Relay listening on %s:%d', host, port)
server.serve_forever()