Files
encoach_backend_new_v2/odoo/addons/website_event/controllers/main.py
Yamen Ahmad 3e83d8d7d5 feat: add complete EnCoach backend — Odoo 19 + all addons
Includes:
- Odoo 19 framework (odoo/)
- 27 custom EnCoach addons (new_project/custom_addons/)
  - encoach_core, encoach_api, encoach_lms_api, encoach_adaptive_api
  - encoach_exam, encoach_taxonomy, encoach_adaptive, encoach_assignment
  - encoach_ai, encoach_ai_grading, encoach_ai_generation, encoach_ai_media
  - encoach_courseware, encoach_communication, encoach_subscription
  - encoach_notification, encoach_approval, encoach_branding
  - encoach_classroom, encoach_registration, encoach_stats
  - encoach_faq, encoach_ticket, encoach_training, encoach_resources
  - encoach_adaptive_ai, encoach_sis
- 21 OpenEduCat Enterprise modules (new_project/enterprise-19/)
- 14 OpenEduCat Community modules (new_project/openeducat_erp-19.0/)
- Configuration: odoo.conf, requirements.txt, scripts
- 200+ REST API endpoints with JWT authentication
- SRS and test documentation

Made-with: Cursor
2026-04-01 17:10:04 +04:00

543 lines
26 KiB
Python

