# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) """Public client self-service portal at /repair. Phase 1 scope (no AI yet): - /repair Landing page with "Start" CTA - /repair/new Multi-step form - /repair/submit POST -> creates repair.order via shared intake service - /repair/thanks Confirmation with reference - /repair/lookup_phone jsonrpc safe partner match (masked PII) Security: - Public auth (no login) - the voicemail prompts mention this URL - Per-IP rate limit on submit (configurable) - Honeypot + CSRF - Phone lookup returns ONLY masked name + address slice (never other PII) - Records created via sudo in the controller; record rules don't apply because anonymous users don't have a session Phase 2+ will add: AI self-check, upsell engine, smart SMS verify, safety on-call paging, reCAPTCHA v3. """ import base64 import hashlib import logging import re import time from odoo import SUPERUSER_ID, http, fields from odoo.http import request from odoo.tools import email_normalize _logger = logging.getLogger(__name__) # In-memory rate-limit window per worker. Good enough for Phase 1 # and matches the project's "no extra infra" goal. Resets on restart. _RATE_LIMIT_BUCKET = {} def _now_hour_bucket(): return int(time.time() // 3600) def _mask_partner_for_lookup(partner): """Return ONLY safe summary fields - never the full partner record.""" name = partner.name or "" # First name + last initial; never reveal full surname. if " " in name: first, last = name.split(" ", 1) safe_name = f"{first} {(last or ' ')[:1]}." else: safe_name = name return { "matched": True, "name": safe_name, "city": partner.city or "", } def _e164_clean(phone): if not phone: return "" return re.sub(r"[^\d+]", "", phone)[-12:] class ClientRepairPortal(http.Controller): # ------------------------------------------------------------------ # RATE LIMIT (scoped per endpoint so /repair/self_check and # /repair/submit and /repair/lookup_phone don't share one bucket). # ------------------------------------------------------------------ def _check_rate_limit(self, scope="submit"): ICP = request.env["ir.config_parameter"].sudo() # Scope-specific cap if configured, falls back to the global. try: limit = int(ICP.get_param( f"fusion_repairs.client_portal_rate_limit_per_hour_{scope}", ICP.get_param("fusion_repairs.client_portal_rate_limit_per_hour", "10"), )) except (ValueError, TypeError): limit = 10 ip = ( request.httprequest.headers.get("X-Forwarded-For") or request.httprequest.remote_addr or "unknown" ) ip = ip.split(",")[0].strip() bucket = _now_hour_bucket() key = f"{scope}:{ip}:{bucket}" # Prune old buckets across all scopes (cheap - dict is small). suffix = f":{bucket}" for k in list(_RATE_LIMIT_BUCKET.keys()): if not k.endswith(suffix): _RATE_LIMIT_BUCKET.pop(k, None) if _RATE_LIMIT_BUCKET.get(key, 0) >= limit: return True # blocked _RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1 return False # ------------------------------------------------------------------ # LANDING # ------------------------------------------------------------------ @http.route("/repair", type="http", auth="public", website=True, sitemap=True) def repair_landing(self, sn=None, **kw): serial_info = self._resolve_serial_info((sn or "").strip()) # Preserve the ?sn= in the CTA so the form gets it too. form_url = "/repair/new" + (f"?sn={sn}" if sn else "") return request.render("fusion_repairs.portal_client_repair_landing", { "page_name": "client_repair_landing", "serial_info": serial_info, "form_url": form_url, }) @http.route("/repair/new", type="http", auth="public", website=True, sitemap=False) def repair_new(self, sn=None, **kw): categories = request.env["fusion.repair.product.category"].sudo().search([ ("active", "=", True), ], order="sequence, name") serial_info = self._resolve_serial_info((sn or "").strip()) return request.render("fusion_repairs.portal_client_repair_form", { "page_name": "client_repair_new", "categories": categories, "serial_info": serial_info, "error": kw.get("error"), }) # ------------------------------------------------------------------ # B4: resolve ?sn= from a QR sticker scan # ------------------------------------------------------------------ def _resolve_serial_info(self, serial): if not serial: return None Lot = request.env["stock.lot"].sudo() lot = Lot.search([("name", "=", serial)], limit=1) if not lot: return None product = lot.product_id category = product.product_tmpl_id.x_fc_repair_category_id return { "serial": lot.name, "lot_id": lot.id, "product_id": product.id, "product_name": product.display_name, "category_id": category.id if category else False, } # ------------------------------------------------------------------ # PARTNER LOOKUP (rate-limited, audited) # The client is identifying themselves with a phone they own. We return # enough info to pre-fill the form (name, email, street, city) plus the # partner_id so submit can re-use the existing record instead of creating # a duplicate. Privacy guard: rate-limited to 10/hr per IP; every match # is logged at INFO level so abuse leaves a trail. # ------------------------------------------------------------------ @http.route("/repair/lookup_phone", type="jsonrpc", auth="public", website=True) def repair_lookup_phone(self, phone=None, **kw): if self._check_rate_limit(scope="lookup"): return {"error": "rate_limited"} cleaned = _e164_clean(phone) if len(cleaned) < 7: return {"matched": False, "partners": []} matches = request.env["res.partner"].sudo().search([ "|", ("phone", "ilike", cleaned[-7:]), ("phone_sanitized", "ilike", cleaned[-7:]), ], limit=3) # cap at 3 - real households rarely have more if not matches: return {"matched": False, "partners": []} _logger.info( "Portal phone lookup matched %d partner(s) for last7=%s from IP=%s", len(matches), cleaned[-7:], request.httprequest.remote_addr, ) return { "matched": True, "partners": [{ "id": p.id, "name": p.name or "", "email": p.email or "", "street": p.street or "", "city": p.city or "", } for p in matches], } # ------------------------------------------------------------------ # SUBMIT # ------------------------------------------------------------------ @http.route("/repair/submit", type="http", auth="public", methods=["POST"], csrf=True, website=True) def repair_submit(self, **post): # Honeypot - bots tend to fill every visible field. if (post.get("hp_company") or "").strip(): _logger.info("Client portal submit blocked by honeypot from IP=%s", request.httprequest.remote_addr) return request.redirect("/repair/new?error=spam") if self._check_rate_limit(scope="submit"): return request.redirect("/repair/new?error=rate_limited") # Required fields. partner_name = (post.get("client_name") or "").strip() phone = (post.get("client_phone") or "").strip() issue_summary = (post.get("issue_summary") or "").strip() category_id = int(post.get("category_id") or 0) if not (partner_name and phone and issue_summary and category_id): return request.redirect("/repair/new?error=missing") # Validate email if provided. Empty is allowed; malformed redirects back. raw_email = (post.get("client_email") or "").strip() clean_email = email_normalize(raw_email) if raw_email else False if raw_email and not clean_email: return request.redirect("/repair/new?error=email") # B3: trust the explicit known_partner_id from the lookup widget when # present (client identified themselves via the lookup widget on this # very page). Otherwise re-match by phone, otherwise create. partner = False try: known_id = int(post.get("known_partner_id") or 0) except (ValueError, TypeError): known_id = 0 if known_id: partner = request.env["res.partner"].sudo().browse(known_id).exists() cleaned_phone = _e164_clean(phone) if not partner and len(cleaned_phone) >= 7: partner = request.env["res.partner"].sudo().search([ "|", ("phone", "ilike", cleaned_phone[-7:]), ("phone_sanitized", "ilike", cleaned_phone[-7:]), ], limit=1) partner_vals = None if not partner: partner_vals = { "name": partner_name, "phone": phone, "email": clean_email or False, "street": (post.get("client_street") or "").strip(), "city": (post.get("client_city") or "").strip(), } # Stage uploaded photos. files = request.httprequest.files.getlist("photos") attachment_ids = [] for f in files or []: if not getattr(f, "filename", None): continue data = f.read() if not data: continue attachment_ids.append(request.env["ir.attachment"].sudo().create({ "name": f.filename, "datas": base64.b64encode(data), "res_model": "fusion.repair.intake.session", "res_id": 0, }).id) # B4: resolve ?sn= QR scan -> attach the lot to the repair serial_info = self._resolve_serial_info((post.get("serial_number") or "").strip()) equipment = { "repair_category_id": category_id, "third_party": post.get("third_party") in ("on", "true", "1"), "urgency": post.get("urgency") or "normal", "issue_summary": issue_summary, "internal_notes": (post.get("internal_notes") or "").strip(), "photo_attachment_ids": attachment_ids, } if serial_info: equipment["lot_id"] = serial_info["lot_id"] # If client didn't override category, use what the QR identified. if not category_id and serial_info.get("category_id"): equipment["repair_category_id"] = serial_info["category_id"] # Pick a real human owner for the repair so emails go from a person: # admin if present, else the lowest-id non-share user, else SUPERUSER_ID. admin = request.env.ref("base.user_admin", raise_if_not_found=False) if admin: intake_uid = admin.id else: internal = request.env["res.users"].sudo().search( [("share", "=", False)], order="id asc", limit=1, ) intake_uid = internal.id if internal else SUPERUSER_ID payload = { "partner_id": partner.id if partner else None, "partner_vals": partner_vals, "intake_user_id": intake_uid, "equipment_items": [equipment], } try: repairs = request.env["fusion.repair.intake.service"].sudo() \ .create_repair_orders(payload, source="client_portal") except Exception: _logger.exception("Client portal repair submit failed") return request.redirect("/repair/new?error=server") token = hashlib.sha256( f"{repairs[0].id}:{repairs[0].create_date}".encode() ).hexdigest()[:16] return request.redirect(f"/repair/thanks?ref={repairs[0].name}&t={token}") @http.route("/repair/thanks", type="http", auth="public", website=True, sitemap=False) def repair_thanks(self, ref=None, t=None, **kw): return request.render("fusion_repairs.portal_client_repair_thanks", { "page_name": "client_repair_thanks", "ref": ref or "", }) # ------------------------------------------------------------------ # CL6 / CL7: AI self-check JSONRPC endpoint # ------------------------------------------------------------------ @http.route("/repair/self_check", type="jsonrpc", auth="public", website=True) def repair_self_check(self, category_id=None, symptoms=None, urgency=None, **kw): if self._check_rate_limit(scope="self_check"): return {"error": "rate_limited"} if not symptoms: symptoms = [] if isinstance(symptoms, str): symptoms = [symptoms] # Defensive: cap input size to defend against prompt-injection bloat symptoms = [str(s)[:500] for s in symptoms[:5]] Service = request.env["fusion.repair.ai.service"].sudo() return Service.suggest_self_check( product_category_id=int(category_id or 0) or None, symptoms=symptoms, urgency=urgency or None, ) # ------------------------------------------------------------------ # CL15: on-call acknowledgement endpoint # Only the paged user OR a Repairs Manager can ack - prevents arbitrary # internal users (or someone with a forwarded mail) from acknowledging # a page they were never paged for. # ------------------------------------------------------------------ @http.route("/repair/on-call/ack/", type="http", auth="user", website=True, sitemap=False) def repair_on_call_ack(self, token, **kw): Repair = request.env["repair.order"].sudo() repair = Repair.search([("x_fc_on_call_token", "=", token)], limit=1) if not repair: return request.render( "fusion_repairs.portal_on_call_ack_invalid", {}, ) user = request.env.user is_paged_user = user == repair.x_fc_on_call_paged_user_id is_manager = user.has_group("fusion_repairs.group_fusion_repairs_manager") if not (is_paged_user or is_manager): _logger.warning( "On-call ack denied for repair %s - user %s is not the paged " "user (%s) and not a Repairs Manager.", repair.name, user.login, repair.x_fc_on_call_paged_user_id.login or "(none)", ) return request.render( "fusion_repairs.portal_on_call_ack_invalid", {}, ) Service = request.env["fusion.repair.on.call.service"].sudo() Service.acknowledge(repair, user) return request.render("fusion_repairs.portal_on_call_ack_ok", { "repair_name": repair.name, })