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>
234 lines
8.5 KiB
Python
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()
|