import babel.dates
import werkzeug
from ast import literal_eval
from collections import Counter
from werkzeug.exceptions import NotFound
from odoo import fields, http, _
from odoo.addons.website.controllers.main import QueryURL
from odoo.fields import Domain
from odoo.http import request
from odoo.tools.misc import get_lang
from odoo.tools import lazy
from odoo.tools.translate import LazyTranslate
from odoo.exceptions import UserError, ValidationError
_lt = LazyTranslate(__name__)
class WebsiteEventController(http.Controller):
def sitemap_event(env, rule, qs):
if not qs or qs.lower() in '/events':
yield {'loc': '/events'}
# ------------------------------------------------------------
# EVENT LIST
# ------------------------------------------------------------
def _get_events_search_options(self, slug_tags, **post):
return {
'displayDescription': True,
'displayDetail': False,
'displayExtraDetail': False,
'displayExtraLink': False,
'displayImage': False,
'allowFuzzy': not post.get('noFuzzy'),
'date': post.get('date'),
'tags': slug_tags or post.get('tags'),
'type': post.get('type'),
'country': post.get('country'),
}
@http.route([
path
for base in ('event', 'events')
for path in [
f'/{base}',
f'/{base}/page/<int:page>',
f'/{base}/tags/<string:slug_tags>',
f'/{base}/tags/<string:slug_tags>/page/<int:page>',
]
], type='http', auth="public", website=True, sitemap=sitemap_event, list_as_website_content=_lt("Events"))
def events(self, page=1, slug_tags=None, **searches):
if (slug_tags or searches.get('tags', '[]').count(',') > 0) and request.httprequest.method == 'GET' and not searches.get('prevent_redirect'):
# Previously, the tags were searched using GET, which caused issues with crawlers (too many hits)
# We replaced those with POST to avoid that, but it's not sufficient as bots "remember" crawled pages for a while
# This permanent redirect is placed to instruct the bots that this page is no longer valid
# Note: We allow a single tag to be GET, to keep crawlers & indexes on those pages
# What we really want to avoid is combinatorial explosions
# (Tags are formed as a JSON array, so we count ',' to keep it simple)
# TODO: remove in a few stable versions (v19?), including the "prevent_redirect" param in templates
return request.redirect('/event', code=301)
Event = request.env['event.event']
SudoEventType = request.env['event.type'].sudo()
searches.setdefault('search', '')
searches.setdefault('date', 'scheduled')
searches.setdefault('tags', '')
searches.setdefault('type', 'all')
searches.setdefault('country', 'all')
# The previous name of the 'scheduled' filter is 'upcoming' and may still be present in URL's saved by users.
if searches['date'] == 'upcoming':
searches['date'] = 'scheduled'
website = request.website
step = 12 # Number of events per page
options = self._get_events_search_options(slug_tags, **searches)
order = 'date_begin'
if searches.get('date', 'scheduled') == 'old':
order = 'date_begin desc'
order = 'is_published desc, ' + order + ', id desc'
search = searches.get('search')
event_count, details, fuzzy_search_term = website._search_with_fuzzy("events", search,
limit=page * step, order=order, options=options)
event_details = details[0]
events = event_details.get('results', Event)
events = events[(page - 1) * step:page * step]
# count by domains without self search
domain_search = Domain('name', 'ilike', fuzzy_search_term or searches['search']) if searches['search'] else Domain.TRUE
no_date_domain = Domain.AND(event_details['no_date_domain'])
dates = event_details['dates']
for date in dates:
if date[0] not in ['all', 'old']:
date[3] = Event.search_count(no_date_domain & domain_search & Domain(date[2]))
no_country_domain = Domain.AND(event_details['no_country_domain'])
country_groups = Event._read_group(
no_country_domain & domain_search,
["country_id"], ["__count"], order="country_id")
countries = [{
'country_id_count': sum(count for __, count in country_groups),
'country_id': (0, _("All Countries")),
}]
for g_country, count in country_groups:
countries.append({
'country_id_count': count,
'country_id': g_country and (g_country.id, g_country.sudo().display_name),
})
search_tags = self._extract_searched_event_tags(searches, slug_tags)
current_date = event_details['current_date']
current_type = None
current_country = None
if searches["type"] != 'all':
current_type = SudoEventType.browse(int(searches['type']))
if searches["country"] != 'all' and searches["country"] != 'online':
current_country = request.env['res.country'].browse(int(searches['country']))
pager = website.pager(
url=f"/event/tags/{slug_tags}" if slug_tags else "/event",
url_args=searches,
total=event_count,
page=page,
step=step,
scope=5)
keep = QueryURL('/event', ['tags'],
tags=slug_tags,
**{
key: value for key, value in searches.items() if (
key != 'tags' and (
key == 'search' or
(value != 'scheduled' if key == 'date' else value != 'all'))
)
})
searches['search'] = fuzzy_search_term or search
values = {
'current_date': current_date,
'current_country': current_country,
'current_type': current_type,
'event_ids': events, # event_ids used in website_event_track so we keep name as it is
'dates': dates,
'categories': request.env['event.tag.category'].search([
('is_published', '=', True), '|', ('website_id', '=', website.id), ('website_id', '=', False)
]),
'countries': countries,
'pager': pager,
'searches': searches,
'search_tags': search_tags,
'keep_event_url': keep,
'slugify_tags': self._slugify_tags,
'search_count': event_count,
'original_search': fuzzy_search_term and search,
'website': website
}
return request.render("website_event.index", values)
# ------------------------------------------------------------
# EVENT PAGE
# ------------------------------------------------------------
@http.route(['''/event/<model("event.event"):event>/page/<path:page>'''], type='http', auth="public", website=True, sitemap=False, readonly=True)
def event_page(self, event, page, **post):
values = {
'event': event,
'event_page': True,
}
base_page_name = page
if '.' not in page:
page = 'website_event.%s' % page
view = request.env["website.event.menu"].sudo().search([
("event_id", "=", event.id),
'|',
("view_id.key", "ilike", page),
("view_id.key", "ilike", f'website_event.{event.name}-{base_page_name.split("/")[-1]}'),
], limit=1).view_id
try:
# Every event page view should have its own SEO.
page = view.key if view else page
values['seo_object'] = request.website.get_template(page)
values['main_object'] = event
except ValueError:
# page not found
page = 'website.page_404'
return request.render(page, values)
@http.route(['''/event/<model("event.event"):event>'''], type='http', auth="public", website=True, sitemap=True, readonly=True)
def event(self, event, **post):
if event.menu_id and event.menu_id.child_id:
target_url = event.menu_id.child_id[0].url
else:
target_url = '/event/%s/register' % str(event.id)
if post.get('enable_editor') == '1':
target_url += '?enable_editor=1'
return request.redirect(target_url)
@http.route(['''/event/<model("event.event"):event>/register'''], type='http', auth="public", website=True, sitemap=False, readonly=True)
def event_register(self, event, **post):
values = self._prepare_event_register_values(event, **post)
return request.render("website_event.event_description_full", values)
def _prepare_event_register_values(self, event, **post):
"""Return the require values to render the template."""
urls = lazy(event._get_event_resource_urls)
return {
'event': event,
'main_object': event,
'range': range,
'google_url': lazy(lambda: urls.get('google_url')),
'iCal_url': lazy(lambda: urls.get('iCal_url')),
'registration_error_code': post.get('registration_error_code'),
'slots': event.event_slot_ids._filter_open_slots().grouped('date'),
'website_visitor_timezone': request.env['website.visitor']._get_visitor_timezone(),
}
def _process_tickets_form(self, event, form_details):
""" Process posted data about ticket order. Generic ticket are supported
for event without tickets (generic registration).
:return: list of order per ticket: [{
'id': if of ticket if any (0 if no ticket),
'ticket': browse record of ticket if any (None if no ticket),
'name': ticket name (or generic 'Registration' name if no ticket),
'quantity': number of registrations for that ticket,
'current_limit_per_order': maximum of ticket orderable
}, {...}]
"""
ticket_order = {}
for key, value in form_details.items():
registration_items = key.split('nb_register-')
if len(registration_items) != 2:
continue
ticket_order[int(registration_items[1])] = int(value)
ticket_dict = dict((ticket.id, ticket) for ticket in request.env['event.event.ticket'].sudo().search([
('id', 'in', [tid for tid in ticket_order.keys() if tid]),
('event_id', '=', event.id)
]))
tickets = request.env['event.event.ticket'].browse(ticket_dict.keys())
slot = request.env['event.slot'].browse(int(slot)) if (slot := form_details.get("event_slot_id", False)) else slot
tickets_limits = tickets._get_current_limit_per_order(slot, event)
return [{
'id': tid if ticket_dict.get(tid) else 0,
'ticket': ticket_dict.get(tid),
'name': ticket_dict[tid]['name'] if ticket_dict.get(tid) else _('Registration'),
'quantity': count,
'current_limit_per_order': tickets_limits.get(tid, next(iter(tickets_limits.values()))), # next is used if the ticket id isn't known (alone event case)
} for tid, count in ticket_order.items() if count]
@http.route(['/event/<model("event.event"):event>/registration/slot/<int:slot_id>/tickets'], type='jsonrpc', auth="public", methods=['POST'], website=True)
def registration_tickets(self, event, slot_id):
""" After slot selection, render ticket selection modal.
To restrict the selectable number of tickets, give the slot seats available and
each slot tickets seats available to the template.
"""
slot = request.env['event.slot'].browse(slot_id)
slot_tickets = [
(slot, ticket)
for ticket in event.event_ticket_ids
]
return request.env['ir.ui.view']._render_template("website_event.modal_ticket_registration", {
'event': event,
'event_slot': slot,
'seats_available_slot_tickets': {
ticket.id: availability
for (_, ticket), availability in zip(slot_tickets, event._get_seats_availability(slot_tickets))
}
})
@http.route(['/event/<model("event.event"):event>/registration/new'], type='jsonrpc', auth="public", methods=['POST'], website=True)
def registration_new(self, event, **post):
""" After (slot and) tickets selection, render attendee(s) registration form.
Slot and tickets availability check already performed in the template. """
tickets = self._process_tickets_form(event, post)
slot_id = post.get('event_slot_id', False)
# Availability check needed as the total number of tickets can exceed the event/slot available tickets
availability_check = True
# Double check to verify that we are ordering fewer tickets than the limit conditions set
limit_check = not any(ticket['quantity'] > ticket['current_limit_per_order'] for ticket in tickets)
if event.seats_limited:
ordered_seats = 0
for ticket in tickets:
ordered_seats += ticket['quantity']
seats_available = event.seats_available
if slot_id:
seats_available = request.env['event.slot'].browse(int(slot_id)).seats_available or 0
if seats_available < ordered_seats:
availability_check = False
if not tickets:
return False
default_first_attendee = {}
if not request.env.user._is_public():
default_first_attendee = {
"name": request.env.user.name,
"email": request.env.user.email,
"phone": request.env.user.phone,
}
else:
visitor = request.env['website.visitor']._get_visitor_from_request()
if visitor.email:
default_first_attendee = {
"name": visitor.display_name,
"email": visitor.email,
"phone": visitor.mobile,
}
return request.env['ir.ui.view']._render_template("website_event.registration_attendee_details", {
'tickets': tickets,
'event_slot_id': slot_id,
'event': event,
'availability_check': availability_check,
'default_first_attendee': default_first_attendee,
'limit_check': limit_check,
})
def _process_attendees_form(self, event, form_details):
""" Process data posted from the attendee details form.
Extracts question answers:
- For both questions asked 'once_per_order' and questions asked to every attendee
- For questions of type 'simple_choice', extracting the suggested answer id
- For questions of type 'text_box', extracting the text answer of the attendee.
:param form_details: posted data from frontend registration form, like
{'1-name': 'r', '1-email': 'r@r.com', '1-phone': '', '1-event_slot_id': '1', '1-event_ticket_id': '1'}
"""
allowed_fields = request.env['event.registration']._get_website_registration_allowed_fields()
registration_fields = {key: v for key, v in request.env['event.registration']._fields.items() if key in allowed_fields}
for ticket_id in list(filter(lambda x: x is not None, [form_details[field] if 'event_ticket_id' in field else None for field in form_details.keys()])):
if int(ticket_id) not in event.event_ticket_ids.ids and len(event.event_ticket_ids.ids) > 0:
raise UserError(_("This ticket is not available for sale for this event"))
registrations = {}
general_answer_ids = []
general_identification_answers = {}
# as we may have several questions populating the same field (e.g: the phone)
# we use this to hold the fields that have already been handled
# goal is to use the answer to the first question of every 'type' (aka name / phone / email / company name)
already_handled_fields_data = {}
for key, value in form_details.items():
if not value or '-' not in key:
continue
key_values = key.split('-')
# Special case for handling event_ticket_id data that holds only 2 values
if len(key_values) == 2:
registration_index, field_name = key_values
if field_name not in registration_fields:
continue
# Only cast when needed, as it might crash here for custom inputs in overrides
if isinstance(registration_fields[field_name], (fields.Many2one, fields.Integer)):
value = int(value) or False
registrations.setdefault(registration_index, dict())[field_name] = value
continue
if len(key_values) != 3:
continue
registration_index, question_type, question_id = key_values
answer_values = None
if question_type == 'simple_choice':
answer_values = {
'question_id': int(question_id),
'value_answer_id': int(value)
}
else:
answer_values = {
'question_id': int(question_id),
'value_text_box': value
}
if answer_values and not int(registration_index):
general_answer_ids.append((0, 0, answer_values))
elif answer_values:
registrations.setdefault(registration_index, dict())\
.setdefault('registration_answer_ids', list()).append((0, 0, answer_values))
if question_type in ('name', 'email', 'phone', 'company_name')\
and question_type not in already_handled_fields_data.get(registration_index, []):
if question_type not in registration_fields:
continue
field_name = question_type
already_handled_fields_data.setdefault(registration_index, list()).append(field_name)
if not int(registration_index):
general_identification_answers[field_name] = value
else:
registrations.setdefault(registration_index, dict())[field_name] = value
if general_answer_ids:
for registration in registrations.values():
registration.setdefault('registration_answer_ids', list()).extend(general_answer_ids)
if general_identification_answers:
for registration in registrations.values():
registration.update(general_identification_answers)
return list(registrations.values())
def _create_attendees_from_registration_post(self, event, registration_data):
""" Also try to set a visitor (from request) and
a partner (if visitor linked to a user for example). Purpose is to gather
as much informations as possible, notably to ease future communications.
Also try to update visitor informations based on registration info. """
visitor_sudo = request.env['website.visitor']._get_visitor_from_request(force_create=True)
registrations_to_create = []
for registration_values in registration_data:
registration_values['event_id'] = event.id
if not registration_values.get('partner_id') and visitor_sudo.partner_id:
registration_values['partner_id'] = visitor_sudo.partner_id.id
elif not registration_values.get('partner_id'):
registration_values['partner_id'] = False if request.env.user._is_public() else request.env.user.partner_id.id
# update registration based on visitor
registration_values['visitor_id'] = visitor_sudo.id
registrations_to_create.append(registration_values)
return request.env['event.registration'].sudo().create(registrations_to_create)
@http.route(['''/event/<model("event.event"):event>/registration/confirm'''], type='http', auth="public", methods=['POST'], website=True)
def registration_confirm(self, event, **post):
""" Check before creating and finalize the creation of the registrations
that we have enough seats for all selected tickets.
If we don't, the user is instead redirected to page to register with a
formatted error message. """
try:
request.env['ir.http']._verify_request_recaptcha_token('website_event_registration')
except UserError:
return request.redirect('/event/%s/register?registration_error_code=recaptcha_failed' % event.id)
registrations_data = self._process_attendees_form(event, post)
counter_per_combination = Counter((registration.get('event_slot_id', False), registration['event_ticket_id']) for registration in registrations_data)
slot_ids = {slot_id for slot_id, _ in counter_per_combination if slot_id}
ticket_ids = {ticket_id for _, ticket_id in counter_per_combination if ticket_id}
slots_per_id = {slot.id: slot for slot in self.env['event.slot'].browse(slot_ids)}
tickets_per_id = {ticket.id: ticket for ticket in self.env['event.event.ticket'].browse(ticket_ids)}
try:
event._verify_seats_availability(list({
(slots_per_id.get(slot_id, False), tickets_per_id.get(ticket_id, False), count)
for (slot_id, ticket_id), count in counter_per_combination.items()
}))
except ValidationError:
return request.redirect('/event/%s/register?registration_error_code=insufficient_seats' % event.id)
attendees_sudo = self._create_attendees_from_registration_post(event, registrations_data)
return request.redirect(('/event/%s/registration/success?' % event.id) + werkzeug.urls.url_encode({'registration_ids': ",".join([str(id) for id in attendees_sudo.ids])}))
@http.route(['/event/<model("event.event"):event>/registration/success'], type='http', auth="public", methods=['GET'], website=True, sitemap=False)
def event_registration_success(self, event, registration_ids):
# fetch the related registrations, make sure they belong to the correct visitor / event pair
visitor = request.env['website.visitor']._get_visitor_from_request()
if not visitor:
raise NotFound()
attendees_sudo = request.env['event.registration'].sudo().search([
('id', 'in', [str(registration_id) for registration_id in registration_ids.split(',')]),
('event_id', '=', event.id),
('visitor_id', '=', visitor.id),
])
return request.render("website_event.registration_complete",
self._get_registration_confirm_values(event, attendees_sudo))
def _get_registration_confirm_values(self, event, attendees_sudo):
slot = attendees_sudo.event_slot_id
urls = event._get_event_resource_urls(slot)
return {
'attendees': attendees_sudo,
'event': event,
'google_url': urls.get('google_url'),
'iCal_url': urls.get('iCal_url'),
'slot': slot,
'slots': event.event_slot_ids._filter_open_slots().grouped('date'),
'website_visitor_timezone': request.env['website.visitor']._get_visitor_timezone(),
}
# ------------------------------------------------------------
# TOOLS (HELPERS)
# ------------------------------------------------------------
def get_formated_date(self, event):
start_date = fields.Datetime.from_string(event.date_begin).date()
end_date = fields.Datetime.from_string(event.date_end).date()
month = babel.dates.get_month_names('abbreviated', locale=get_lang(event.env).code)[start_date.month]
return ('%s %s%s') % (month, start_date.strftime("%e"), (end_date != start_date and ("-" + end_date.strftime("%e")) or ""))
def _extract_searched_event_tags(self, searches, slug_tags):
tags = request.env['event.tag']
if slug_tags:
tags = self._event_search_tags_slug(slug_tags)
elif 'tags' in searches:
tags = self._event_search_tags_ids(searches['tags'])
return tags
def _slugify_tags(self, tag_ids, toggle_tag_id=None):
""" Prepares a comma separated slugified tags for the sake of readable URLs.
:param toggle_tag_id: add the tag being clicked to the already
selected tags as well as in URL; if tag is already selected
by the user it is removed from the selected tags (and so from the URL);
"""
tag_ids = list(tag_ids)
if toggle_tag_id and toggle_tag_id in tag_ids:
tag_ids.remove(toggle_tag_id)
elif toggle_tag_id:
tag_ids.append(toggle_tag_id)
return ','.join(request.env['ir.http']._slug(tag_id) for tag_id in request.env['event.tag'].browse(tag_ids)) or ''
def _event_search_tags_ids(self, search_tags):
""" Input: %5B4%5D """
EventTag = request.env['event.tag']
try:
tag_ids = literal_eval(search_tags or '')
except Exception: # noqa: BLE001
return EventTag
return EventTag.search([('id', 'in', tag_ids)]) if tag_ids else EventTag
def _event_search_tags_slug(self, search_tags):
""" Input: event-1,event-2 """
EventTag = request.env['event.tag']
try:
tag_ids = list(filter(None, [request.env['ir.http']._unslug(tag)[1] for tag in (search_tags or '').split(',')]))
except Exception: # noqa: BLE001
return EventTag
return EventTag.search([('id', 'in', tag_ids)]) if tag_ids else EventTag