19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -1,12 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# some inherit require to define those models first
from . import event_type
from . import event_type_mail
from . import event_type_ticket
from . import event_event
from . import event_mail
from . import event_mail_registration
from . import event_mail_slot
from . import event_registration
from . import event_slot
from . import event_stage
from . import event_tag
from . import event_ticket
from . import mail_template
from . import res_config_settings
from . import res_partner
from . import event_question_answer
from . import event_registration_answer
from . import event_question

View file

@ -1,15 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import pytz
import textwrap
import urllib.parse
from datetime import datetime, timedelta
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from markupsafe import escape
from urllib.parse import urlparse
from odoo import _, api, Command, fields, models
from odoo import _, api, Command, fields, models, tools
from odoo.addons.base.models.res_partner import _tz_get
from odoo.tools import format_datetime, is_html_empty
from odoo.exceptions import UserError, ValidationError
from odoo.exceptions import ValidationError
from odoo.fields import Datetime, Domain
from odoo.tools import format_date, format_datetime, format_time, frozendict
from odoo.tools.mail import is_html_empty, html_to_inner_content
from odoo.tools.misc import formatLang
from odoo.tools.translate import html_translate
@ -22,85 +28,30 @@ except ImportError:
vobject = None
class EventType(models.Model):
_name = 'event.type'
_description = 'Event Template'
_order = 'sequence, id'
def _default_event_mail_type_ids(self):
return [(0, 0,
{'notification_type': 'mail',
'interval_nbr': 0,
'interval_unit': 'now',
'interval_type': 'after_sub',
'template_ref': 'mail.template, %i' % self.env.ref('event.event_subscription').id,
}),
(0, 0,
{'notification_type': 'mail',
'interval_nbr': 1,
'interval_unit': 'hours',
'interval_type': 'before_event',
'template_ref': 'mail.template, %i' % self.env.ref('event.event_reminder').id,
}),
(0, 0,
{'notification_type': 'mail',
'interval_nbr': 3,
'interval_unit': 'days',
'interval_type': 'before_event',
'template_ref': 'mail.template, %i' % self.env.ref('event.event_reminder').id,
})]
name = fields.Char('Event Template', required=True, translate=True)
note = fields.Html(string='Note')
sequence = fields.Integer()
# tickets
event_type_ticket_ids = fields.One2many('event.type.ticket', 'event_type_id', string='Tickets')
tag_ids = fields.Many2many('event.tag', string="Tags")
# registration
has_seats_limitation = fields.Boolean('Limited Seats')
seats_max = fields.Integer(
'Maximum Registrations', compute='_compute_seats_max',
readonly=False, store=True,
help="It will select this default maximum value when you choose this event")
auto_confirm = fields.Boolean(
'Automatically Confirm Registrations', default=True,
help="Events and registrations will automatically be confirmed "
"upon creation, easing the flow for simple events.")
default_timezone = fields.Selection(
_tz_get, string='Timezone', default=lambda self: self.env.user.tz or 'UTC')
# communication
event_type_mail_ids = fields.One2many(
'event.type.mail', 'event_type_id', string='Mail Schedule',
default=_default_event_mail_type_ids)
# ticket reports
ticket_instructions = fields.Html('Ticket Instructions', translate=True,
help="This information will be printed on your tickets.")
@api.depends('has_seats_limitation')
def _compute_seats_max(self):
for template in self:
if not template.has_seats_limitation:
template.seats_max = 0
class EventEvent(models.Model):
"""Event"""
_name = 'event.event'
_description = 'Event'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'date_begin'
_order = 'date_begin, id'
# Maximum number of tickets that can be ordered at one time on a same line
EVENT_MAX_TICKETS = 30
@api.model
def default_get(self, fields_list):
result = super().default_get(fields_list)
if 'date_begin' in fields_list and 'date_begin' not in result:
now = fields.Datetime.now()
def default_get(self, fields):
result = super().default_get(fields)
if 'date_begin' in fields and 'date_begin' not in result:
now = Datetime.now()
# Round the datetime to the nearest half hour (e.g. 08:17 => 08:30 and 08:37 => 09:00)
result['date_begin'] = now.replace(second=0, microsecond=0) + timedelta(minutes=-now.minute % 30)
if 'date_end' in fields_list and 'date_end' not in result and result.get('date_begin'):
if 'date_end' in fields and 'date_end' not in result and result.get('date_begin'):
result['date_end'] = result['date_begin'] + timedelta(days=1)
return result
def get_kiosk_url(self):
return self.get_base_url() + "/odoo/registration-desk"
def _get_default_stage_id(self):
return self.env['event.stage'].search([], limit=1)
@ -112,6 +63,13 @@ class EventEvent(models.Model):
def _default_event_mail_ids(self):
return self.env['event.type']._default_event_mail_type_ids()
@api.model
def _lang_get(self):
return self.env['res.lang'].get_installed()
def _default_question_ids(self):
return self.env['event.type']._default_question_ids()
name = fields.Char(string='Event', translate=True, required=True)
note = fields.Html(string='Note', store=True, compute="_compute_note", readonly=False)
description = fields.Html(string='Description', translate=html_translate, sanitize_attributes=False, sanitize_form=False, default=_default_description)
@ -119,6 +77,7 @@ class EventEvent(models.Model):
user_id = fields.Many2one(
'res.users', string='Responsible', tracking=True,
default=lambda self: self.env.user)
use_barcode = fields.Boolean(compute='_compute_use_barcode')
company_id = fields.Many2one(
'res.company', string='Company', change_default=True,
default=lambda self: self.env.company,
@ -126,30 +85,34 @@ class EventEvent(models.Model):
organizer_id = fields.Many2one(
'res.partner', string='Organizer', tracking=True,
default=lambda self: self.env.company.partner_id,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
event_type_id = fields.Many2one('event.type', string='Template', ondelete='set null')
check_company=True)
event_type_id = fields.Many2one(
'event.type', string='Template', ondelete='set null',
help="Choose a template to auto-fill tickets, communications, descriptions and other fields.")
event_mail_ids = fields.One2many(
'event.mail', 'event_id', string='Mail Schedule', copy=True,
compute='_compute_event_mail_ids', readonly=False, store=True)
tag_ids = fields.Many2many(
'event.tag', string="Tags", readonly=False,
store=True, compute="_compute_tag_ids")
# properties
registration_properties_definition = fields.PropertiesDefinition('Registration Properties')
# Kanban fields
kanban_state = fields.Selection([('normal', 'In Progress'), ('done', 'Done'), ('blocked', 'Blocked')], default='normal', copy=False)
kanban_state_label = fields.Char(
string='Kanban State Label', compute='_compute_kanban_state_label',
store=True, tracking=True)
kanban_state = fields.Selection([
('normal', 'In Progress'),
('done', 'Ready for Next Stage'),
('blocked', 'Blocked'),
('cancel', 'Cancelled')
], default='normal', copy=False, compute='_compute_kanban_state', readonly=False, store=True, tracking=True)
stage_id = fields.Many2one(
'event.stage', ondelete='restrict', default=_get_default_stage_id,
group_expand='_read_group_stage_ids', tracking=True, copy=False)
legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True)
legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True)
legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True)
group_expand='_read_group_expand_full', tracking=True, copy=False)
# Seats and computation
seats_max = fields.Integer(
string='Maximum Attendees',
compute='_compute_seats_max', readonly=False, store=True,
help="For each event you can define a maximum registration of seats(number of attendees), above this numbers the registrations are not accepted.")
help="For each event you can define a maximum registration of seats(number of attendees), above this number the registrations are not accepted. "
"If the event has multiple slots, this maximum number is applied per slot.")
seats_limited = fields.Boolean('Limit Attendees', required=True, compute='_compute_seats_limited',
precompute=True, readonly=False, store=True)
seats_reserved = fields.Integer(
@ -158,23 +121,23 @@ class EventEvent(models.Model):
seats_available = fields.Integer(
string='Available Seats',
store=False, readonly=True, compute='_compute_seats')
seats_unconfirmed = fields.Integer(
string='Unconfirmed Registrations',
store=False, readonly=True, compute='_compute_seats')
seats_used = fields.Integer(
string='Number of Attendees',
store=False, readonly=True, compute='_compute_seats')
seats_expected = fields.Integer(
string='Number of Expected Attendees',
seats_taken = fields.Integer(
string='Number of Taken Seats',
store=False, readonly=True, compute='_compute_seats')
# Registration fields
auto_confirm = fields.Boolean(
string='Autoconfirmation', compute='_compute_auto_confirm', readonly=False, store=True,
help='Autoconfirm Registrations. Registrations will automatically be confirmed upon creation.')
registration_ids = fields.One2many('event.registration', 'event_id', string='Attendees')
is_multi_slots = fields.Boolean("Is Multi Slots", copy=True,
help="Allow multiple time slots. "
"The communications, the maximum number of attendees and the maximum number of tickets registrations "
"are defined for each time slot instead of the whole event.")
event_slot_ids = fields.One2many("event.slot", "event_id", "Slots", copy=True)
event_slot_count = fields.Integer("Slots Count", compute="_compute_event_slot_count")
event_ticket_ids = fields.One2many(
'event.event.ticket', 'event_id', string='Event Ticket', copy=True,
compute='_compute_event_ticket_ids', readonly=False, store=True)
compute='_compute_event_ticket_ids', readonly=False, store=True, precompute=True)
event_registrations_started = fields.Boolean(
'Registrations started', compute='_compute_event_registrations_started',
help="registrations have started if the current datetime is after the earliest starting date of tickets."
@ -182,7 +145,7 @@ class EventEvent(models.Model):
event_registrations_open = fields.Boolean(
'Registration open', compute='_compute_event_registrations_open', compute_sudo=True,
help="Registrations are open if:\n"
"- the event is not ended\n"
"- the event is not ended or not cancelled\n"
"- there are seats available on event\n"
"- the tickets are sellable (if ticketing is used)")
event_registrations_sold_out = fields.Boolean(
@ -191,7 +154,6 @@ class EventEvent(models.Model):
start_sale_datetime = fields.Datetime(
'Start sale date', compute='_compute_start_sale_date',
help='If ticketing is used, contains the earliest starting sale date of tickets.')
# Date fields
date_tz = fields.Selection(
_tz_get, string='Display Timezone', required=True,
@ -200,15 +162,15 @@ class EventEvent(models.Model):
date_begin = fields.Datetime(string='Start Date', required=True, tracking=True,
help="When the event is scheduled to take place (expressed in your local timezone on the form view).")
date_end = fields.Datetime(string='End Date', required=True, tracking=True)
date_begin_located = fields.Char(string='Start Date Located', compute='_compute_date_begin_tz')
date_end_located = fields.Char(string='End Date Located', compute='_compute_date_end_tz')
is_ongoing = fields.Boolean('Is Ongoing', compute='_compute_is_ongoing', search='_search_is_ongoing')
is_one_day = fields.Boolean(compute='_compute_field_is_one_day')
is_finished = fields.Boolean(compute='_compute_is_finished', search='_search_is_finished')
# Location and communication
address_id = fields.Many2one(
'res.partner', string='Venue', default=lambda self: self.env.company.partner_id.id,
tracking=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
check_company=True,
tracking=True
)
address_search = fields.Many2one(
'res.partner', string='Address', compute='_compute_address_search', search='_search_address_search')
address_inline = fields.Char(
@ -216,30 +178,83 @@ class EventEvent(models.Model):
compute_sudo=True)
country_id = fields.Many2one(
'res.country', 'Country', related='address_id.country_id', readonly=False, store=True)
event_url = fields.Char(
string='Online Event URL', compute='_compute_event_url', readonly=False, store=True,
help="Link where the online event will take place.",
)
event_share_url = fields.Char(string='Event Share URL', compute='_compute_event_share_url')
lang = fields.Selection(_lang_get, string='Language',
help="All the communication emails sent to attendees will be translated in this language.")
# ticket reports
badge_format = fields.Selection(
string='Badge Dimension',
selection=[
('A4_french_fold', 'A4 foldable'),
('A6', 'A6'),
('four_per_sheet', '4 per sheet'),
], default='A6', required=True)
badge_image = fields.Image('Badge Background', max_width=1024, max_height=1024)
ticket_instructions = fields.Html('Ticket Instructions', translate=True,
compute='_compute_ticket_instructions', store=True, readonly=False,
help="This information will be printed on your tickets.")
# questions
question_ids = fields.Many2many('event.question', 'event_event_event_question_rel',
string='Questions', compute='_compute_question_ids', readonly=False, store=True, precompute=True)
general_question_ids = fields.Many2many('event.question', 'event_event_event_question_rel',
string='General Questions', domain=[('once_per_order', '=', True)])
specific_question_ids = fields.Many2many('event.question', 'event_event_event_question_rel',
string='Specific Questions', domain=[('once_per_order', '=', False)])
@api.depends('stage_id', 'kanban_state')
def _compute_kanban_state_label(self):
def _compute_use_barcode(self):
use_barcode = self.env['ir.config_parameter'].sudo().get_param('event.use_event_barcode') == 'True'
for record in self:
record.use_barcode = use_barcode
def _compute_event_share_url(self):
"""Get the URL to use to redirect to the event, overriden in website for fallback."""
for event in self:
if event.kanban_state == 'normal':
event.kanban_state_label = event.stage_id.legend_normal
elif event.kanban_state == 'blocked':
event.kanban_state_label = event.stage_id.legend_blocked
else:
event.kanban_state_label = event.stage_id.legend_done
event.event_share_url = event.event_url
@api.depends('seats_max', 'registration_ids.state', 'registration_ids.active')
@api.depends('event_type_id')
def _compute_question_ids(self):
""" Update event questions from its event type. Depends are set only on
event_type_id itself to emulate an onchange. Changing event type content
itself should not trigger this method.
When synchronizing questions:
* lines with no registered answers for the event are removed;
* type lines are added;
"""
for event in self:
questions_tokeep_ids = []
if self._origin.question_ids:
# Keep questions with attendee answers for the event.
questions_tokeep_ids.extend(
(event.registration_ids.registration_answer_ids.question_id & self._origin.question_ids).ids
)
if not event.event_type_id and not questions_tokeep_ids:
event.question_ids = self._default_question_ids()
continue
if questions_tokeep_ids:
questions_toremove = event._origin.question_ids.filtered(
lambda question: question.id not in questions_tokeep_ids)
command = [(3, question.id) for question in questions_toremove]
else:
command = [(5, 0)]
event.question_ids = command
event.question_ids = [Command.link(question_id.id) for question_id in event.event_type_id.question_ids]
@api.depends('event_slot_count', 'is_multi_slots', 'seats_max', 'registration_ids.state', 'registration_ids.active')
def _compute_seats(self):
""" Determine reserved, available, reserved but unconfirmed and used seats. """
""" Determine available, reserved, used and taken seats. """
# initialize fields to 0
for event in self:
event.seats_unconfirmed = event.seats_reserved = event.seats_used = event.seats_available = 0
event.seats_reserved = event.seats_used = event.seats_available = 0
# aggregate registrations by event and by state
state_field = {
'draft': 'seats_unconfirmed',
'open': 'seats_reserved',
'done': 'seats_used',
}
@ -248,22 +263,23 @@ class EventEvent(models.Model):
if self.ids:
query = """ SELECT event_id, state, count(event_id)
FROM event_registration
WHERE event_id IN %s AND state IN ('draft', 'open', 'done') AND active = true
WHERE event_id IN %s AND state IN ('open', 'done') AND active = true
GROUP BY event_id, state
"""
self.env['event.registration'].flush_model(['event_id', 'state', 'active'])
self._cr.execute(query, (tuple(self.ids),))
res = self._cr.fetchall()
self.env.cr.execute(query, (tuple(self.ids),))
res = self.env.cr.fetchall()
for event_id, state, num in res:
results[event_id][state_field[state]] = num
# compute seats_available and expected
for event in self:
event.update(results.get(event._origin.id or event.id, base_vals))
if event.seats_max > 0:
event.seats_available = event.seats_max - (event.seats_reserved + event.seats_used)
seats_max = event.seats_max * event.event_slot_count if event.is_multi_slots else event.seats_max
if seats_max > 0:
event.seats_available = seats_max - (event.seats_reserved + event.seats_used)
event.seats_expected = event.seats_unconfirmed + event.seats_reserved + event.seats_used
event.seats_taken = event.seats_reserved + event.seats_used
@api.depends('date_tz', 'start_sale_datetime')
def _compute_event_registrations_started(self):
@ -281,6 +297,7 @@ class EventEvent(models.Model):
def _compute_event_registrations_open(self):
""" Compute whether people may take registrations for this event
* for cancelled events, registrations are not open;
* event.date_end -> if event is done, registrations are not open anymore;
* event.start_sale_datetime -> lowest start date of tickets (if any; start_sale_datetime
is False if no ticket are defined, see _compute_start_sale_date);
@ -291,10 +308,29 @@ class EventEvent(models.Model):
event = event._set_tz_context()
current_datetime = fields.Datetime.context_timestamp(event, fields.Datetime.now())
date_end_tz = event.date_end.astimezone(pytz.timezone(event.date_tz or 'UTC')) if event.date_end else False
event.event_registrations_open = event.event_registrations_started and \
event.event_registrations_open = event.kanban_state != 'cancel' and \
event.event_registrations_started and \
(date_end_tz >= current_datetime if date_end_tz else True) and \
(not event.seats_limited or not event.seats_max or event.seats_available) and \
(not event.event_ticket_ids or any(ticket.sale_available for ticket in event.event_ticket_ids))
(
# Not multi slots: open if no tickets or at least a sale available ticket
(not event.is_multi_slots and
(not event.event_ticket_ids or any(ticket.sale_available for ticket in event.event_ticket_ids)))
or
# Multi slots: open if at least a slot and no tickets or at least an ongoing ticket with availability
(event.is_multi_slots and event.event_slot_count and (
not event.event_ticket_ids or any(
ticket.is_launched and not ticket.is_expired and (
any(availability is None or availability > 0
for availability in event._get_seats_availability([
(slot, ticket)
for slot in event.event_slot_ids
])
)
) for ticket in event.event_ticket_ids
)
))
)
@api.depends('event_ticket_ids.start_sale_datetime')
def _compute_start_sale_date(self):
@ -304,38 +340,32 @@ class EventEvent(models.Model):
start_dates = [ticket.start_sale_datetime for ticket in event.event_ticket_ids if not ticket.is_expired]
event.start_sale_datetime = min(start_dates) if start_dates and all(start_dates) else False
@api.depends('event_ticket_ids.sale_available', 'seats_available', 'seats_limited')
@api.depends('event_slot_ids', 'event_ticket_ids.sale_available', 'seats_available', 'seats_limited')
def _compute_event_registrations_sold_out(self):
"""Note that max seats limits for events and sum of limits for all its tickets may not be
equal to enable flexibility.
E.g. max 20 seats for ticket A, 20 seats for ticket B
* With max 20 seats for the event
* Without limit set on the event (=40, but the customer didn't explicitly write 40)
When the event is multi slots, instead of checking if every tickets is sold out,
checking if every slot-ticket combination is sold out.
"""
for event in self:
event.event_registrations_sold_out = (
(event.seats_limited and event.seats_max and not event.seats_available)
or (event.event_ticket_ids and all(ticket.is_sold_out for ticket in event.event_ticket_ids))
(event.seats_limited and event.seats_max and not event.seats_available > 0)
or (event.event_ticket_ids and (
not any(availability is None or availability > 0
for availability in event._get_seats_availability([
(slot, ticket)
for slot in event.event_slot_ids
for ticket in event.event_ticket_ids
])
)
if event.is_multi_slots else
all(ticket.is_sold_out for ticket in event.event_ticket_ids)
))
)
@api.depends('date_tz', 'date_begin')
def _compute_date_begin_tz(self):
for event in self:
if event.date_begin:
event.date_begin_located = format_datetime(
self.env, event.date_begin, tz=event.date_tz, dt_format='medium')
else:
event.date_begin_located = False
@api.depends('date_tz', 'date_end')
def _compute_date_end_tz(self):
for event in self:
if event.date_end:
event.date_end_located = format_datetime(
self.env, event.date_end, tz=event.date_tz, dt_format='medium')
else:
event.date_end_located = False
@api.depends('date_begin', 'date_end')
def _compute_is_ongoing(self):
now = fields.Datetime.now()
@ -343,17 +373,10 @@ class EventEvent(models.Model):
event.is_ongoing = event.date_begin <= now < event.date_end
def _search_is_ongoing(self, operator, value):
if operator not in ['=', '!=']:
raise UserError(_('This operator is not supported'))
if not isinstance(value, bool):
raise UserError(_('Value should be True or False (not %s)') % value)
if operator != 'in':
return NotImplemented
now = fields.Datetime.now()
if (operator == '=' and value) or (operator == '!=' and not value):
domain = [('date_begin', '<=', now), ('date_end', '>', now)]
else:
domain = ['|', ('date_begin', '>', now), ('date_end', '<=', now)]
event_ids = self.env['event.event']._search(domain)
return [('id', 'in', event_ids)]
return [('date_begin', '<=', now), ('date_end', '>', now)]
@api.depends('date_begin', 'date_end', 'date_tz')
def _compute_field_is_one_day(self):
@ -377,17 +400,9 @@ class EventEvent(models.Model):
event.is_finished = datetime_end <= current_datetime
def _search_is_finished(self, operator, value):
if operator not in ['=', '!=']:
raise ValueError(_('This operator is not supported'))
if not isinstance(value, bool):
raise ValueError(_('Value should be True or False (not %s)'), value)
now = fields.Datetime.now()
if (operator == '=' and value) or (operator == '!=' and not value):
domain = [('date_end', '<=', now)]
else:
domain = [('date_end', '>', now)]
event_ids = self.env['event.event']._search(domain)
return [('id', 'in', event_ids)]
if operator != 'in':
return NotImplemented
return [('date_end', '<=', fields.Datetime.now())]
@api.depends('event_type_id')
def _compute_date_tz(self):
@ -397,26 +412,36 @@ class EventEvent(models.Model):
if not event.date_tz:
event.date_tz = self.env.user.tz or 'UTC'
@api.depends("event_slot_ids")
def _compute_event_slot_count(self):
slot_count_per_event = dict(self.env['event.slot']._read_group(
domain=[('event_id', 'in', self.ids)],
groupby=['event_id'],
aggregates=['__count']
))
for event in self:
event.event_slot_count = slot_count_per_event.get(event, 0)
@api.depends('address_id')
def _compute_address_search(self):
for event in self:
event.address_search = event.address_id
def _search_address_search(self, operator, value):
if operator != 'ilike' or not isinstance(value, str):
raise NotImplementedError(_('Operation not supported.'))
address_ids = self.env['res.partner']._search([
'|', '|', '|', '|', '|',
('street', 'ilike', value),
('street2', 'ilike', value),
('city', 'ilike', value),
('zip', 'ilike', value),
('state_id', 'ilike', value),
('country_id', 'ilike', value),
])
return [('address_id', 'in', address_ids)]
def make_codomain(value):
return Domain.OR(
Domain(field, 'ilike', value)
for field in ('name', 'street', 'street2', 'city', 'zip', 'state_id', 'country_id')
)
if isinstance(value, Domain):
domain = value.map_conditions(lambda cond: cond if cond.field_expr != 'display_name' else make_codomain(cond.value))
return Domain('address_id', operator, domain)
if operator == 'ilike' and isinstance(value, str):
return Domain('address_id', 'any', make_codomain(value))
# for the trivial "empty" case, there is no empty address
if operator == 'in' and (not value or not any(value)):
return Domain(False)
return NotImplemented
# seats
@ -444,15 +469,6 @@ class EventEvent(models.Model):
if not event.seats_limited:
event.seats_limited = False
@api.depends('event_type_id')
def _compute_auto_confirm(self):
""" Update event configuration from its event type. Depends are set only
on event_type_id itself, not its sub fields. Purpose is to emulate an
onchange: if event type is changed, update event configuration. Changing
event type content itself should not trigger this method. """
for event in self:
event.auto_confirm = event.event_type_id.auto_confirm
@api.depends('event_type_id')
def _compute_event_mail_ids(self):
""" Update event configuration from its event type. Depends are set only
@ -478,11 +494,11 @@ class EventEvent(models.Model):
# lines to add: those which do not have the exact copy available in lines to keep
if event.event_type_id.event_type_mail_ids:
mails_to_keep_vals = {mail._prepare_event_mail_values() for mail in event.event_mail_ids - mails_to_remove}
mails_to_keep_vals = {frozendict(mail._prepare_event_mail_values()) for mail in event.event_mail_ids - mails_to_remove}
for mail in event.event_type_id.event_type_mail_ids:
mail_values = mail._prepare_event_mail_values()
mail_values = frozendict(mail._prepare_event_mail_values())
if mail_values not in mails_to_keep_vals:
command.append(Command.create(mail_values._asdict()))
command.append(Command.create(mail_values))
if command:
event.event_mail_ids = command
@ -534,6 +550,12 @@ class EventEvent(models.Model):
if event.event_type_id and not is_html_empty(event.event_type_id.note):
event.note = event.event_type_id.note
@api.depends('stage_id')
def _compute_kanban_state(self):
for task in self:
if task.kanban_state != 'cancel':
task.kanban_state = 'normal'
@api.depends('event_type_id')
def _compute_ticket_instructions(self):
for event in self:
@ -553,17 +575,34 @@ class EventEvent(models.Model):
else:
event.address_inline = event.address_id.name or ''
@api.constrains('seats_max', 'seats_limited', 'registration_ids')
def _check_seats_availability(self, minimal_availability=0):
sold_out_events = []
for event in self:
if event.seats_limited and event.seats_max and event.seats_available < minimal_availability:
sold_out_events.append(
(_('- "%(event_name)s": Missing %(nb_too_many)i seats.',
event_name=event.name, nb_too_many=-event.seats_available)))
if sold_out_events:
raise ValidationError(_('There are not enough seats available for:')
+ '\n%s\n' % '\n'.join(sold_out_events))
@api.depends('address_id')
def _compute_event_url(self):
"""Reset url field as it should only be used for events with no physical location."""
self.filtered('address_id').event_url = ''
@api.constrains("date_begin", "date_end", "event_slot_ids", "is_multi_slots")
def _check_slots_dates(self):
multi_slots_event_ids = self.filtered(lambda event: event.is_multi_slots).ids
if not multi_slots_event_ids:
return
min_max_slot_dates_per_event = {
event: (min_start, max_end)
for event, min_start, max_end in self.env['event.slot']._read_group(
domain=[('event_id', 'in', multi_slots_event_ids)],
groupby=['event_id'],
aggregates=['start_datetime:min', 'end_datetime:max']
)
}
events_w_slots_outside_bounds = []
for event, (min_start, max_end) in min_max_slot_dates_per_event.items():
if (not (event.date_begin <= min_start <= event.date_end) or
not (event.date_begin <= max_end <= event.date_end)):
events_w_slots_outside_bounds.append(event)
if events_w_slots_outside_bounds:
raise ValidationError(_(
"These events cannot have slots scheduled outside of their time range:\n%(event_names)s",
event_names="\n".join(f"- {event.name}" for event in events_w_slots_outside_bounds)
))
@api.constrains('date_begin', 'date_end')
def _check_closing_date(self):
@ -571,33 +610,40 @@ class EventEvent(models.Model):
if event.date_end < event.date_begin:
raise ValidationError(_('The closing date cannot be earlier than the beginning date.'))
@api.model
def _read_group_stage_ids(self, stages, domain, order):
return self.env['event.stage'].search([])
@api.constrains('event_url')
def _check_event_url(self):
for event in self.filtered('event_url'):
url = urlparse(event.event_url)
if not (url.scheme and url.netloc):
raise ValidationError(_('Please enter a valid event URL.'))
@api.model_create_multi
def create(self, vals_list):
events = super(EventEvent, self).create(vals_list)
for res in events:
if res.organizer_id:
res.message_subscribe([res.organizer_id.id])
self.env.flush_all()
return events
@api.onchange('event_url')
def _onchange_event_url(self):
"""Correct the url by adding scheme if it is missing."""
for event in self.filtered('event_url'):
parsed_url = urlparse(event.event_url)
if parsed_url.scheme not in ('http', 'https'):
event.event_url = 'https://' + event.event_url
def write(self, vals):
if 'stage_id' in vals and 'kanban_state' not in vals:
# reset kanban state when changing stage
vals['kanban_state'] = 'normal'
res = super(EventEvent, self).write(vals)
if vals.get('organizer_id'):
self.message_subscribe([vals['organizer_id']])
return res
@api.onchange('seats_max')
def _onchange_seats_max(self):
for event in self:
if event.seats_limited and event.seats_max and event.seats_available <= 0 and \
(event.event_slot_ids if event.is_multi_slots else True):
return {
'warning': {
'title': _("Update the limit of registrations?"),
'message': _("There are more registrations than this limit, "
"the event will be sold out and the extra registrations will remain."),
}
}
def name_get(self):
@api.depends('event_registrations_sold_out', 'seats_limited', 'seats_max', 'seats_available')
@api.depends_context('name_with_seats_availability')
def _compute_display_name(self):
"""Adds ticket seats availability if requested by context."""
if not self.env.context.get('name_with_seats_availability'):
return super().name_get()
res = []
return super()._compute_display_name()
for event in self:
# event or its tickets are sold out
if event.event_registrations_sold_out:
@ -610,32 +656,133 @@ class EventEvent(models.Model):
)
else:
name = event.name
res.append((event.id, name))
return res
event.display_name = name
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
self.ensure_one()
default = dict(default or {}, name=_("%s (copy)") % (self.name))
return super(EventEvent, self).copy(default)
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", event.name)) for event, vals in zip(self, vals_list)]
@api.model
def _get_mail_message_access(self, res_ids, operation, model_name=None):
if (
operation == 'create'
and self.env.user.has_group('event.group_event_registration_desk')
and (not model_name or model_name == 'event.event')
):
def _mail_get_operation_for_mail_message_operation(self, message_operation):
if (message_operation == 'create' and self.env.user.has_group('event.group_event_registration_desk')):
# allow the registration desk users to post messages on Event
# can not be done with "_mail_post_access" otherwise public user will be
# able to post on published Event (see website_event)
return 'read'
return super(EventEvent, self)._get_mail_message_access(res_ids, operation, model_name)
return dict.fromkeys(self, 'read')
return super()._mail_get_operation_for_mail_message_operation(message_operation)
def _set_tz_context(self):
self.ensure_one()
return self.with_context(tz=self.date_tz or 'UTC')
def _get_seats_availability(self, slot_tickets):
""" Get availabilities for given combinations of slot / ticket. Returns
a list following input order. None denotes no limit. """
self.ensure_one()
if not (all(len(item) == 2 for item in slot_tickets)):
raise ValueError('Input should be a list of tuples containing slot, ticket')
if any(slot for (slot, _ticket) in slot_tickets):
slot_tickets_nb_registrations = {
(slot.id, ticket.id): count
for (slot, ticket, count) in self.env['event.registration'].sudo()._read_group(
domain=[('event_slot_id', '!=', False), ('event_id', 'in', self.ids),
('state', 'in', ['open', 'done']), ('active', '=', True)],
groupby=['event_slot_id', 'event_ticket_id'],
aggregates=['__count']
)
}
availabilities = []
for slot, ticket in slot_tickets:
available = None
# event is constrained: max stands for either each slot, either global (no slots)
if self.seats_limited and self.seats_max:
if slot:
available = slot.seats_available
else:
available = self.seats_available
# ticket is constrained: max standard for either each slot / ticket, either global (no slots)
if available != 0 and ticket and ticket.seats_max:
if slot:
ticket_available = ticket.seats_max - slot_tickets_nb_registrations.get((slot.id, ticket.id), 0)
else:
ticket_available = ticket.seats_available
available = ticket_available if available is None else min(available, ticket_available)
availabilities.append(available)
return availabilities
def _verify_seats_availability(self, slot_tickets):
""" Check event seats availability, for combinations of slot / ticket.
:param slot_tickets: a list of tuples(slot, ticket, count). Slot and
ticket are optional, depending on event configuration. If count is 0
it is a simple check current values do not overflow limit. If count
is given, it serves as a check there are enough remaining seats.
:raises ValidationError: if the event / slot / ticket do not have
enough available seats
"""
self.ensure_one()
if not (all(len(item) == 3 for item in slot_tickets)):
raise ValueError('Input should be a list of tuples containing slot, ticket, count')
sold_out = []
availabilities = self._get_seats_availability([(item[0], item[1]) for item in slot_tickets])
for (slot, ticket, count), available in zip(slot_tickets, availabilities, strict=True):
if available is None: # unconstrained
continue
if available < count:
if slot and ticket:
name = f'{ticket.name} - {slot.display_name}'
elif slot:
name = slot.display_name
elif ticket:
name = ticket.name
else:
name = self.name
sold_out.append((name, count - available))
if sold_out:
info = [] # note: somehow using list comprehension make translate.py crash in default lang
for item in sold_out:
info.append(_('%(slot_name)s: missing %(count)s seat(s)', slot_name=item[0], count=item[1]))
raise ValidationError(
_('There are not enough seats available for %(event_name)s:\n%(sold_out_info)s',
event_name=self.name,
sold_out_info='\n'.join(info),
)
)
# ------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------
def action_open_slot_calendar(self):
self.ensure_one()
now = datetime.now().astimezone(pytz.timezone(self.env.user.tz or 'UTC'))
next_hour = now + timedelta(hours=1)
return {
'type': 'ir.actions.act_window',
'name': _('Slots'),
'view_mode': 'calendar,list,form',
'mobile_view_mode': 'list',
'res_model': 'event.slot',
'target': 'current',
'domain': [('event_id', '=', self.id)],
'context': {
'default_event_id': self.id,
# Default hours for the list view and mobile quick create.
# Desktop calendar multi create using defaults in local storage
# (= the last selected time range or fallback on 12PM-1PM).
'default_start_hour': next_hour.hour,
'default_end_hour': (next_hour + timedelta(hours=1)).hour,
# To disable calendar days outside of event date range.
'event_calendar_range_start_date': self.date_begin.astimezone(pytz.timezone(self.date_tz)).date(),
'event_calendar_range_end_date': self.date_end.astimezone(pytz.timezone(self.date_tz)).date(),
# Calendar view initial date.
'initial_date': min(max(datetime.now(), self.date_begin), self.date_end),
},
}
def action_set_done(self):
"""
Action which will move the events
@ -646,13 +793,46 @@ class EventEvent(models.Model):
if first_ended_stage:
self.write({'stage_id': first_ended_stage.id})
def mail_attendees(self, template_id, force_send=False, filter_func=lambda self: self.state != 'cancel'):
for event in self:
for attendee in event.registration_ids.filtered(filter_func):
self.env['mail.template'].browse(template_id).send_mail(attendee.id, force_send=force_send)
def _get_date_range_str(self, start_datetime=False, lang_code=False):
self.ensure_one()
datetime = start_datetime or self.date_begin
today_tz = pytz.utc.localize(fields.Datetime.now()).astimezone(pytz.timezone(self.date_tz))
event_date_tz = pytz.utc.localize(datetime).astimezone(pytz.timezone(self.date_tz))
diff = (event_date_tz.date() - today_tz.date())
if diff.days <= 0:
return _('today')
if diff.days == 1:
return _('tomorrow')
if (diff.days < 7):
return _('in %d days', diff.days)
if (diff.days < 14):
return _('next week')
if event_date_tz.month == (today_tz + relativedelta(months=+1)).month:
return _('next month')
return _('on %(date)s', date=format_date(self.env, datetime, lang_code=lang_code, date_format='medium'))
def _get_ics_file(self):
def _get_external_description(self):
"""
Description of the event shortened to maximum 1900 characters to
leave some space for addition by sub-modules.
Meant to be used for external content (ics/icalc/Gcal).
Reference Docs for URL limit -: https://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers
"""
self.ensure_one()
description = ''
if self.event_share_url:
description = f'<a href="{escape(self.event_share_url)}">{escape(self.name)}</a>\n'
description += textwrap.shorten(html_to_inner_content(self.description), 1900)
return description
def _get_external_description_url_encoded(self):
"""Get a url-encoded version of the description for mail templates."""
return urllib.parse.quote_plus(self._get_external_description())
def _get_ics_file(self, slot=False):
""" Returns iCalendar file for the event invitation.
:param slot: If a slot is given, schedule with the given slot datetimes
:returns a dict of .ics file content for each event
"""
result = {}
@ -662,17 +842,27 @@ class EventEvent(models.Model):
for event in self:
cal = vobject.iCalendar()
cal_event = cal.add('vevent')
start = slot.start_datetime or event.date_begin
end = slot.end_datetime or event.date_end
cal_event.add('created').value = fields.Datetime.now().replace(tzinfo=pytz.timezone('UTC'))
cal_event.add('dtstart').value = event.date_begin.astimezone(pytz.timezone(event.date_tz))
cal_event.add('dtend').value = event.date_end.astimezone(pytz.timezone(event.date_tz))
cal_event.add('dtstart').value = start.astimezone(pytz.timezone(event.date_tz))
cal_event.add('dtend').value = end.astimezone(pytz.timezone(event.date_tz))
cal_event.add('summary').value = event.name
cal_event.add('description').value = event._get_external_description()
if event.address_id:
cal_event.add('location').value = event.address_inline
result[event.id] = cal.serialize().encode('utf-8')
return result
def _get_tickets_access_hash(self, registration_ids):
""" Returns the ground truth hash for accessing the tickets in route /event/<int:event_id>/my_tickets.
The dl links are always made event-dependant, hence the method linked to the record in self.
"""
self.ensure_one()
return tools.hmac(self.env(su=True), 'event-registration-ticket-report-access', (self.id, sorted(registration_ids)))
@api.autovacuum
def _gc_mark_events_done(self):
""" move every ended events in the next 'ended stage' """

View file

@ -1,18 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import random
import threading
from collections import namedtuple
from datetime import datetime
from dateutil.relativedelta import relativedelta
from markupsafe import Markup
from odoo import api, fields, models, tools
from odoo import api, fields, models, modules, tools
from odoo.addons.base.models.ir_qweb import QWebError
from odoo.tools import exception_to_unicode
from odoo.tools.translate import _
from odoo.exceptions import MissingError, ValidationError
_logger = logging.getLogger(__name__)
@ -26,51 +22,7 @@ _INTERVALS = {
}
class EventTypeMail(models.Model):
""" Template of event.mail to attach to event.type. Those will be copied
upon all events created in that type to ease event creation. """
_name = 'event.type.mail'
_description = 'Mail Scheduling on Event Category'
@api.model
def _selection_template_model(self):
return [('mail.template', 'Mail')]
event_type_id = fields.Many2one(
'event.type', string='Event Type',
ondelete='cascade', required=True)
notification_type = fields.Selection([('mail', 'Mail')], string='Send', default='mail', required=True)
interval_nbr = fields.Integer('Interval', default=1)
interval_unit = fields.Selection([
('now', 'Immediately'),
('hours', 'Hours'), ('days', 'Days'),
('weeks', 'Weeks'), ('months', 'Months')],
string='Unit', default='hours', required=True)
interval_type = fields.Selection([
('after_sub', 'After each registration'),
('before_event', 'Before the event'),
('after_event', 'After the event')],
string='Trigger', default="before_event", required=True)
template_model_id = fields.Many2one('ir.model', string='Template Model', compute='_compute_template_model_id', compute_sudo=True)
template_ref = fields.Reference(string='Template', selection='_selection_template_model', required=True)
@api.depends('notification_type')
def _compute_template_model_id(self):
mail_model = self.env['ir.model']._get('mail.template')
for mail in self:
mail.template_model_id = mail_model if mail.notification_type == 'mail' else False
def _prepare_event_mail_values(self):
self.ensure_one()
return namedtuple("MailValues", ['notification_type', 'interval_nbr', 'interval_unit', 'interval_type', 'template_ref'])(
self.notification_type,
self.interval_nbr,
self.interval_unit,
self.interval_type,
'%s,%i' % (self.template_ref._name, self.template_ref.id)
)
class EventMailScheduler(models.Model):
class EventMail(models.Model):
""" Event automated mailing. This model replaces all existing fields and
configuration allowing to send emails on events since Odoo 9. A cron exists
that periodically checks for mailing to run. """
@ -78,23 +30,8 @@ class EventMailScheduler(models.Model):
_rec_name = 'event_id'
_description = 'Event Automated Mailing'
@api.model
def _selection_template_model(self):
return [('mail.template', 'Mail')]
def _selection_template_model_get_mapping(self):
return {'mail': 'mail.template'}
@api.onchange('notification_type')
def set_template_ref_model(self):
mail_model = self.env['mail.template']
if self.notification_type == 'mail':
record = mail_model.search([('model', '=', 'event.registration')], limit=1)
self.template_ref = "{},{}".format('mail.template', record.id) if record else False
event_id = fields.Many2one('event.event', string='Event', required=True, ondelete='cascade')
event_id = fields.Many2one('event.event', string='Event', required=True, index=True, ondelete='cascade')
sequence = fields.Integer('Display order')
notification_type = fields.Selection([('mail', 'Mail')], string='Send', default='mail', required=True)
interval_nbr = fields.Integer('Interval', default=1)
interval_unit = fields.Selection([
('now', 'Immediately'),
@ -102,154 +39,426 @@ class EventMailScheduler(models.Model):
('weeks', 'Weeks'), ('months', 'Months')],
string='Unit', default='hours', required=True)
interval_type = fields.Selection([
# attendee based
('after_sub', 'After each registration'),
('before_event', 'Before the event'),
('after_event', 'After the event')],
string='Trigger ', default="before_event", required=True)
# event based: start date
('before_event', 'Before the event starts'),
('after_event_start', 'After the event started'),
# event based: end date
('after_event', 'After the event ended'),
('before_event_end', 'Before the event ends')],
string='Trigger ', default="before_event", required=True,
help="Indicates when the communication is sent. "
"If the event has multiple slots, the interval is related to each time slot instead of the whole event.")
scheduled_date = fields.Datetime('Schedule Date', compute='_compute_scheduled_date', store=True)
error_datetime = fields.Datetime('Last Error')
# contact and status
last_registration_id = fields.Many2one('event.registration', 'Last Attendee')
mail_registration_ids = fields.One2many(
'event.mail.registration', 'scheduler_id',
help='Communication related to event registrations')
mail_slot_ids = fields.One2many(
'event.mail.slot', 'scheduler_id',
help='Slot-based communication')
mail_done = fields.Boolean("Sent", copy=False, readonly=True)
mail_state = fields.Selection(
[('running', 'Running'), ('scheduled', 'Scheduled'), ('sent', 'Sent')],
[('running', 'Running'), ('scheduled', 'Scheduled'), ('sent', 'Sent'), ('error', 'Error'), ('cancelled', 'Cancelled')],
string='Global communication Status', compute='_compute_mail_state')
mail_count_done = fields.Integer('# Sent', copy=False, readonly=True)
template_model_id = fields.Many2one('ir.model', string='Template Model', compute='_compute_template_model_id', compute_sudo=True)
template_ref = fields.Reference(string='Template', selection='_selection_template_model', required=True)
@api.depends('notification_type')
def _compute_template_model_id(self):
mail_model = self.env['ir.model']._get('mail.template')
for mail in self:
mail.template_model_id = mail_model if mail.notification_type == 'mail' else False
notification_type = fields.Selection([('mail', 'Mail')], string='Send', compute='_compute_notification_type')
template_ref = fields.Reference(string='Template', ondelete={'mail.template': 'cascade'}, required=True, selection=[('mail.template', 'Mail')])
@api.depends('event_id.date_begin', 'event_id.date_end', 'interval_type', 'interval_unit', 'interval_nbr')
def _compute_scheduled_date(self):
for scheduler in self:
if scheduler.interval_type == 'after_sub':
date, sign = scheduler.event_id.create_date, 1
elif scheduler.interval_type == 'before_event':
date, sign = scheduler.event_id.date_begin, -1
elif scheduler.interval_type in ('before_event', 'after_event_start'):
date, sign = scheduler.event_id.date_begin, scheduler.interval_type == 'before_event' and -1 or 1
else:
date, sign = scheduler.event_id.date_end, 1
date, sign = scheduler.event_id.date_end, scheduler.interval_type == 'after_event' and 1 or -1
scheduler.scheduled_date = date.replace(microsecond=0) + _INTERVALS[scheduler.interval_unit](sign * scheduler.interval_nbr) if date else False
@api.depends('interval_type', 'scheduled_date', 'mail_done')
next_schedule = self.filtered('scheduled_date').mapped('scheduled_date')
if next_schedule and (cron := self.env.ref('event.event_mail_scheduler', raise_if_not_found=False)):
cron._trigger(next_schedule)
@api.depends('error_datetime', 'interval_type', 'mail_done', 'event_id')
def _compute_mail_state(self):
for scheduler in self:
# issue detected
if scheduler.error_datetime:
scheduler.mail_state = 'error'
# event cancelled
elif not scheduler.mail_done and scheduler.event_id.kanban_state == 'cancel':
scheduler.mail_state = 'cancelled'
# registrations based
if scheduler.interval_type == 'after_sub':
elif scheduler.interval_type == 'after_sub':
scheduler.mail_state = 'running'
# global event based
elif scheduler.mail_done:
scheduler.mail_state = 'sent'
elif scheduler.scheduled_date:
scheduler.mail_state = 'scheduled'
else:
scheduler.mail_state = 'running'
scheduler.mail_state = 'scheduled'
@api.constrains('notification_type', 'template_ref')
def _check_template_ref_model(self):
model_map = self._selection_template_model_get_mapping()
for record in self.filtered('template_ref'):
model = model_map[record.notification_type]
if record.template_ref._name != model:
raise ValidationError(_('The template which is referenced should be coming from %(model_name)s model.', model_name=model))
@api.depends('template_ref')
def _compute_notification_type(self):
"""Assigns the type of template in use, if any is set."""
self.notification_type = 'mail'
def execute(self):
for scheduler in self:
now = fields.Datetime.now()
now = fields.Datetime.now()
for scheduler in self._filter_template_ref():
if scheduler.interval_type == 'after_sub':
new_registrations = scheduler.event_id.registration_ids.filtered_domain(
[('state', 'not in', ('cancel', 'draft'))]
) - scheduler.mail_registration_ids.registration_id
scheduler._create_missing_mail_registrations(new_registrations)
# execute scheduler on registrations
scheduler.mail_registration_ids.execute()
total_sent = len(scheduler.mail_registration_ids.filtered(lambda reg: reg.mail_sent))
scheduler.update({
'mail_done': total_sent >= (scheduler.event_id.seats_reserved + scheduler.event_id.seats_used),
'mail_count_done': total_sent,
})
scheduler._execute_attendee_based()
elif scheduler.event_id.is_multi_slots:
scheduler._execute_slot_based()
else:
# before or after event -> one shot email
if scheduler.mail_done or scheduler.notification_type != 'mail':
continue
# no template -> ill configured, skip and avoid crash
if not scheduler.template_ref:
# before or after event -> one shot communication, once done skip
if scheduler.mail_done:
continue
# do not send emails if the mailing was scheduled before the event but the event is over
if scheduler.scheduled_date <= now and (scheduler.interval_type != 'before_event' or scheduler.event_id.date_end > now):
scheduler.event_id.mail_attendees(scheduler.template_ref.id)
# Mail is sent to all attendees (unconfirmed as well), so count all attendees
scheduler.update({
'mail_done': True,
'mail_count_done': scheduler.event_id.seats_expected,
})
if scheduler.scheduled_date <= now and (scheduler.interval_type not in ('before_event', 'after_event_start') or scheduler.event_id.date_end > now):
scheduler._execute_event_based()
scheduler.error_datetime = False
return True
def _execute_event_based(self, mail_slot=False):
""" Main scheduler method when running in event-based mode aka
'after_event' or 'before_event' (and their negative counterparts).
This is a global communication done once i.e. we do not track each
registration individually.
:param mail_slot: optional <event.mail.slot> slot-specific event communication,
when event uses slots. In that case, it works like the classic event
communication (iterative, ...) but information is specific to each
slot (last registration, scheduled datetime, ...)
"""
auto_commit = not modules.module.current_test
batch_size = int(
self.env['ir.config_parameter'].sudo().get_param('mail.batch_size')
) or 50 # be sure to not have 0, as otherwise no iteration is done
cron_limit = int(
self.env['ir.config_parameter'].sudo().get_param('mail.render.cron.limit')
) or 1000 # be sure to not have 0, as otherwise we will loop
scheduler_record = mail_slot or self
# fetch registrations to contact
registration_domain = [
('event_id', '=', self.event_id.id),
('state', 'not in', ["draft", "cancel"]),
]
if mail_slot:
registration_domain += [('event_slot_id', '=', mail_slot.event_slot_id.id)]
if scheduler_record.last_registration_id:
registration_domain += [('id', '>', self.last_registration_id.id)]
registrations = self.env["event.registration"].search(registration_domain, limit=(cron_limit + 1), order="id ASC")
# no registrations -> done
if not registrations:
scheduler_record.mail_done = True
return
# there are more than planned for the cron -> reschedule
if len(registrations) > cron_limit:
registrations = registrations[:cron_limit]
self.env.ref('event.event_mail_scheduler')._trigger()
for registrations_chunk in tools.split_every(batch_size, registrations.ids, self.env["event.registration"].browse):
self._execute_event_based_for_registrations(registrations_chunk)
scheduler_record.last_registration_id = registrations_chunk[-1]
self._refresh_mail_count_done(mail_slot=mail_slot)
if auto_commit:
self.env.cr.commit()
# invalidate cache, no need to keep previous content in memory
self.env.invalidate_all()
def _execute_event_based_for_registrations(self, registrations):
""" Method doing notification and recipients specific implementation
of contacting attendees globally.
:param registrations: a recordset of registrations to contact
"""
self.ensure_one()
if self.notification_type == "mail":
self._send_mail(registrations)
return True
def _execute_slot_based(self):
""" Main scheduler method when running in slot-based mode aka
'after_event' or 'before_event' (and their negative counterparts) on
events with slots. This is a global communication done once i.e. we do
not track each registration individually. """
# create slot-specific schedulers if not existing
missing_slots = self.event_id.event_slot_ids - self.mail_slot_ids.event_slot_id
if missing_slots:
self.write({'mail_slot_ids': [
(0, 0, {'event_slot_id': slot.id})
for slot in missing_slots
]})
# filter slots to contact
now = fields.Datetime.now()
for mail_slot in self.mail_slot_ids:
# before or after event -> one shot communication, once done skip
if mail_slot.mail_done:
continue
# do not send emails if the mailing was scheduled before the slot but the slot is over
if mail_slot.scheduled_date <= now and (self.interval_type not in ('before_event', 'after_event_start') or mail_slot.event_slot_id.end_datetime > now):
self._execute_event_based(mail_slot=mail_slot)
def _execute_attendee_based(self):
""" Main scheduler method when running in attendee-based mode aka
'after_sub'. This relies on a sub model allowing to know which
registrations have been contacted.
It currently does two main things
* generate missing 'event.mail.registrations' which are scheduled
communication linked to registrations;
* launch registration-based communication, splitting in batches as
it may imply a lot of computation. When having more than given
limit to handle, schedule another call of cron to avoid having to
wait another cron interval check;
"""
self.ensure_one()
context_registrations = self.env.context.get('event_mail_registration_ids')
auto_commit = not modules.module.current_test
batch_size = int(
self.env['ir.config_parameter'].sudo().get_param('mail.batch_size')
) or 50 # be sure to not have 0, as otherwise no iteration is done
cron_limit = int(
self.env['ir.config_parameter'].sudo().get_param('mail.render.cron.limit')
) or 1000 # be sure to not have 0, as otherwise we will loop
# fillup on subscription lines (generate more than to render creating
# mail.registration is less costly than rendering emails)
# note: original 2many domain was
# ("id", "not in", self.env["event.registration"]._search([
# ("mail_registration_ids.scheduler_id", "in", self.ids),
# ]))
# but it gives less optimized sql
new_attendee_domain = [
('event_id', '=', self.event_id.id),
("state", "not in", ("cancel", "draft")),
("mail_registration_ids", "not in", self.env["event.mail.registration"]._search(
[('scheduler_id', 'in', self.ids)]
)),
]
if context_registrations:
new_attendee_domain += [
('id', 'in', context_registrations),
]
self.env["event.mail.registration"].flush_model(["registration_id", "scheduler_id"])
new_attendees = self.env["event.registration"].search(new_attendee_domain, limit=cron_limit * 2, order="id ASC")
new_attendee_mails = self._create_missing_mail_registrations(new_attendees)
# fetch attendee schedulers to run (or use the one given in context)
mail_domain = self.env["event.mail.registration"]._get_skip_domain() + [("scheduler_id", "=", self.id)]
if context_registrations:
new_attendee_mails = new_attendee_mails.filtered_domain(mail_domain)
else:
new_attendee_mails = self.env["event.mail.registration"].search(
mail_domain,
limit=(cron_limit + 1), order="id ASC"
)
# there are more than planned for the cron -> reschedule
if len(new_attendee_mails) > cron_limit:
new_attendee_mails = new_attendee_mails[:cron_limit]
self.env.ref('event.event_mail_scheduler')._trigger()
for chunk in tools.split_every(batch_size, new_attendee_mails.ids, self.env["event.mail.registration"].browse):
# filter out canceled / draft, and compare to seats_taken (same heuristic)
valid_chunk = chunk.filtered(lambda m: m.registration_id.state not in ("draft", "cancel"))
# scheduled mails for draft / cancel should be removed as they won't be sent
(chunk - valid_chunk).unlink()
# send communications, then update only when being in cron mode (aka no
# context registrations) to avoid concurrent updates on scheduler
valid_chunk._execute_on_registrations()
# if not context_registrations:
self._refresh_mail_count_done()
if auto_commit:
self.env.cr.commit()
# invalidate cache, no need to keep previous content in memory
self.env.invalidate_all()
def _create_missing_mail_registrations(self, registrations):
new = []
new = self.env["event.mail.registration"]
for scheduler in self:
new += [{
'registration_id': registration.id,
'scheduler_id': scheduler.id,
} for registration in registrations]
if new:
return self.env['event.mail.registration'].create(new)
return self.env['event.mail.registration']
for _chunk in tools.split_every(500, registrations.ids, self.env["event.registration"].browse):
new += self.env['event.mail.registration'].create([{
'registration_id': registration.id,
'scheduler_id': scheduler.id,
} for registration in registrations])
return new
def _refresh_mail_count_done(self, mail_slot=False):
for scheduler in self:
if scheduler.interval_type == "after_sub":
total_sent = self.env["event.mail.registration"].search_count([
("scheduler_id", "=", self.id),
("mail_sent", "=", True),
])
scheduler.mail_count_done = total_sent
elif mail_slot and mail_slot.last_registration_id:
total_sent = self.env["event.registration"].search_count([
("id", "<=", mail_slot.last_registration_id.id),
("event_id", "=", scheduler.event_id.id),
("event_slot_id", "=", mail_slot.event_slot_id.id),
("state", "not in", ["draft", "cancel"]),
])
mail_slot.mail_count_done = total_sent
mail_slot.mail_done = total_sent >= mail_slot.event_slot_id.seats_taken
scheduler.mail_count_done = sum(scheduler.mail_slot_ids.mapped('mail_count_done'))
scheduler.mail_done = scheduler.mail_count_done >= scheduler.event_id.seats_taken
elif scheduler.last_registration_id:
total_sent = self.env["event.registration"].search_count([
("id", "<=", self.last_registration_id.id),
("event_id", "=", self.event_id.id),
("state", "not in", ["draft", "cancel"]),
])
scheduler.mail_count_done = total_sent
scheduler.mail_done = total_sent >= self.event_id.seats_taken
else:
scheduler.mail_count_done = 0
scheduler.mail_done = False
def _filter_template_ref(self):
""" Check for valid template reference: existing, working template """
type_info = self._template_model_by_notification_type()
if not self:
return self.browse()
invalid = self.browse()
missing = self.browse()
for scheduler in self:
tpl_model = type_info[scheduler.notification_type]
if scheduler.template_ref._name != tpl_model:
invalid += scheduler
else:
template = self.env[tpl_model].browse(scheduler.template_ref.id).exists()
if not template:
missing += scheduler
for scheduler in missing:
_logger.warning(
"Cannot process scheduler %s (event %s - ID %s) as it refers to non-existent %s (ID %s)",
scheduler.id, scheduler.event_id.name, scheduler.event_id.id,
tpl_model, scheduler.template_ref.id
)
for scheduler in invalid:
_logger.warning(
"Cannot process scheduler %s (event %s - ID %s) as it refers to invalid template %s (ID %s) (%s instead of %s)",
scheduler.id, scheduler.event_id.name, scheduler.event_id.id,
scheduler.template_ref.name, scheduler.template_ref.id,
scheduler.template_ref._name, tpl_model)
return self - missing - invalid
def _send_mail(self, registrations):
""" Mail action: send mail to attendees """
if self.event_id.organizer_id.email:
author = self.event_id.organizer_id
elif self.env.company.email:
author = self.env.company.partner_id
elif self.env.user.email:
author = self.env.user.partner_id
else:
author = self.env.ref('base.user_root').partner_id
composer_values = {
'composition_mode': 'mass_mail',
'force_send': False,
'model': registrations._name,
'res_ids': registrations.ids,
'template_id': self.template_ref.id,
}
# force author, as mailing mode does not try to find the author matching
# email_from (done only when posting on chatter); give email_from if not
# configured on template
composer_values['author_id'] = author.id
composer_values['email_from'] = self.template_ref.email_from or author.email_formatted
composer = self.env['mail.compose.message'].create(composer_values)
# backward compatible behavior: event mail scheduler does not force partner
# creation, email_cc / email_to is kept on outgoing emails
composer.with_context(mail_composer_force_partners=False)._action_send_mail()
def _template_model_by_notification_type(self):
return {
"mail": "mail.template",
}
def _prepare_event_mail_values(self):
self.ensure_one()
return namedtuple("MailValues", ['notification_type', 'interval_nbr', 'interval_unit', 'interval_type', 'template_ref'])(
self.notification_type,
self.interval_nbr,
self.interval_unit,
self.interval_type,
'%s,%i' % (self.template_ref._name, self.template_ref.id)
)
return {
'interval_nbr': self.interval_nbr,
'interval_unit': self.interval_unit,
'interval_type': self.interval_type,
'template_ref': '%s,%i' % (self.template_ref._name, self.template_ref.id),
}
@api.model
def _warn_template_error(self, scheduler, exception):
# We warn ~ once by hour ~ instead of every 10 min if the interval unit is more than 'hours'.
if random.random() < 0.1666 or scheduler.interval_unit in ('now', 'hours'):
ex_s = exception_to_unicode(exception)
try:
event, template = scheduler.event_id, scheduler.template_ref
emails = list(set([event.organizer_id.email, event.user_id.email, template.write_uid.email]))
subject = _("WARNING: Event Scheduler Error for event: %s", event.name)
body = _("""Event Scheduler for:
- Event: %(event_name)s (%(event_id)s)
- Scheduled: %(date)s
- Template: %(template_name)s (%(template_id)s)
def _warn_error(self, exception):
last_error_dt = self.error_datetime
now = self.env.cr.now().replace(microsecond=0)
if not last_error_dt or last_error_dt < now - relativedelta(hours=1):
# message base: event, date
event, template = self.event_id, self.template_ref
if self.interval_type == "after_sub":
scheduled_date = now
else:
scheduled_date = self.scheduled_date
body_content = _(
"Communication for %(event_name)s scheduled on %(scheduled_date)s failed.",
event_name=event.name,
scheduled_date=scheduled_date,
)
Failed with error:
- %(error)s
You receive this email because you are:
- the organizer of the event,
- or the responsible of the event,
- or the last writer of the template.
""",
event_name=event.name,
event_id=event.id,
date=scheduler.scheduled_date,
template_name=template.name,
template_id=template.id,
error=ex_s)
email = self.env['ir.mail_server'].build_email(
email_from=self.env.user.email,
email_to=emails,
subject=subject, body=body,
# add some information on cause
template_link = Markup('<a href="%s">%s (%s)</a>') % (
f"{self.get_base_url()}/odoo/{template._name}/{template.id}",
template.display_name,
template.id,
)
cause = exception.__cause__ or exception.__context__
if hasattr(cause, 'qweb'):
source_content = _(
"This is due to an error in template %(template_link)s.",
template_link=template_link,
)
self.env['ir.mail_server'].send_email(email)
except Exception as e:
_logger.error("Exception while sending traceback by email: %s.\n Original Traceback:\n%s", e, exception)
pass
if isinstance(cause, QWebError) and isinstance(cause.__cause__, AttributeError):
error_message = _(
"There is an issue with dynamic placeholder. Actual error received is: %(error)s.",
error=Markup('<br/>%s') % cause.__cause__,
)
else:
error_message = _(
"Rendering of template failed with error: %(error)s.",
error=Markup('<br/>%s') % cause.qweb,
)
else:
source_content = _(
"This may be linked to template %(template_link)s.",
template_link=template_link,
)
error_message = _(
"It failed with error %(error)s.",
error=exception_to_unicode(exception),
)
body = Markup("<p>%s %s<br /><br />%s</p>") % (body_content, source_content, error_message)
recipients = (event.organizer_id | event.user_id.partner_id | template.write_uid.partner_id).filtered(
lambda p: p.active
)
self.event_id.message_post(
body=body,
force_send=False, # use email queue, especially it could be cause of error
notify_author_mention=True, # in case of event responsible creating attendees
partner_ids=recipients.ids,
)
self.error_datetime = now
@api.model
def run(self, autocommit=False):
@ -260,9 +469,15 @@ You receive this email because you are:
@api.model
def schedule_communications(self, autocommit=False):
schedulers = self.search([
# skip archived events
('event_id.active', '=', True),
# skip if event is cancelled
('event_id.kanban_state', '!=', 'cancel'),
# scheduled
('scheduled_date', '<=', fields.Datetime.now()),
# event-based: todo / attendee-based: running until event is not done
('mail_done', '=', False),
('scheduled_date', '<=', fields.Datetime.now())
'|', ('interval_type', '!=', 'after_sub'), ('event_id.date_end', '>', self.env.cr.now()),
])
for scheduler in schedulers:
@ -272,67 +487,8 @@ You receive this email because you are:
except Exception as e:
_logger.exception(e)
self.env.invalidate_all()
self._warn_template_error(scheduler, e)
scheduler._warn_error(e)
else:
if autocommit and not getattr(threading.current_thread(), 'testing', False):
if autocommit and not modules.module.current_test:
self.env.cr.commit()
return True
class EventMailRegistration(models.Model):
_name = 'event.mail.registration'
_description = 'Registration Mail Scheduler'
_rec_name = 'scheduler_id'
_order = 'scheduled_date DESC'
scheduler_id = fields.Many2one('event.mail', 'Mail Scheduler', required=True, ondelete='cascade')
registration_id = fields.Many2one('event.registration', 'Attendee', required=True, ondelete='cascade')
scheduled_date = fields.Datetime('Scheduled Time', compute='_compute_scheduled_date', store=True)
mail_sent = fields.Boolean('Mail Sent')
def execute(self):
now = fields.Datetime.now()
todo = self.filtered(lambda reg_mail:
not reg_mail.mail_sent and \
reg_mail.registration_id.state in ['open', 'done'] and \
(reg_mail.scheduled_date and reg_mail.scheduled_date <= now) and \
reg_mail.scheduler_id.notification_type == 'mail'
)
done = self.browse()
for reg_mail in todo:
organizer = reg_mail.scheduler_id.event_id.organizer_id
company = self.env.company
author = self.env.ref('base.user_root').partner_id
if organizer.email:
author = organizer
elif company.email:
author = company.partner_id
elif self.env.user.email:
author = self.env.user.partner_id
email_values = {
'author_id': author.id,
}
template = None
try:
template = reg_mail.scheduler_id.template_ref.exists()
except MissingError:
pass
if not template:
_logger.warning("Cannot process ticket %s, because Mail Scheduler %s has reference to non-existent template", reg_mail.registration_id, reg_mail.scheduler_id)
continue
if not template.email_from:
email_values['email_from'] = author.email_formatted
template.send_mail(reg_mail.registration_id.id, email_values=email_values)
done |= reg_mail
done.write({'mail_sent': True})
@api.depends('registration_id', 'scheduler_id.interval_unit', 'scheduler_id.interval_type')
def _compute_scheduled_date(self):
for mail in self:
if mail.registration_id:
mail.scheduled_date = mail.registration_id.create_date.replace(microsecond=0) + _INTERVALS[mail.scheduler_id.interval_unit](mail.scheduler_id.interval_nbr)
else:
mail.scheduled_date = False

View file

@ -0,0 +1,56 @@
import logging
from odoo import api, fields, models
from odoo.addons.event.models.event_mail import _INTERVALS
from odoo.exceptions import MissingError
_logger = logging.getLogger(__name__)
class EventMailRegistration(models.Model):
_name = 'event.mail.registration'
_description = 'Registration Mail Scheduler'
_rec_name = 'scheduler_id'
_order = 'scheduled_date DESC, id ASC'
scheduler_id = fields.Many2one('event.mail', 'Mail Scheduler', required=True, index=True, ondelete='cascade')
registration_id = fields.Many2one('event.registration', 'Attendee', required=True, index=True, ondelete='cascade')
scheduled_date = fields.Datetime('Scheduled Time', compute='_compute_scheduled_date', store=True)
mail_sent = fields.Boolean('Mail Sent')
@api.depends('registration_id', 'scheduler_id.interval_unit', 'scheduler_id.interval_type')
def _compute_scheduled_date(self):
for mail in self:
if mail.registration_id:
mail.scheduled_date = mail.registration_id.create_date.replace(microsecond=0) + _INTERVALS[mail.scheduler_id.interval_unit](mail.scheduler_id.interval_nbr)
else:
mail.scheduled_date = False
def execute(self):
# Deprecated, to be called only from parent scheduler
skip_domain = self._get_skip_domain() + [("registration_id.state", "in", ("open", "done"))]
self.filtered_domain(skip_domain)._execute_on_registrations()
def _execute_on_registrations(self):
""" Private mail registration execution. We consider input is already
filtered at this point, allowing to let caller do optimizations when
managing batches of registrations. """
todo = self.filtered(
lambda r: r.scheduler_id.notification_type == "mail"
)
for scheduler, reg_mails in todo.grouped('scheduler_id').items():
# exclusion_list should not be applied as registering to an event is implicitly
# subscribing to the emails relevant to the event such as the email containing the ticket
scheduler.with_context(default_use_exclusion_list=False)._send_mail(reg_mails.registration_id)
todo.mail_sent = True
return todo
def _get_skip_domain(self):
""" Domain of mail registrations ot skip: not already done, linked to
a valid registration, and scheduled in the past. """
return [
("mail_sent", "=", False),
("scheduled_date", "!=", False),
("scheduled_date", "<=", self.env.cr.now()),
]

View file

@ -0,0 +1,31 @@
from odoo import api, fields, models
from odoo.addons.event.models.event_mail import _INTERVALS
class EventMailRegistration(models.Model):
_name = 'event.mail.slot'
_description = 'Slot Mail Scheduler'
_rec_name = 'scheduler_id'
_order = 'scheduled_date DESC, id ASC'
event_slot_id = fields.Many2one('event.slot', 'Slot', ondelete='cascade', required=True)
scheduled_date = fields.Datetime('Schedule Date', compute='_compute_scheduled_date', store=True)
scheduler_id = fields.Many2one('event.mail', 'Mail Scheduler', ondelete='cascade', required=True, index=True)
# contact and status
last_registration_id = fields.Many2one('event.registration', 'Last Attendee')
mail_count_done = fields.Integer('# Sent', copy=False, readonly=True)
mail_done = fields.Boolean("Sent", copy=False, readonly=True)
@api.depends('event_slot_id.start_datetime', 'event_slot_id.end_datetime', 'scheduler_id.interval_unit', 'scheduler_id.interval_type')
def _compute_scheduled_date(self):
for mail_slot in self:
scheduler = mail_slot.scheduler_id
if scheduler.interval_type in ('before_event', 'after_event_start'):
date, sign = mail_slot.event_slot_id.start_datetime, (scheduler.interval_type == 'before_event' and -1) or 1
else:
date, sign = mail_slot.event_slot_id.end_datetime, (scheduler.interval_type == 'after_event' and 1) or -1
mail_slot.scheduled_date = date.replace(microsecond=0) + _INTERVALS[scheduler.interval_unit](sign * scheduler.interval_nbr) if date else False
next_schedule = self.filtered('scheduled_date').mapped('scheduled_date')
if next_schedule and (cron := self.env.ref('event.event_mail_scheduler', raise_if_not_found=False)):
cron._trigger(next_schedule)

View file

@ -0,0 +1,102 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class EventQuestion(models.Model):
_name = 'event.question'
_rec_name = 'title'
_order = 'sequence,id'
_description = 'Event Question'
title = fields.Char(required=True, translate=True)
question_type = fields.Selection([
('simple_choice', 'Selection'),
('text_box', 'Text Input'),
('name', 'Name'),
('email', 'Email'),
('phone', 'Phone'),
('company_name', 'Company'),
], default='simple_choice', string="Question Type", required=True)
active = fields.Boolean('Active', default=True)
event_type_ids = fields.Many2many('event.type', string='Event Types', copy=False)
event_ids = fields.Many2many('event.event', string='Events', copy=False)
event_count = fields.Integer('# Events', compute='_compute_event_count')
is_default = fields.Boolean('Default question', help="Include by default in new events.")
is_reusable = fields.Boolean('Is Reusable',
compute='_compute_is_reusable', default=True, store=True,
help='Allow this question to be selected and reused for any future event. Always true for default questions.')
answer_ids = fields.One2many('event.question.answer', 'question_id', "Answers", copy=True)
sequence = fields.Integer(default=10)
once_per_order = fields.Boolean('Ask once per order',
help="Check this for order-level questions (e.g., 'Company Name') where the answer is the same for everyone.")
is_mandatory_answer = fields.Boolean('Mandatory Answer')
_check_default_question_is_reusable = models.Constraint(
'CHECK(is_default IS DISTINCT FROM TRUE OR is_reusable IS TRUE)',
"A default question must be reusable."
)
@api.depends('event_ids')
def _compute_event_count(self):
event_count_per_question = dict(self.env['event.event']._read_group(
domain=[('question_ids', 'in', self.ids)],
groupby=['question_ids'],
aggregates=['__count']
))
for question in self:
question.event_count = event_count_per_question.get(question, 0)
@api.depends('is_default', 'event_type_ids')
def _compute_is_reusable(self):
self.filtered('is_default').is_reusable = True
def write(self, vals):
""" We add a check to prevent changing the question_type of a question that already has answers.
Indeed, it would mess up the event.registration.answer (answer type not matching the question type). """
if 'question_type' in vals:
questions_new_type = self.filtered(lambda question: question.question_type != vals['question_type'])
if questions_new_type:
answer_count = self.env['event.registration.answer'].search_count([('question_id', 'in', questions_new_type.ids)])
if answer_count > 0:
raise UserError(_("You cannot change the question type of a question that already has answers!"))
return super().write(vals)
@api.ondelete(at_uninstall=False)
def _unlink_except_answered_question(self):
if self.env['event.registration.answer'].search_count([('question_id', 'in', self.ids)]):
raise UserError(_('You cannot delete a question that has already been answered by attendees. You can archive it instead.'))
@api.ondelete(at_uninstall=False)
def _unlink_except_default_question(self):
if set(self.ids) & set(self.env['event.type']._default_question_ids()):
raise UserError(_('You cannot delete a default question.'))
def action_view_question_answers(self):
""" Allow analyzing the attendees answers to event questions in a convenient way:
- A graph view showing counts of each suggestion for simple_choice questions
(Along with secondary pivot and list views)
- A list view showing textual answers values for text_box questions.
"""
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("event.action_event_registration_report")
action['context'] = {'search_default_question_id': self.id}
if event_id := self.env.context.get('search_default_event_id'):
action['context'].update(search_default_event_id=event_id)
# Fetch attendee answers for which the event is still linked to the question.
action['domain'] = [('event_id.question_ids', 'in', self.ids)]
if self.question_type == 'simple_choice':
action['views'] = [(False, 'graph'), (False, 'pivot'), (False, 'list')]
elif self.question_type == 'text_box':
action['views'] = [(False, 'list')]
return action
def action_event_view(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("event.action_event_view")
action['domain'] = [('question_ids', 'in', self.ids)]
return action

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class EventQuestionAnswer(models.Model):
""" Contains suggested answers to a 'simple_choice' event.question. """
_name = 'event.question.answer'
_order = 'sequence,id'
_description = 'Event Question Answer'
name = fields.Char('Answer', required=True, translate=True)
question_id = fields.Many2one('event.question', required=True, index=True, ondelete='cascade')
sequence = fields.Integer(default=10)
@api.ondelete(at_uninstall=False)
def _unlink_except_selected_answer(self):
if self.env['event.registration.answer'].search_count([('value_answer_id', 'in', self.ids)]):
raise UserError(_('You cannot delete an answer that has already been selected by attendees.'))

View file

@ -1,21 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
import logging
import os
import pytz
from odoo import _, api, fields, models, SUPERUSER_ID
from odoo.tools import format_date, email_normalize, email_normalize_all
from odoo.exceptions import AccessError, ValidationError
# phone_validation is not officially in the depends of event, but we would like
# to have the formatting available in event, not in event_sms -> do a conditional
# import just to be sure
try:
from odoo.addons.phone_validation.tools.phone_validation import phone_format
except ImportError:
def phone_format(number, country_code, country_phone_code, force_format='INTERNATIONAL', raise_exception=True):
return number
from odoo.fields import Domain
from odoo.tools import email_normalize, format_date, formataddr
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class EventRegistration(models.Model):
@ -23,43 +15,98 @@ class EventRegistration(models.Model):
_description = 'Event Registration'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'id desc'
_mail_defaults_to_email = True
@api.model
def _get_random_barcode(self):
"""Generate a string representation of a pseudo-random 8-byte number for barcode
generation.
A decimal serialisation is longer than a hexadecimal one *but* it
generates a more compact barcode (Code128C rather than Code128A).
Generate 8 bytes (64 bits) barcodes as 16 bytes barcodes are not
compatible with all scanners.
"""
return str(int.from_bytes(os.urandom(8), 'little'))
# event
event_id = fields.Many2one(
'event.event', string='Event', required=True,
readonly=True, states={'draft': [('readonly', False)]})
'event.event', string='Event', required=True, tracking=True, index=True)
is_multi_slots = fields.Boolean(string="Is Event Multi Slots", related="event_id.is_multi_slots")
event_slot_id = fields.Many2one(
"event.slot", string="Slot", ondelete='restrict', tracking=True, index="btree_not_null",
domain="[('event_id', '=', event_id)]")
event_ticket_id = fields.Many2one(
'event.event.ticket', string='Event Ticket', readonly=True, ondelete='restrict',
states={'draft': [('readonly', False)]})
'event.event.ticket', string='Ticket Type', ondelete='restrict', tracking=True, index='btree_not_null')
active = fields.Boolean(default=True)
barcode = fields.Char(string='Barcode', default=lambda self: self._get_random_barcode(), readonly=True, copy=False)
# utm informations
utm_campaign_id = fields.Many2one('utm.campaign', 'Campaign', index=True, ondelete='set null')
utm_campaign_id = fields.Many2one('utm.campaign', 'Campaign', index=True, ondelete='set null')
utm_source_id = fields.Many2one('utm.source', 'Source', index=True, ondelete='set null')
utm_medium_id = fields.Many2one('utm.medium', 'Medium', index=True, ondelete='set null')
# attendee
partner_id = fields.Many2one('res.partner', string='Booked by', tracking=1)
partner_id = fields.Many2one('res.partner', string='Booked by', tracking=1, index='btree_not_null')
name = fields.Char(
string='Attendee Name', index='trigram',
compute='_compute_name', readonly=False, store=True, tracking=10)
email = fields.Char(string='Email', compute='_compute_email', readonly=False, store=True, tracking=11)
phone = fields.Char(string='Phone', compute='_compute_phone', readonly=False, store=True, tracking=12)
mobile = fields.Char(string='Mobile', compute='_compute_mobile', readonly=False, store=True, tracking=13)
compute='_compute_name', readonly=False, store=True, tracking=2)
email = fields.Char(string='Email', compute='_compute_email', readonly=False, store=True, tracking=3)
phone = fields.Char(string='Phone', compute='_compute_phone', readonly=False, store=True, tracking=4)
company_name = fields.Char(
string='Company Name', compute='_compute_company_name', readonly=False, store=True, tracking=5)
# organization
date_closed = fields.Datetime(
string='Attended Date', compute='_compute_date_closed',
readonly=False, store=True)
event_begin_date = fields.Datetime(string="Event Start Date", related='event_id.date_begin', readonly=True)
event_end_date = fields.Datetime(string="Event End Date", related='event_id.date_end', readonly=True)
event_begin_date = fields.Datetime("Event Start Date", compute="_compute_event_begin_date", search="_search_event_begin_date")
event_end_date = fields.Datetime("Event End Date", compute="_compute_event_end_date", search="_search_event_end_date")
event_date_range = fields.Char("Date Range", compute="_compute_date_range")
event_organizer_id = fields.Many2one(string='Event Organizer', related='event_id.organizer_id', readonly=True)
event_user_id = fields.Many2one(string='Event Responsible', related='event_id.user_id', readonly=True)
company_id = fields.Many2one(
'res.company', string='Company', related='event_id.company_id',
store=True, readonly=True, states={'draft': [('readonly', False)]})
store=True, readonly=False)
state = fields.Selection([
('draft', 'Unconfirmed'), ('cancel', 'Cancelled'),
('open', 'Confirmed'), ('done', 'Attended')],
string='Status', default='draft', readonly=True, copy=False, tracking=True)
('draft', 'Unconfirmed'),
('open', 'Registered'),
('done', 'Attended'),
('cancel', 'Cancelled')],
string='Status', default='open',
readonly=True, copy=False, tracking=6,
help='Unconfirmed: registrations in a pending state waiting for an action (specific case, notably with sale status)\n'
'Registered: registrations considered taken by a client\n'
'Attended: registrations for which the attendee attended the event\n'
'Cancelled: registrations cancelled manually')
# questions
registration_answer_ids = fields.One2many('event.registration.answer', 'registration_id', string='Attendee Answers')
registration_answer_choice_ids = fields.One2many('event.registration.answer', 'registration_id', string='Attendee Selection Answers',
domain=[('question_type', '=', 'simple_choice')])
# scheduled mails
mail_registration_ids = fields.One2many(
'event.mail.registration', 'registration_id',
string="Scheduler Emails", readonly=True)
# properties
registration_properties = fields.Properties(
'Properties', definition='event_id.registration_properties_definition', copy=True)
_barcode_event_uniq = models.Constraint(
'unique(barcode)',
'Barcode should be unique',
)
@api.constrains('active', 'state', 'event_id', 'event_slot_id', 'event_ticket_id')
def _check_seats_availability(self):
tocheck = self.filtered(lambda registration: registration.state in ('open', 'done') and registration.active)
for event, registrations in tocheck.grouped('event_id').items():
event._verify_seats_availability([
(slot, ticket, 0)
for slot, ticket in self.env['event.registration']._read_group(
[('id', 'in', registrations.ids)],
['event_slot_id', 'event_ticket_id']
)
])
@api.model
def default_get(self, fields):
ret_vals = super().default_get(fields)
utm_mixin_fields = ("campaign_id", "medium_id", "source_id")
@ -78,7 +125,7 @@ class EventRegistration(models.Model):
if not registration.name and registration.partner_id:
registration.name = registration._synchronize_partner_values(
registration.partner_id,
fnames=['name']
fnames={'name'},
).get('name') or False
@api.depends('partner_id')
@ -87,26 +134,27 @@ class EventRegistration(models.Model):
if not registration.email and registration.partner_id:
registration.email = registration._synchronize_partner_values(
registration.partner_id,
fnames=['email']
fnames={'email'},
).get('email') or False
@api.depends('partner_id')
def _compute_phone(self):
for registration in self:
if not registration.phone and registration.partner_id:
registration.phone = registration._synchronize_partner_values(
partner_values = registration._synchronize_partner_values(
registration.partner_id,
fnames=['phone']
).get('phone') or False
fnames={'phone'},
)
registration.phone = partner_values.get('phone') or False
@api.depends('partner_id')
def _compute_mobile(self):
def _compute_company_name(self):
for registration in self:
if not registration.mobile and registration.partner_id:
registration.mobile = registration._synchronize_partner_values(
if not registration.company_name and registration.partner_id:
registration.company_name = registration._synchronize_partner_values(
registration.partner_id,
fnames=['mobile']
).get('mobile') or False
fnames={'company_name'},
).get('company_name') or False
@api.depends('state')
def _compute_date_closed(self):
@ -117,6 +165,45 @@ class EventRegistration(models.Model):
else:
registration.date_closed = False
@api.depends("event_id", "event_slot_id", "partner_id")
def _compute_date_range(self):
for registration in self:
registration.event_date_range = registration.event_id._get_date_range_str(
start_datetime=registration.event_slot_id.start_datetime,
lang_code=registration.partner_id.lang,
)
@api.depends("event_id", "event_slot_id")
def _compute_event_begin_date(self):
for registration in self:
registration.event_begin_date = registration.event_slot_id.start_datetime or registration.event_id.date_begin
@api.model
def _search_event_begin_date(self, operator, value):
return Domain.OR([
["&", ("event_slot_id", "!=", False), ("event_slot_id.start_datetime", operator, value)],
["&", ("event_slot_id", "=", False), ("event_id.date_begin", operator, value)],
])
@api.depends("event_id", "event_slot_id")
def _compute_event_end_date(self):
for registration in self:
registration.event_end_date = registration.event_slot_id.end_datetime or registration.event_id.date_end
@api.model
def _search_event_end_date(self, operator, value):
return Domain.OR([
["&", ("event_slot_id", "!=", False), ("event_slot_id.end_datetime", operator, value)],
["&", ("event_slot_id", "=", False), ("event_id.date_end", operator, value)],
])
@api.constrains('event_id', 'event_slot_id')
def _check_event_slot(self):
if any(registration.event_id != registration.event_slot_id.event_id for registration in self if registration.event_slot_id):
raise ValidationError(_('Invalid event / slot choice'))
if any(not registration.event_slot_id for registration in self if registration.is_multi_slots):
raise ValidationError(_('Slot choice is mandatory on multi-slots events.'))
@api.constrains('event_id', 'event_ticket_id')
def _check_event_ticket(self):
if any(registration.event_id != registration.event_ticket_id.event_id for registration in self if registration.event_ticket_id):
@ -124,7 +211,7 @@ class EventRegistration(models.Model):
def _synchronize_partner_values(self, partner, fnames=None):
if fnames is None:
fnames = ['name', 'email', 'phone', 'mobile']
fnames = {'name', 'email', 'phone'}
if partner:
contact_id = partner.address_get().get('contact', False)
if contact_id:
@ -132,17 +219,41 @@ class EventRegistration(models.Model):
return dict((fname, contact[fname]) for fname in fnames if contact[fname])
return {}
@api.onchange('event_id')
def _onchange_event(self):
if self.event_slot_id and self.event_id != self.event_slot_id.event_id:
self.event_slot_id = False
if self.event_ticket_id and self.event_id != self.event_ticket_id.event_id:
self.event_ticket_id = False
@api.onchange('phone', 'event_id', 'partner_id')
def _onchange_phone_validation(self):
if self.phone:
country = self.partner_id.country_id or self.event_id.country_id or self.env.company.country_id
self.phone = self._phone_format(self.phone, country)
self.phone = self._phone_format(fname='phone', country=country) or self.phone
@api.onchange('mobile', 'event_id', 'partner_id')
def _onchange_mobile_validation(self):
if self.mobile:
country = self.partner_id.country_id or self.event_id.country_id or self.env.company.country_id
self.mobile = self._phone_format(self.mobile, country)
@api.model
def register_attendee(self, barcode, event_id):
attendee = self.search([('barcode', '=', barcode)], limit=1)
if not attendee:
return {'error': 'invalid_ticket'}
res = attendee._get_registration_summary()
if attendee.state == 'cancel':
status = 'canceled_registration'
elif attendee.state == 'draft':
status = 'unconfirmed_registration'
elif attendee.event_id.is_finished:
status = 'not_ongoing_event'
elif attendee.state != 'done':
if event_id and attendee.event_id.id != event_id:
status = 'need_manual_confirmation'
else:
attendee.action_set_done()
status = 'confirmed_registration'
else:
status = 'already_registered'
res.update({'status': status})
return res
# ------------------------------------------------------------
# CRUD
@ -154,7 +265,7 @@ class EventRegistration(models.Model):
all_partner_ids = set(values['partner_id'] for values in vals_list if values.get('partner_id'))
all_event_ids = set(values['event_id'] for values in vals_list if values.get('event_id'))
for values in vals_list:
if not values.get('phone') and not values.get('mobile'):
if not values.get('phone'):
continue
related_country = self.env['res.country']
@ -164,93 +275,31 @@ class EventRegistration(models.Model):
related_country = self.env['event.event'].with_prefetch(all_event_ids).browse(values['event_id']).country_id
if not related_country:
related_country = self.env.company.country_id
values['phone'] = self._phone_format(number=values['phone'], country=related_country) or values['phone']
for fname in {'mobile', 'phone'}:
if values.get(fname):
values[fname] = self._phone_format(values[fname], related_country)
registrations = super(EventRegistration, self).create(vals_list)
# auto_confirm if possible; if not automatically confirmed, call mail schedulers in case
# some were created already open
if registrations._check_auto_confirmation():
registrations.sudo().action_confirm()
elif not self.env.context.get('install_mode', False):
# running the scheduler for demo data can cause an issue where wkhtmltopdf runs during
# server start and hangs indefinitely, leading to serious crashes
# we currently avoid this by not running the scheduler, would be best to find the actual
# reason for this issue and fix it so we can remove this check
registrations._update_mail_schedulers()
registrations = super().create(vals_list)
registrations._update_mail_schedulers()
return registrations
def write(self, vals):
confirming = vals.get('state') in {'open', 'done'}
to_confirm = (self.filtered(lambda registration: registration.state in {'draft', 'cancel'})
if confirming else None)
ret = super(EventRegistration, self).write(vals)
# As these Event(Ticket) methods are model constraints, it is not necessary to call them
# explicitly when creating new registrations. However, it is necessary to trigger them here
# as changes in registration states cannot be used as constraints triggers.
ret = super().write(vals)
if confirming:
to_confirm.event_id._check_seats_availability()
to_confirm.event_ticket_id._check_seats_availability()
to_confirm._update_mail_schedulers()
if not self.env.context.get('install_mode', False):
# running the scheduler for demo data can cause an issue where wkhtmltopdf runs
# during server start and hangs indefinitely, leading to serious crashes we
# currently avoid this by not running the scheduler, would be best to find the
# actual reason for this issue and fix it so we can remove this check
to_confirm._update_mail_schedulers()
if vals.get('state') == 'done':
message = _("Attended on %(attended_date)s", attended_date=format_date(env=self.env, value=fields.Datetime.now(), date_format='short'))
self._message_log_batch(bodies={registration.id: message for registration in self})
return ret
def name_get(self):
""" Custom name_get implementation to better differentiate registrations
linked to a given partner but with different name (one partner buying
several registrations)
* name, partner_id has no name -> take name
* partner_id has name, name void or same -> take partner name
* both have name: partner + name
def _compute_display_name(self):
""" Custom display_name in case a registration is nott linked to an attendee
"""
ret_list = []
for registration in self:
if registration.partner_id.name:
if registration.name and registration.name != registration.partner_id.name:
name = '%s, %s' % (registration.partner_id.name, registration.name)
else:
name = registration.partner_id.name
else:
name = registration.name
ret_list.append((registration.id, name))
return ret_list
def toggle_active(self):
pre_inactive = self - self.filtered(self._active_name)
super().toggle_active()
# Necessary triggers as changing registration states cannot be used as triggers for the
# Event(Ticket) models constraints.
if pre_inactive:
pre_inactive.event_id._check_seats_availability()
pre_inactive.event_ticket_id._check_seats_availability()
def _check_auto_confirmation(self):
""" Checks that all registrations are for `auto-confirm` events. """
return all(event.auto_confirm for event in self.event_id)
def _phone_format(self, number, country):
""" Call phone_validation formatting tool function. Returns original
number in case formatting cannot be done (no country, wrong info, ...) """
if not number or not country:
return number
new_number = phone_format(
number,
country.code,
country.phone_code,
force_format='E164',
raise_exception=False,
)
return new_number if new_number else number
registration.display_name = registration.name or f"#{registration.id}"
# ------------------------------------------------------------
# ACTIONS / BUSINESS
@ -278,12 +327,9 @@ class EventRegistration(models.Model):
compose_form = self.env.ref('mail.email_compose_message_wizard_form')
ctx = dict(
default_model='event.registration',
default_res_id=self.id,
default_use_template=bool(template),
default_template_id=template and template.id,
default_res_ids=self.ids,
default_template_id=template.id if template else False,
default_composition_mode='comment',
default_email_layout_xmlid="mail.mail_notification_light",
name_with_seats_availability=False,
)
return {
'name': _('Compose Email'),
@ -299,54 +345,78 @@ class EventRegistration(models.Model):
def _update_mail_schedulers(self):
""" Update schedulers to set them as running again, and cron to be called
as soon as possible. """
if self.env.context.get("install_mode", False):
# running the scheduler for demo data can cause an issue where wkhtmltopdf runs during
# server start and hangs indefinitely, leading to serious crashes
# we currently avoid this by not running the scheduler, would be best to find the actual
# reason for this issue and fix it so we can remove this check
return
open_registrations = self.filtered(lambda registration: registration.state == 'open')
if not open_registrations:
return
onsubscribe_schedulers = self.env['event.mail'].sudo().search([
('event_id', 'in', open_registrations.event_id.ids),
('interval_type', '=', 'after_sub')
('interval_type', '=', 'after_sub'),
])
if not onsubscribe_schedulers:
return
onsubscribe_schedulers.update({'mail_done': False})
# we could simply call _create_missing_mail_registrations and let cron do their job
# but it currently leads to several delays. We therefore call execute until
# cron triggers are correctly used
onsubscribe_schedulers.with_user(SUPERUSER_ID).execute()
# either trigger the cron, either run schedulers immediately (scaling choice)
async_scheduler = self.env['ir.config_parameter'].sudo().get_param('event.event_mail_async')
if async_scheduler:
self.env.ref('event.event_mail_scheduler')._trigger()
self.env.ref('mail.ir_cron_mail_scheduler_action')._trigger()
else:
# we could simply call _create_missing_mail_registrations and let cron do their job
# but it currently leads to several delays. We therefore call execute until
# cron triggers are correctly used
for scheduler in onsubscribe_schedulers:
try:
scheduler.with_context(
event_mail_registration_ids=open_registrations.ids
).with_user(SUPERUSER_ID).execute()
except Exception as e:
_logger.exception("Failed to run scheduler %s", scheduler.id)
scheduler._warn_error(e)
# ------------------------------------------------------------
# MAILING / GATEWAY
# ------------------------------------------------------------
def _message_get_suggested_recipients(self):
recipients = super(EventRegistration, self)._message_get_suggested_recipients()
public_users = self.env['res.users'].sudo()
public_groups = self.env.ref("base.group_public", raise_if_not_found=False)
if public_groups:
public_users = public_groups.sudo().with_context(active_test=False).mapped("users")
try:
for attendee in self:
is_public = attendee.sudo().with_context(active_test=False).partner_id.user_ids in public_users if public_users else False
if attendee.partner_id and not is_public:
attendee._message_add_suggested_recipient(recipients, partner=attendee.partner_id, reason=_('Customer'))
elif attendee.email:
attendee._message_add_suggested_recipient(recipients, email=attendee.email, reason=_('Customer Email'))
except AccessError: # no read access rights -> ignore suggested recipients
pass
return recipients
@api.model
def _mail_template_default_values(self):
return {
"email_from": "{{ (object.event_id.organizer_id.email_formatted or object.event_id.company_id.email_formatted or user.email_formatted or '') }}",
"lang": "{{ object.event_id.lang or object.partner_id.lang }}",
"use_default_to": True,
}
def _message_get_default_recipients(self):
def _message_compute_subject(self):
if self.name:
return _(
"%(event_name)s - Registration for %(attendee_name)s",
event_name=self.event_id.name,
attendee_name=self.name,
)
return _(
"%(event_name)s - Registration #%(registration_id)s",
event_name=self.event_id.name,
registration_id=self.id,
)
def _message_add_default_recipients(self):
# Prioritize registration email over partner_id, which may be shared when a single
# partner booked multiple seats
return {r.id:
{
'partner_ids': [],
'email_to': ','.join(email_normalize_all(r.email)) or r.email,
'email_cc': False,
} for r in self
}
results = super()._message_add_default_recipients()
for record in self:
email_to_lst = results[record.id]['email_to_lst']
if len(email_to_lst) == 1:
email_normalized = email_normalize(email_to_lst[0])
if email_normalized and email_normalized == email_normalize(record.email):
results[record.id]['email_to_lst'] = [formataddr((record.name or "", email_normalized))]
return results
def _message_post_after_hook(self, message, msg_vals):
if self.email and not self.partner_id:
@ -371,32 +441,27 @@ class EventRegistration(models.Model):
# TOOLS
# ------------------------------------------------------------
def get_date_range_str(self, lang_code=False):
self.ensure_one()
today_tz = pytz.utc.localize(fields.Datetime.now()).astimezone(pytz.timezone(self.event_id.date_tz))
event_date_tz = pytz.utc.localize(self.event_begin_date).astimezone(pytz.timezone(self.event_id.date_tz))
diff = (event_date_tz.date() - today_tz.date())
if diff.days <= 0:
return _('today')
elif diff.days == 1:
return _('tomorrow')
elif (diff.days < 7):
return _('in %d days') % (diff.days, )
elif (diff.days < 14):
return _('next week')
elif event_date_tz.month == (today_tz + relativedelta(months=+1)).month:
return _('next month')
else:
return _('on %(date)s', date=format_date(self.env, self.event_begin_date, lang_code=lang_code, date_format='medium'))
def _get_registration_summary(self):
self.ensure_one()
is_date_closed_today = False
if self.date_closed:
event_tz = pytz.timezone(self.event_id.date_tz)
now = fields.Datetime.now(pytz.UTC).astimezone(event_tz)
closed_date = self.date_closed.astimezone(event_tz)
is_date_closed_today = now.date() == closed_date.date()
return {
'id': self.id,
'name': self.name,
'partner_id': self.partner_id.id,
'ticket_name': self.event_ticket_id.name or _('None'),
'slot_name': self.event_slot_id.display_name,
'ticket_name': self.event_ticket_id.name,
'event_id': self.event_id.id,
'event_display_name': self.event_id.display_name,
'company_name': self.event_id.company_id and self.event_id.company_id.name or False,
'registration_answers': self.registration_answer_ids.filtered('value_answer_id').mapped('display_name'),
'company_name': self.company_name,
'badge_format': self.event_id.badge_format,
'date_closed_formatted': format_date(env=self.env, value=self.date_closed, date_format='short') if self.date_closed else False,
'is_date_closed_today': is_date_closed_today,
}

View file

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class EventRegistrationAnswer(models.Model):
""" Represents the user input answer for a single event.question """
_name = 'event.registration.answer'
_description = 'Event Registration Answer'
_rec_names_search = ['value_answer_id', 'value_text_box']
question_id = fields.Many2one(
'event.question', ondelete='restrict', required=True,
domain="[('event_ids', 'in', event_id)]")
registration_id = fields.Many2one('event.registration', required=True, index=True, ondelete='cascade')
partner_id = fields.Many2one('res.partner', related='registration_id.partner_id')
event_id = fields.Many2one('event.event', related='registration_id.event_id')
question_type = fields.Selection(related='question_id.question_type')
value_answer_id = fields.Many2one('event.question.answer', string="Suggested answer")
value_text_box = fields.Text('Text answer')
_value_check = models.Constraint(
"CHECK(value_answer_id IS NOT NULL OR COALESCE(value_text_box, '') <> '')",
'There must be a suggested value or a text value.',
)
# for displaying selected answers by attendees in attendees list view
@api.depends('value_answer_id', 'question_type', 'value_text_box')
def _compute_display_name(self):
for reg in self:
reg.display_name = reg.value_answer_id.name if reg.question_type == "simple_choice" else reg.value_text_box

View file

@ -0,0 +1,144 @@
import pytz
from datetime import datetime
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools.date_utils import float_to_time
from odoo.tools import (
format_date,
format_datetime,
formatLang,
format_time,
)
class EventSlot(models.Model):
_name = "event.slot"
_description = "Event Slot"
_order = "event_id, date, start_hour, end_hour, id"
event_id = fields.Many2one("event.event", "Event", required=True, ondelete="cascade", index=True)
color = fields.Integer("Color", default=0)
date = fields.Date("Date", required=True)
date_tz = fields.Selection(related="event_id.date_tz")
start_hour = fields.Float("Starting Hour", required=True, help="Expressed in the event timezone.")
end_hour = fields.Float("Ending Hour", required=True, help="Expressed in the event timezone.")
start_datetime = fields.Datetime("Start Datetime", compute="_compute_datetimes", store=True)
end_datetime = fields.Datetime("End Datetime", compute="_compute_datetimes", store=True)
# Registrations
is_sold_out = fields.Boolean(
"Sold Out", compute="_compute_is_sold_out",
help="Whether seats are sold out for this slot.")
registration_ids = fields.One2many("event.registration", "event_slot_id", string="Attendees")
seats_available = fields.Integer(
string="Available Seats",
store=False, readonly=True, compute="_compute_seats")
seats_reserved = fields.Integer(
string="Number of Registrations",
store=False, readonly=True, compute="_compute_seats")
seats_taken = fields.Integer(
string="Number of Taken Seats",
store=False, readonly=True, compute="_compute_seats")
seats_used = fields.Integer(
string="Number of Attendees",
store=False, readonly=True, compute="_compute_seats")
@api.constrains("start_hour", "end_hour")
def _check_hours(self):
for slot in self:
if not (0 <= slot.start_hour <= 23.99 and 0 <= slot.end_hour <= 23.99):
raise ValidationError(_("A slot hour must be between 0:00 and 23:59."))
if slot.end_hour <= slot.start_hour:
raise ValidationError(_("A slot end hour must be later than its start hour.\n%s", slot.display_name))
@api.constrains("date", "start_hour", "end_hour")
def _check_time_range(self):
for slot in self:
event_start = slot.event_id.date_begin
event_end = slot.event_id.date_end
if not (event_start <= slot.start_datetime <= event_end) or not (event_start <= slot.end_datetime <= event_end):
raise ValidationError(_(
"A slot cannot be scheduled outside of its event time range.\n\n"
"Event:\t\t%(event_start)s - %(event_end)s\n"
"Slot:\t\t%(slot_name)s",
event_start=format_datetime(self.env, event_start, tz=slot.date_tz, dt_format='medium'),
event_end=format_datetime(self.env, event_end, tz=slot.date_tz, dt_format='medium'),
slot_name=slot.display_name,
))
@api.depends("date", "date_tz", "start_hour", "end_hour")
def _compute_datetimes(self):
for slot in self:
event_tz = pytz.timezone(slot.date_tz)
start = datetime.combine(slot.date, float_to_time(slot.start_hour))
end = datetime.combine(slot.date, float_to_time(slot.end_hour))
slot.start_datetime = event_tz.localize(start).astimezone(pytz.UTC).replace(tzinfo=None)
slot.end_datetime = event_tz.localize(end).astimezone(pytz.UTC).replace(tzinfo=None)
@api.depends("seats_available")
@api.depends_context('name_with_seats_availability')
def _compute_display_name(self):
"""Adds slot seats availability if requested by context.
Always display the name without availabilities if the event is multi slots
because the availability displayed won't be relative to the possible ticket combinations
but only relative to the event and this will confuse the user.
"""
for slot in self:
date = format_date(self.env, slot.date, date_format="medium")
start = format_time(self.env, float_to_time(slot.start_hour), time_format="short")
end = format_time(self.env, float_to_time(slot.end_hour), time_format="short")
name = f"{date}, {start} - {end}"
if (
self.env.context.get('name_with_seats_availability') and slot.event_id.seats_limited
and not slot.event_id.is_multi_slots
):
name = _('%(slot_name)s (Sold out)', slot_name=name) if not slot.seats_available else \
_(
'%(slot_name)s (%(count)s seats remaining)',
slot_name=name,
count=formatLang(self.env, slot.seats_available, digits=0),
)
slot.display_name = name
@api.depends("event_id.seats_limited", "seats_available")
def _compute_is_sold_out(self):
for slot in self:
slot.is_sold_out = slot.event_id.seats_limited and not slot.seats_available
@api.depends("event_id", "event_id.seats_max", "registration_ids.state", "registration_ids.active")
def _compute_seats(self):
# initialize fields to 0
for slot in self:
slot.seats_reserved = slot.seats_used = slot.seats_available = 0
# aggregate registrations by slot and by state
state_field = {
'open': 'seats_reserved',
'done': 'seats_used',
}
base_vals = dict.fromkeys(state_field.values(), 0)
results = {slot_id: dict(base_vals) for slot_id in self.ids}
if self.ids:
query = """ SELECT event_slot_id, state, count(event_slot_id)
FROM event_registration
WHERE event_slot_id IN %s AND state IN ('open', 'done') AND active = true
GROUP BY event_slot_id, state
"""
self.env['event.registration'].flush_model(['event_slot_id', 'state', 'active'])
self.env.cr.execute(query, (tuple(self.ids),))
res = self.env.cr.fetchall()
for slot_id, state, num in res:
results[slot_id][state_field[state]] = num
# compute seats_available
for slot in self:
slot.update(results.get(slot._origin.id or slot.id, base_vals))
if slot.event_id.seats_max > 0:
slot.seats_available = slot.event_id.seats_max - (slot.seats_reserved + slot.seats_used)
slot.seats_taken = slot.seats_reserved + slot.seats_used
@api.ondelete(at_uninstall=False)
def _unlink_except_if_registrations(self):
if self.registration_ids:
raise UserError(_(
"The following slots cannot be deleted while they have one or more registrations linked to them:\n- %s",
'\n- '.join(self.mapped('display_name'))))

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, fields, models
from odoo import fields, models
class EventStage(models.Model):
@ -16,12 +16,3 @@ class EventStage(models.Model):
pipe_end = fields.Boolean(
string='End Stage', default=False,
help='Events will automatically be moved into this stage when they are finished. The event moved into this stage will automatically be set as green.')
legend_blocked = fields.Char(
'Red Kanban Label', default=lambda s: _('Blocked'), translate=True, prefetch='legend', required=True,
help='Override the default value displayed for the blocked state for kanban selection.')
legend_done = fields.Char(
'Green Kanban Label', default=lambda s: _('Ready for Next Stage'), translate=True, prefetch='legend', required=True,
help='Override the default value displayed for the done state for kanban selection.')
legend_normal = fields.Char(
'Grey Kanban Label', default=lambda s: _('In Progress'), translate=True, prefetch='legend', required=True,
help='Override the default value displayed for the normal state for kanban selection.')

View file

@ -7,7 +7,7 @@ from odoo import api, fields, models
class EventTagCategory(models.Model):
_name = "event.tag.category"
_name = 'event.tag.category'
_description = "Event Tag Category"
_order = "sequence"
@ -25,7 +25,7 @@ class EventTagCategory(models.Model):
class EventTag(models.Model):
_name = "event.tag"
_name = 'event.tag'
_description = "Event Tag"
_order = "category_sequence, sequence, id"
@ -34,7 +34,7 @@ class EventTag(models.Model):
name = fields.Char("Name", required=True, translate=True)
sequence = fields.Integer('Sequence', default=0)
category_id = fields.Many2one("event.tag.category", string="Category", required=True, ondelete='cascade')
category_id = fields.Many2one("event.tag.category", string="Category", required=True, index=True, ondelete='cascade')
category_sequence = fields.Integer(related='category_id.sequence', string='Category Sequence', store=True)
color = fields.Integer(
string='Color Index', default=lambda self: self._default_color(),

View file

@ -1,56 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
from odoo.exceptions import UserError
from odoo.tools.misc import formatLang
class EventTemplateTicket(models.Model):
_name = 'event.type.ticket'
_description = 'Event Template Ticket'
# description
name = fields.Char(
string='Name', default=lambda self: _('Registration'),
required=True, translate=True)
description = fields.Text(
'Description', translate=True,
help="A description of the ticket that you want to communicate to your customers.")
event_type_id = fields.Many2one(
'event.type', string='Event Category', ondelete='cascade', required=True)
# seats
seats_limited = fields.Boolean(string='Limit Attendees', readonly=True, store=True,
compute='_compute_seats_limited')
seats_max = fields.Integer(
string='Maximum Attendees',
help="Define the number of available tickets. If you have too many registrations you will "
"not be able to sell tickets anymore. Set 0 to ignore this rule set as unlimited.")
@api.depends('seats_max')
def _compute_seats_limited(self):
for ticket in self:
ticket.seats_limited = ticket.seats_max
@api.model
def _get_event_ticket_fields_whitelist(self):
""" Whitelist of fields that are copied from event_type_ticket_ids to event_ticket_ids when
changing the event_type_id field of event.event """
return ['name', 'description', 'seats_max']
class EventTicket(models.Model):
""" Ticket model allowing to have differnt kind of registrations for a given
class EventEventTicket(models.Model):
""" Ticket model allowing to have different kind of registrations for a given
event. Ticket are based on ticket type as they share some common fields
and behavior. Those models come from <= v13 Odoo event.event.ticket that
modeled both concept: tickets for event templates, and tickets for events. """
_name = 'event.event.ticket'
_inherit = 'event.type.ticket'
_inherit = ['event.type.ticket']
_description = 'Event Ticket'
_order = "event_id, sequence, name, id"
@api.model
def default_get(self, fields):
res = super(EventTicket, self).default_get(fields)
res = super().default_get(fields)
if 'name' in fields and (not res.get('name') or res['name'] == _('Registration')) and self.env.context.get('default_event_name'):
res['name'] = _('Registration for %s', self.env.context['default_event_name'])
return res
@ -59,7 +26,7 @@ class EventTicket(models.Model):
event_type_id = fields.Many2one(ondelete='set null', required=False)
event_id = fields.Many2one(
'event.event', string="Event",
ondelete='cascade', required=True)
ondelete='cascade', required=True, index=True)
company_id = fields.Many2one('res.company', related='event_id.company_id')
# sale
start_sale_datetime = fields.Datetime(string="Registration Start")
@ -73,10 +40,14 @@ class EventTicket(models.Model):
# seats
seats_reserved = fields.Integer(string='Reserved Seats', compute='_compute_seats', store=False)
seats_available = fields.Integer(string='Available Seats', compute='_compute_seats', store=False)
seats_unconfirmed = fields.Integer(string='Unconfirmed Seats', compute='_compute_seats', store=False)
seats_used = fields.Integer(string='Used Seats', compute='_compute_seats', store=False)
seats_taken = fields.Integer(string="Taken Seats", compute="_compute_seats", store=False)
limit_max_per_order = fields.Integer(string='Limit per Order', default=0,
help="Maximum of this product per order.\nSet to 0 to ignore this rule")
is_sold_out = fields.Boolean(
'Sold Out', compute='_compute_is_sold_out', help='Whether seats are not available for this ticket.')
# reports
color = fields.Char('Color', default="#875A7B")
@api.depends('end_sale_datetime', 'event_id.date_tz')
def _compute_is_expired(self):
@ -108,21 +79,20 @@ class EventTicket(models.Model):
@api.depends('seats_max', 'registration_ids.state', 'registration_ids.active')
def _compute_seats(self):
""" Determine reserved, available, reserved but unconfirmed and used seats. """
""" Determine available, reserved, used and taken seats. """
# initialize fields to 0 + compute seats availability
for ticket in self:
ticket.seats_unconfirmed = ticket.seats_reserved = ticket.seats_used = ticket.seats_available = 0
ticket.seats_reserved = ticket.seats_used = ticket.seats_available = 0
# aggregate registrations by ticket and by state
results = {}
if self.ids:
state_field = {
'draft': 'seats_unconfirmed',
'open': 'seats_reserved',
'done': 'seats_used',
}
query = """ SELECT event_ticket_id, state, count(event_id)
FROM event_registration
WHERE event_ticket_id IN %s AND state IN ('draft', 'open', 'done') AND active = true
WHERE event_ticket_id IN %s AND state IN ('open', 'done') AND active = true
GROUP BY event_ticket_id, state
"""
self.env['event.registration'].flush_model(['event_id', 'event_ticket_id', 'state', 'active'])
@ -135,6 +105,7 @@ class EventTicket(models.Model):
ticket.update(results.get(ticket._origin.id or ticket.id, {}))
if ticket.seats_max > 0:
ticket.seats_available = ticket.seats_max - (ticket.seats_reserved + ticket.seats_used)
ticket.seats_taken = ticket.seats_reserved + ticket.seats_used
@api.depends('seats_limited', 'seats_available', 'event_id.event_registrations_sold_out')
def _compute_is_sold_out(self):
@ -151,25 +122,31 @@ class EventTicket(models.Model):
raise UserError(_('The stop date cannot be earlier than the start date. '
'Please check ticket %(ticket_name)s', ticket_name=ticket.name))
@api.constrains('registration_ids', 'seats_max')
def _check_seats_availability(self, minimal_availability=0):
sold_out_tickets = []
@api.constrains('limit_max_per_order', 'seats_max')
def _constrains_limit_max_per_order(self):
for ticket in self:
if ticket.seats_max and ticket.seats_available < minimal_availability:
sold_out_tickets.append((_(
'- the ticket "%(ticket_name)s" (%(event_name)s): Missing %(nb_too_many)i seats.',
ticket_name=ticket.name, event_name=ticket.event_id.name, nb_too_many=-ticket.seats_available)))
if sold_out_tickets:
raise ValidationError(_('There are not enough seats available for:')
+ '\n%s\n' % '\n'.join(sold_out_tickets))
if ticket.seats_max and ticket.limit_max_per_order > ticket.seats_max:
raise UserError(_('The limit per order cannot be greater than the maximum seats number. '
'Please check ticket %(ticket_name)s', ticket_name=ticket.name))
if ticket.limit_max_per_order > ticket.event_id.EVENT_MAX_TICKETS:
raise UserError(_('The limit per order cannot be greater than %(limit_orderable)s. '
'Please check ticket %(ticket_name)s', limit_orderable=ticket.event_id.EVENT_MAX_TICKETS, ticket_name=ticket.name))
if ticket.limit_max_per_order < 0:
raise UserError(_('The limit per order must be positive. '
'Please check ticket %(ticket_name)s', ticket_name=ticket.name))
def name_get(self):
"""Adds ticket seats availability if requested by context."""
@api.depends('seats_max', 'seats_available')
@api.depends_context('name_with_seats_availability')
def _compute_display_name(self):
"""Adds ticket seats availability if requested by context.
Always display the name without availabilities if the event is multi slots
because the availability displayed won't be relative to the possible slot combinations
but only relative to the event and this will confuse the user.
"""
if not self.env.context.get('name_with_seats_availability'):
return super().name_get()
res = []
return super()._compute_display_name()
for ticket in self:
if not ticket.seats_max:
if not ticket.seats_max or ticket.event_id.is_multi_slots:
name = ticket.name
elif not ticket.seats_available:
name = _('%(ticket_name)s (Sold out)', ticket_name=ticket.name)
@ -179,8 +156,26 @@ class EventTicket(models.Model):
ticket_name=ticket.name,
count=formatLang(self.env, ticket.seats_available, digits=0),
)
res.append((ticket.id, name))
return res
ticket.display_name = name
def _get_current_limit_per_order(self, event_slot=False, event=False):
""" Compute the maximum possible number of tickets for an order, taking
into account the given event_slot if applicable.
If no ticket is created (alone event), event_id argument is used. Then
return the dictionary with False as key. """
event_slot.ensure_one() if event_slot else None
if self:
slots_seats_available = self.event_id._get_seats_availability([[event_slot, ticket] for ticket in self])
else:
return {False: event_slot.seats_available if event_slot else (event.seats_available if event.seats_limited else event.EVENT_MAX_TICKETS)}
availabilities = {}
for ticket, seats_available in zip(self, slots_seats_available):
if not seats_available: # "No limit"
seats_available = ticket.limit_max_per_order or ticket.event_id.EVENT_MAX_TICKETS
else:
seats_available = min(ticket.limit_max_per_order or seats_available, seats_available)
availabilities[ticket.id] = seats_available
return availabilities
def _get_ticket_multiline_description(self):
""" Compute a multiline description of this ticket. It is used when ticket

View file

@ -0,0 +1,62 @@
from odoo import api, fields, models
from odoo.addons.base.models.res_partner import _tz_get
class EventType(models.Model):
_name = 'event.type'
_description = 'Event Template'
_order = 'sequence, id'
def _default_event_mail_type_ids(self):
return [(0, 0,
{'interval_nbr': 0,
'interval_unit': 'now',
'interval_type': 'after_sub',
'template_ref': 'mail.template, %i' % self.env.ref('event.event_subscription').id,
}),
(0, 0,
{'interval_nbr': 1,
'interval_unit': 'hours',
'interval_type': 'before_event',
'template_ref': 'mail.template, %i' % self.env.ref('event.event_reminder').id,
}),
(0, 0,
{'interval_nbr': 3,
'interval_unit': 'days',
'interval_type': 'before_event',
'template_ref': 'mail.template, %i' % self.env.ref('event.event_reminder').id,
})]
def _default_question_ids(self):
return self.env['event.question'].search([('is_default', '=', True), ('active', '=', True)]).ids
name = fields.Char('Event Template', required=True, translate=True)
note = fields.Html(string='Note')
sequence = fields.Integer(default=10)
# tickets
event_type_ticket_ids = fields.One2many('event.type.ticket', 'event_type_id', string='Tickets')
tag_ids = fields.Many2many('event.tag', string="Tags")
# registration
has_seats_limitation = fields.Boolean('Limited Seats')
seats_max = fields.Integer(
'Maximum Registrations', compute='_compute_seats_max',
readonly=False, store=True,
help="It will select this default maximum value when you choose this event")
default_timezone = fields.Selection(
_tz_get, string='Timezone', default=lambda self: self.env.user.tz or 'UTC')
# communication
event_type_mail_ids = fields.One2many(
'event.type.mail', 'event_type_id', string='Mail Schedule',
default=_default_event_mail_type_ids)
# ticket reports
ticket_instructions = fields.Html('Ticket Instructions', translate=True,
help="This information will be printed on your tickets.")
question_ids = fields.Many2many(
'event.question', default=_default_question_ids,
string='Questions', copy=True)
@api.depends('has_seats_limitation')
def _compute_seats_max(self):
for template in self:
if not template.has_seats_limitation:
template.seats_max = 0

View file

@ -0,0 +1,44 @@
from odoo import api, fields, models
class EventTypeMail(models.Model):
""" Template of event.mail to attach to event.type. Those will be copied
upon all events created in that type to ease event creation. """
_name = 'event.type.mail'
_description = 'Mail Scheduling on Event Category'
event_type_id = fields.Many2one(
'event.type', string='Event Type',
ondelete='cascade', required=True)
interval_nbr = fields.Integer('Interval', default=1)
interval_unit = fields.Selection([
('now', 'Immediately'),
('hours', 'Hours'), ('days', 'Days'),
('weeks', 'Weeks'), ('months', 'Months')],
string='Unit', default='hours', required=True)
interval_type = fields.Selection([
# attendee based
('after_sub', 'After each registration'),
# event based: start date
('before_event', 'Before the event starts'),
('after_event_start', 'After the event started'),
# event based: end date
('after_event', 'After the event ended'),
('before_event_end', 'Before the event ends')],
string='Trigger', default="before_event", required=True)
notification_type = fields.Selection([('mail', 'Mail')], string='Send', compute='_compute_notification_type')
template_ref = fields.Reference(string='Template', ondelete={'mail.template': 'cascade'}, required=True, selection=[('mail.template', 'Mail')])
@api.depends('template_ref')
def _compute_notification_type(self):
"""Assigns the type of template in use, if any is set."""
self.notification_type = 'mail'
def _prepare_event_mail_values(self):
self.ensure_one()
return {
'interval_nbr': self.interval_nbr,
'interval_unit': self.interval_unit,
'interval_type': self.interval_type,
'template_ref': '%s,%i' % (self.template_ref._name, self.template_ref.id),
}

View file

@ -0,0 +1,36 @@
from odoo import api, fields, models, _
class EventTypeTicket(models.Model):
_name = 'event.type.ticket'
_description = 'Event Template Ticket'
_order = 'sequence, name, id'
sequence = fields.Integer('Sequence', default=10)
# description
name = fields.Char(
string='Name', default=lambda self: _('Registration'),
required=True, translate=True)
description = fields.Text(
'Description', translate=True,
help="A description of the ticket that you want to communicate to your customers.")
event_type_id = fields.Many2one(
'event.type', string='Event Category', ondelete='cascade', required=True)
# seats
seats_limited = fields.Boolean(string='Limit Attendees', readonly=True, store=True,
compute='_compute_seats_limited')
seats_max = fields.Integer(
string='Maximum Attendees',
help="Define the number of available tickets. If you have too many registrations you will "
"not be able to sell tickets anymore. Set 0 to ignore this rule set as unlimited.")
@api.depends('seats_max')
def _compute_seats_limited(self):
for ticket in self:
ticket.seats_limited = ticket.seats_max
@api.model
def _get_event_ticket_fields_whitelist(self):
""" Whitelist of fields that are copied from event_type_ticket_ids to event_ticket_ids when
changing the event_type_id field of event.event """
return ['sequence', 'name', 'description', 'seats_max']

View file

@ -1,25 +1,24 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.osv import expression
from odoo.fields import Domain
class MailTemplate(models.Model):
_inherit = 'mail.template'
@api.model
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
def _search(self, domain, *args, **kwargs):
"""Context-based hack to filter reference field in a m2o search box to emulate a domain the ORM currently does not support.
As we can not specify a domain on a reference field, we added a context
key `filter_template_on_event` on the template reference field. If this
key is set, we add our domain in the `args` in the `_name_search`
key is set, we add our domain in the `domain` in the `_search`
method to filtrate the mail templates.
"""
if self.env.context.get('filter_template_on_event'):
args = expression.AND([[('model', '=', 'event.registration')], args])
return super(MailTemplate, self)._name_search(name, args, operator, limit, name_get_uid)
domain = Domain('model', '=', 'event.registration') & Domain(domain)
return super()._search(domain, *args, **kwargs)
def unlink(self):
res = super().unlink()

View file

@ -1,22 +1,49 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
import base64
import binascii
from odoo import _, api, exceptions, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
module_event_sale = fields.Boolean("Tickets")
module_website_event_meet = fields.Boolean("Discussion Rooms")
def _default_use_google_maps_static_api(self):
api_key = self.env['ir.config_parameter'].sudo().get_param('google_maps.signed_static_api_key')
api_secret = self.env['ir.config_parameter'].sudo().get_param('google_maps.signed_static_api_secret')
return bool(api_key and api_secret)
google_maps_static_api_key = fields.Char("Google Maps API key", compute="_compute_maps_static_api_key",
readonly=False, store=True, config_parameter='google_maps.signed_static_api_key')
google_maps_static_api_secret = fields.Char("Google Maps API secret", compute="_compute_maps_static_api_secret",
readonly=False, store=True, config_parameter='google_maps.signed_static_api_secret')
module_event_sale = fields.Boolean("Tickets with Sale")
module_pos_event = fields.Boolean("Tickets with PoS")
module_website_event_track = fields.Boolean("Tracks and Agenda")
module_website_event_track_live = fields.Boolean("Live Mode")
module_website_event_track_quiz = fields.Boolean("Quiz on Tracks")
module_website_event_exhibitor = fields.Boolean("Advanced Sponsors")
module_website_event_questions = fields.Boolean("Registration Survey")
module_event_barcode = fields.Boolean("Barcode")
use_event_barcode = fields.Boolean(string="Use Event Barcode", help="Enable or Disable Event Barcode functionality.", config_parameter='event.use_event_barcode')
barcode_nomenclature_id = fields.Many2one('barcode.nomenclature', related='company_id.nomenclature_id', readonly=False)
module_website_event_sale = fields.Boolean("Online Ticketing")
module_event_booth = fields.Boolean("Booth Management")
use_google_maps_static_api = fields.Boolean("Google Maps static API", default=_default_use_google_maps_static_api)
@api.depends('use_google_maps_static_api')
def _compute_maps_static_api_key(self):
"""Clear API key on disabling google maps."""
for config in self:
if not config.use_google_maps_static_api:
config.google_maps_static_api_key = ''
@api.depends('use_google_maps_static_api')
def _compute_maps_static_api_secret(self):
"""Clear API secret on disabling google maps."""
for config in self:
if not config.use_google_maps_static_api:
config.google_maps_static_api_secret = ''
@api.onchange('module_website_event_track')
def _onchange_module_website_event_track(self):
@ -27,3 +54,23 @@ class ResConfigSettings(models.TransientModel):
if not config.module_website_event_track:
config.module_website_event_track_live = False
config.module_website_event_track_quiz = False
def _check_google_maps_static_api_secret(self):
for config in self:
if config.google_maps_static_api_secret:
try:
base64.urlsafe_b64decode(config.google_maps_static_api_secret)
except binascii.Error:
raise exceptions.UserError(_("Please enter a valid base64 secret"))
@api.model_create_multi
def create(self, vals_list):
configs = super().create(vals_list)
configs._check_google_maps_static_api_secret()
return configs
def write(self, vals):
configs = super().write(vals)
if vals.get('google_maps_static_api_secret'):
configs._check_google_maps_static_api_secret()
return configs

View file

@ -1,7 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
import base64
import binascii
import hmac
import requests
import werkzeug.urls
from odoo import api, fields, models
class ResPartner(models.Model):
@ -9,14 +16,72 @@ class ResPartner(models.Model):
event_count = fields.Integer(
'# Events', compute='_compute_event_count', groups='event.group_event_registration_desk')
static_map_url = fields.Char(compute="_compute_static_map_url")
static_map_url_is_valid = fields.Boolean(compute="_compute_static_map_url_is_valid")
def _compute_event_count(self):
self.event_count = 0
for partner in self:
partner.event_count = self.env['event.event'].search_count([('registration_ids.partner_id', 'child_of', partner.ids)])
@api.depends('zip', 'city', 'country_id', 'street')
def _compute_static_map_url(self):
for partner in self:
partner.static_map_url = partner._google_map_signed_img(zoom=13, width=598, height=200)
@api.depends('static_map_url')
def _compute_static_map_url_is_valid(self):
"""Compute whether the link is valid.
This should only remain valid for a relatively short time.
Here, for the duration it is in cache.
"""
session = requests.Session()
for partner in self:
url = partner.static_map_url
if not url:
partner.static_map_url_is_valid = False
continue
is_valid = False
# If the response isn't strictly successful, assume invalid url
try:
res = session.get(url, timeout=2)
if res.ok and not res.headers.get('X-Staticmap-API-Warning'):
is_valid = True
except requests.exceptions.RequestException:
pass
partner.static_map_url_is_valid = is_valid
def action_event_view(self):
action = self.env["ir.actions.actions"]._for_xml_id("event.action_event_view")
action['context'] = {}
action['domain'] = [('registration_ids.partner_id', 'child_of', self.ids)]
return action
def _google_map_signed_img(self, zoom=13, width=298, height=298):
"""Create a signed static image URL for the location of this partner."""
GOOGLE_MAPS_STATIC_API_KEY = self.env['ir.config_parameter'].sudo().get_param('google_maps.signed_static_api_key')
GOOGLE_MAPS_STATIC_API_SECRET = self.env['ir.config_parameter'].sudo().get_param('google_maps.signed_static_api_secret')
if not GOOGLE_MAPS_STATIC_API_KEY or not GOOGLE_MAPS_STATIC_API_SECRET:
return None
# generate signature as per https://developers.google.com/maps/documentation/maps-static/digital-signature#server-side-signing
location_string = f"{self.street}, {self.city} {self.zip}, {self.country_id and self.country_id.display_name or ''}"
params = {
'center': location_string,
'markers': f'size:mid|{location_string}',
'size': f"{width}x{height}",
'zoom': zoom,
'sensor': "false",
'key': GOOGLE_MAPS_STATIC_API_KEY,
}
unsigned_path = '/maps/api/staticmap?' + werkzeug.urls.url_encode(params)
try:
api_secret_bytes = base64.urlsafe_b64decode(GOOGLE_MAPS_STATIC_API_SECRET + "====")
except binascii.Error:
return None
url_signature_bytes = hmac.digest(api_secret_bytes, unsigned_path.encode(), 'sha1')
params['signature'] = base64.urlsafe_b64encode(url_signature_bytes)
return 'https://maps.googleapis.com/maps/api/staticmap?' + werkzeug.urls.url_encode(params)