From db50c3f15c53d20ff08a9d77a3b9b03fa6ec51c1 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 17 Aug 2026 21:29:40 -0400 Subject: [PATCH] feat(event_qr_ticketing): signed QR tickets + mobile check-in (Phase 2) Extends stock event.registration with a signed QR ticket system, built to complement rather than duplicate Odoo 19's existing barcode/badge infrastructure. ticket_ref ('TIX-{event}-{seq}') and a QR code encoding "ticket_ref|hmac_token" are added; the HMAC is signed with Odoo's own per-database secret (ir.config_parameter 'database.secret', the same mechanism core uses for password-reset tokens), so a copied/edited ticket_ref without the matching signature is rejected as forged. A "Event Ticket (QR)" PDF report is auto-attached to the core registration confirmation email by adding it to event.event_subscription's report_template_ids - no override of core mail-sending logic needed. Adds the missing piece core doesn't provide: a mobile-friendly staff check-in page at /event/checkin (gated on event.group_event_registration_desk), built as a v19 "Interaction" (registry.category("public.interactions"), the current replacement for legacy publicWidget) with manual ticket-ref entry always available and camera scanning via the browser's native BarcodeDetector API - avoiding a third-party CDN dependency and its security/offline-reliability tradeoffs. Guards against forged tokens and double check-in; a live per-event registered-vs-checked-in dashboard. Two more real Odoo 19 surprises hit here: event.event has no more `state` field at all (replaced by a stage_id/event.stage kanban system - my check-in page's event picker now filters by date_end instead), and a route type='json' should be type='jsonrpc' (json still works but is deprecated). Verified against a live Odoo 19 + Postgres 16 container: 9/9 tests pass (ticket generation/uniqueness, valid/duplicate/forged/tampered token handling, manual lookup), plus a full manual live run over HTTP - created an event and registration via JSON-RPC, confirmed ticket_ref generation, authenticated a session, hit /event/checkin/scan for a real check-in and duplicate rejection, confirmed dashboard counts update, and loaded the actual /event/checkin page (title, camera-button markup, and our JS present in the served frontend bundle). Co-Authored-By: Claude Sonnet 5 --- addons/event_qr_ticketing/__init__.py | 2 + addons/event_qr_ticketing/__manifest__.py | 14 +- .../controllers/__init__.py | 1 + .../event_qr_ticketing/controllers/checkin.py | 53 +++++++ .../event_qr_ticketing/data/ir_sequence.xml | 10 ++ addons/event_qr_ticketing/models/__init__.py | 1 + .../models/event_registration.py | 108 ++++++++++++++ .../report/event_ticket_report.xml | 48 ++++++ .../static/src/js/event_checkin.js | 140 ++++++++++++++++++ addons/event_qr_ticketing/tests/__init__.py | 2 + .../tests/test_event_checkin.py | 67 +++++++++ .../tests/test_event_ticket.py | 37 +++++ .../views/checkin_templates.xml | 36 +++++ 13 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 addons/event_qr_ticketing/controllers/__init__.py create mode 100644 addons/event_qr_ticketing/controllers/checkin.py create mode 100644 addons/event_qr_ticketing/data/ir_sequence.xml create mode 100644 addons/event_qr_ticketing/models/__init__.py create mode 100644 addons/event_qr_ticketing/models/event_registration.py create mode 100644 addons/event_qr_ticketing/report/event_ticket_report.xml create mode 100644 addons/event_qr_ticketing/static/src/js/event_checkin.js create mode 100644 addons/event_qr_ticketing/tests/test_event_checkin.py create mode 100644 addons/event_qr_ticketing/tests/test_event_ticket.py create mode 100644 addons/event_qr_ticketing/views/checkin_templates.xml diff --git a/addons/event_qr_ticketing/__init__.py b/addons/event_qr_ticketing/__init__.py index e69de29..f7209b1 100644 --- a/addons/event_qr_ticketing/__init__.py +++ b/addons/event_qr_ticketing/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/event_qr_ticketing/__manifest__.py b/addons/event_qr_ticketing/__manifest__.py index 6590ca8..9c288b6 100644 --- a/addons/event_qr_ticketing/__manifest__.py +++ b/addons/event_qr_ticketing/__manifest__.py @@ -28,9 +28,21 @@ OS product module required. 'event_sale', 'website_event', ], - 'data': [], + 'data': [ + 'data/ir_sequence.xml', + 'report/event_ticket_report.xml', + 'views/checkin_templates.xml', + ], 'demo': [], + 'assets': { + 'web.assets_frontend': [ + 'event_qr_ticketing/static/src/js/event_checkin.js', + ], + }, 'images': ['static/description/banner.png'], 'application': False, 'installable': True, + 'external_dependencies': { + 'python': ['qrcode'], + }, } diff --git a/addons/event_qr_ticketing/controllers/__init__.py b/addons/event_qr_ticketing/controllers/__init__.py new file mode 100644 index 0000000..4f54e22 --- /dev/null +++ b/addons/event_qr_ticketing/controllers/__init__.py @@ -0,0 +1 @@ +from . import checkin diff --git a/addons/event_qr_ticketing/controllers/checkin.py b/addons/event_qr_ticketing/controllers/checkin.py new file mode 100644 index 0000000..4cef49f --- /dev/null +++ b/addons/event_qr_ticketing/controllers/checkin.py @@ -0,0 +1,53 @@ +from odoo import fields, http +from odoo.exceptions import AccessDenied +from odoo.http import request + + +def _require_registration_desk(): + if not request.env.user.has_group('event.group_event_registration_desk'): + raise AccessDenied() + + +class EventCheckinController(http.Controller): + + @http.route(['/event/checkin', '/event/checkin/'], type='http', auth='user', website=True) + def checkin_page(self, event_id=None, **kwargs): + _require_registration_desk() + events = request.env['event.event'].search([('date_end', '>=', fields.Datetime.now())]) + event = request.env['event.event'].browse(event_id) if event_id else events[:1] + return request.render('event_qr_ticketing.checkin_page', { + 'events': events, + 'event': event, + }) + + @http.route(['/event/checkin/scan'], type='jsonrpc', auth='user', website=True) + def checkin_scan(self, value=None, manual=False, **kwargs): + _require_registration_desk() + Registration = request.env['event.registration'] + + if manual: + status, registration = Registration._lookup_by_ticket_ref((value or '').strip()) + else: + status, registration = Registration._verify_scanned_token(value) + + result = {'status': status} + if registration: + result.update( + name=registration.name or registration.partner_id.name, + event=registration.event_id.name, + ticket_ref=registration.ticket_ref, + ) + if status == 'ok': + registration.action_check_in() + + return result + + @http.route(['/event/checkin/dashboard/'], type='jsonrpc', auth='user', website=True) + def checkin_dashboard(self, event_id, **kwargs): + _require_registration_desk() + event = request.env['event.event'].browse(event_id) + registrations = event.registration_ids.filtered(lambda r: r.state in ('open', 'done')) + return { + 'registered': len(registrations), + 'checked_in': len(registrations.filtered('checked_in')), + } diff --git a/addons/event_qr_ticketing/data/ir_sequence.xml b/addons/event_qr_ticketing/data/ir_sequence.xml new file mode 100644 index 0000000..0f071d6 --- /dev/null +++ b/addons/event_qr_ticketing/data/ir_sequence.xml @@ -0,0 +1,10 @@ + + + + Event Ticket Reference + event_qr_ticketing.ticket_ref + 5 + 1 + no_gap + + diff --git a/addons/event_qr_ticketing/models/__init__.py b/addons/event_qr_ticketing/models/__init__.py new file mode 100644 index 0000000..6f6a9df --- /dev/null +++ b/addons/event_qr_ticketing/models/__init__.py @@ -0,0 +1 @@ +from . import event_registration diff --git a/addons/event_qr_ticketing/models/event_registration.py b/addons/event_qr_ticketing/models/event_registration.py new file mode 100644 index 0000000..6b29e8f --- /dev/null +++ b/addons/event_qr_ticketing/models/event_registration.py @@ -0,0 +1,108 @@ +import base64 +import hashlib +import hmac +import io + +import qrcode + +from odoo import api, fields, models + +TICKET_REF_SEQUENCE_CODE = 'event_qr_ticketing.ticket_ref' + + +class EventRegistration(models.Model): + _inherit = 'event.registration' + + ticket_ref = fields.Char(string='Ticket Reference', readonly=True, copy=False, index=True) + ticket_qr = fields.Binary(string='Ticket QR', compute='_compute_ticket_qr') + checked_in = fields.Boolean(string='Checked In', default=False, copy=False, readonly=True) + check_in_time = fields.Datetime(string='Check-in Time', readonly=True, copy=False) + check_in_user_id = fields.Many2one('res.users', string='Checked In By', readonly=True, copy=False) + + _ticket_ref_uniq = models.Constraint('unique(ticket_ref)', 'Ticket reference must be unique.') + + @api.model_create_multi + def create(self, vals_list): + registrations = super().create(vals_list) + for registration in registrations: + if not registration.ticket_ref: + registration.ticket_ref = registration._generate_ticket_ref() + return registrations + + def _generate_ticket_ref(self): + self.ensure_one() + seq_number = self.env['ir.sequence'].next_by_code(TICKET_REF_SEQUENCE_CODE) or '1' + return f"TIX-{self.event_id.id}-{seq_number}" + + def _get_signing_secret(self): + secret = self.env['ir.config_parameter'].sudo().get_param('database.secret') + return secret.encode() + + def _compute_ticket_token(self): + """HMAC of event_id:registration_id:ticket_ref, signed with the per-database secret.""" + self.ensure_one() + payload = f"{self.event_id.id}:{self.id}:{self.ticket_ref}".encode() + return hmac.new(self._get_signing_secret(), payload, hashlib.sha256).hexdigest()[:16] + + def _get_ticket_qr_value(self): + self.ensure_one() + return f"{self.ticket_ref}|{self._compute_ticket_token()}" + + def _compute_ticket_qr(self): + for registration in self: + if not registration.ticket_ref: + registration.ticket_qr = False + continue + qr = qrcode.QRCode(box_size=6, border=2) + qr.add_data(registration._get_ticket_qr_value()) + qr.make(fit=True) + image = qr.make_image(fill_color='black', back_color='white') + buffer = io.BytesIO() + image.save(buffer, format='PNG') + registration.ticket_qr = base64.b64encode(buffer.getvalue()) + + @api.model + def _verify_scanned_token(self, scanned_value): + """Verify a scanned QR payload ('ticket_ref|token'). + + Returns (status, registration) where status is one of: + 'ok', 'already', 'invalid' (bad/forged token), 'not_found'. + """ + parts = (scanned_value or '').split('|') + if len(parts) != 2 or not parts[0]: + return 'invalid', self.env['event.registration'] + + ticket_ref, token = parts + registration = self.search([('ticket_ref', '=', ticket_ref)], limit=1) + if not registration: + return 'not_found', registration + + expected_token = registration._compute_ticket_token() + if not hmac.compare_digest(expected_token, token): + return 'invalid', registration + + if registration.checked_in: + return 'already', registration + + return 'ok', registration + + @api.model + def _lookup_by_ticket_ref(self, ticket_ref): + """Manual entry lookup: staff-trusted, no signature check required.""" + registration = self.search([('ticket_ref', '=', ticket_ref)], limit=1) + if not registration: + return 'not_found', registration + if registration.checked_in: + return 'already', registration + return 'ok', registration + + def action_check_in(self): + self.ensure_one() + self.write({ + 'checked_in': True, + 'check_in_time': fields.Datetime.now(), + 'check_in_user_id': self.env.user.id, + }) + if self.state != 'done': + self.action_set_done() + return True diff --git a/addons/event_qr_ticketing/report/event_ticket_report.xml b/addons/event_qr_ticketing/report/event_ticket_report.xml new file mode 100644 index 0000000..774990a --- /dev/null +++ b/addons/event_qr_ticketing/report/event_ticket_report.xml @@ -0,0 +1,48 @@ + + + + Event Ticket (QR) + event.registration + qweb-pdf + event_qr_ticketing.report_event_ticket_qr + event_qr_ticketing.report_event_ticket_qr + 'Ticket - %s' % (object.ticket_ref or object.name) + + report + + + + + + + + diff --git a/addons/event_qr_ticketing/static/src/js/event_checkin.js b/addons/event_qr_ticketing/static/src/js/event_checkin.js new file mode 100644 index 0000000..018e72a --- /dev/null +++ b/addons/event_qr_ticketing/static/src/js/event_checkin.js @@ -0,0 +1,140 @@ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; + +export class EventCheckin extends Interaction { + static selector = ".o_event_checkin"; + dynamicContent = { + ".o_checkin_submit": { "t-on-click": this.onSubmitManual }, + ".o_checkin_input": { "t-on-keydown": this.onInputKeydown }, + ".o_checkin_event_select": { "t-on-change": this.onEventChange }, + ".o_checkin_camera_btn": { "t-on-click": this.onToggleCamera }, + }; + + setup() { + this.inputEl = this.el.querySelector(".o_checkin_input"); + this.resultEl = this.el.querySelector(".o_checkin_result"); + this.selectEl = this.el.querySelector(".o_checkin_event_select"); + this.videoEl = this.el.querySelector(".o_checkin_camera_video"); + this.cameraBtnEl = this.el.querySelector(".o_checkin_camera_btn"); + this.scanning = false; + this.stream = null; + } + + async willStart() { + if ("BarcodeDetector" in window) { + this.cameraBtnEl.classList.remove("d-none"); + } + return this.updateDashboard(); + } + + getEventId() { + return parseInt(this.selectEl.value, 10); + } + + async updateDashboard() { + const eventId = this.getEventId(); + if (!eventId) { + return; + } + const data = await this.waitFor(rpc(`/event/checkin/dashboard/${eventId}`, {})); + this.el.querySelector(".o_checkin_registered_count").textContent = data.registered; + this.el.querySelector(".o_checkin_checked_in_count").textContent = data.checked_in; + } + + onEventChange() { + this.updateDashboard(); + } + + onInputKeydown(ev) { + if (ev.key === "Enter") { + ev.preventDefault(); + this.onSubmitManual(); + } + } + + async onSubmitManual() { + const value = this.inputEl.value.trim(); + if (!value) { + return; + } + await this.submitScan(value, true); + this.inputEl.value = ""; + this.inputEl.focus(); + } + + async submitScan(value, manual) { + const result = await this.waitFor(rpc("/event/checkin/scan", { value, manual })); + this.showResult(result); + if (result.status === "ok") { + this.updateDashboard(); + } + return result; + } + + showResult(result) { + const messages = { + ok: `Checked in: ${result.name || ""}`, + already: `Already checked in: ${result.name || ""}`, + invalid: "Invalid or forged ticket", + not_found: "Ticket not found", + }; + const classes = { + ok: "alert alert-success", + already: "alert alert-warning", + invalid: "alert alert-danger", + not_found: "alert alert-danger", + }; + this.resultEl.className = classes[result.status] || "alert alert-secondary"; + this.resultEl.textContent = messages[result.status] || "Unknown response"; + } + + async onToggleCamera() { + if (this.scanning) { + this.stopCamera(); + return; + } + try { + this.stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }); + } catch { + this.showResult({ status: "invalid" }); + return; + } + this.videoEl.srcObject = this.stream; + this.videoEl.classList.remove("d-none"); + this.scanning = true; + this.detector = new window.BarcodeDetector({ formats: ["qr_code"] }); + this.scanLoop(); + } + + stopCamera() { + this.scanning = false; + if (this.stream) { + this.stream.getTracks().forEach((track) => track.stop()); + } + this.videoEl.classList.add("d-none"); + } + + async scanLoop() { + if (!this.scanning) { + return; + } + try { + const barcodes = await this.detector.detect(this.videoEl); + if (barcodes.length) { + await this.submitScan(barcodes[0].rawValue, false); + } + } catch { + // detection failed on this frame; keep trying + } + if (this.scanning) { + requestAnimationFrame(() => this.scanLoop()); + } + } + + destroy() { + this.stopCamera(); + } +} + +registry.category("public.interactions").add("event_qr_ticketing.event_checkin", EventCheckin); diff --git a/addons/event_qr_ticketing/tests/__init__.py b/addons/event_qr_ticketing/tests/__init__.py index e69de29..d8b3fba 100644 --- a/addons/event_qr_ticketing/tests/__init__.py +++ b/addons/event_qr_ticketing/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_event_ticket +from . import test_event_checkin diff --git a/addons/event_qr_ticketing/tests/test_event_checkin.py b/addons/event_qr_ticketing/tests/test_event_checkin.py new file mode 100644 index 0000000..6ac69a3 --- /dev/null +++ b/addons/event_qr_ticketing/tests/test_event_checkin.py @@ -0,0 +1,67 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestEventCheckin(TransactionCase): + + def setUp(self): + super().setUp() + self.event = self.env['event.event'].create({ + 'name': 'Checkin Test Event', + 'date_begin': '2030-01-01 10:00:00', + 'date_end': '2030-01-01 18:00:00', + }) + self.registration = self.env['event.registration'].create({ + 'event_id': self.event.id, + 'name': 'Checkin Attendee', + }) + + def test_valid_token_checks_in(self): + qr_value = self.registration._get_ticket_qr_value() + status, found = self.env['event.registration']._verify_scanned_token(qr_value) + self.assertEqual(status, 'ok') + found.action_check_in() + self.assertTrue(found.checked_in) + self.assertTrue(found.check_in_time) + self.assertEqual(found.check_in_user_id, self.env.user) + self.assertEqual(found.state, 'done') + + def test_duplicate_check_in_detected(self): + qr_value = self.registration._get_ticket_qr_value() + status, found = self.env['event.registration']._verify_scanned_token(qr_value) + found.action_check_in() + + status_again, _found_again = self.env['event.registration']._verify_scanned_token(qr_value) + self.assertEqual(status_again, 'already') + + def test_forged_token_rejected(self): + forged_value = f"{self.registration.ticket_ref}|0000000000000000" + status, found = self.env['event.registration']._verify_scanned_token(forged_value) + self.assertEqual(status, 'invalid') + self.assertFalse(found.checked_in) + + def test_tampered_ticket_ref_rejected(self): + real_token = self.registration._compute_ticket_token() + other_event = self.env['event.event'].create({ + 'name': 'Other Event', + 'date_begin': '2030-02-01 10:00:00', + 'date_end': '2030-02-01 18:00:00', + }) + other_registration = self.env['event.registration'].create({ + 'event_id': other_event.id, + 'name': 'Other Attendee', + }) + # Attacker takes a valid token and pastes it onto a different ticket_ref. + forged_value = f"{other_registration.ticket_ref}|{real_token}" + status, found = self.env['event.registration']._verify_scanned_token(forged_value) + self.assertEqual(status, 'invalid') + + def test_manual_lookup_bypasses_signature(self): + status, found = self.env['event.registration']._lookup_by_ticket_ref(self.registration.ticket_ref) + self.assertEqual(status, 'ok') + self.assertEqual(found, self.registration) + + def test_manual_lookup_unknown_ref(self): + status, found = self.env['event.registration']._lookup_by_ticket_ref('NOT-A-REAL-REF') + self.assertEqual(status, 'not_found') + self.assertFalse(found) diff --git a/addons/event_qr_ticketing/tests/test_event_ticket.py b/addons/event_qr_ticketing/tests/test_event_ticket.py new file mode 100644 index 0000000..3902c5d --- /dev/null +++ b/addons/event_qr_ticketing/tests/test_event_ticket.py @@ -0,0 +1,37 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestEventTicket(TransactionCase): + + def setUp(self): + super().setUp() + self.event = self.env['event.event'].create({ + 'name': 'Test Event', + 'date_begin': '2030-01-01 10:00:00', + 'date_end': '2030-01-01 18:00:00', + }) + + def test_ticket_ref_and_qr_generated_on_create(self): + registration = self.env['event.registration'].create({ + 'event_id': self.event.id, + 'name': 'Attendee One', + }) + self.assertTrue(registration.ticket_ref) + self.assertTrue(registration.ticket_ref.startswith(f'TIX-{self.event.id}-')) + self.assertTrue(registration.ticket_qr, "QR image should be generated") + + def test_ticket_ref_unique_per_registration(self): + reg1 = self.env['event.registration'].create({'event_id': self.event.id, 'name': 'A'}) + reg2 = self.env['event.registration'].create({'event_id': self.event.id, 'name': 'B'}) + self.assertNotEqual(reg1.ticket_ref, reg2.ticket_ref) + + def test_scanned_token_round_trips(self): + registration = self.env['event.registration'].create({ + 'event_id': self.event.id, + 'name': 'Attendee Two', + }) + qr_value = registration._get_ticket_qr_value() + status, found = self.env['event.registration']._verify_scanned_token(qr_value) + self.assertEqual(status, 'ok') + self.assertEqual(found, registration) diff --git a/addons/event_qr_ticketing/views/checkin_templates.xml b/addons/event_qr_ticketing/views/checkin_templates.xml new file mode 100644 index 0000000..1c97186 --- /dev/null +++ b/addons/event_qr_ticketing/views/checkin_templates.xml @@ -0,0 +1,36 @@ + + + +