mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 19:12:01 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' """
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
]
|
||||
31
odoo-bringout-oca-ocb-event/event/models/event_mail_slot.py
Normal file
31
odoo-bringout-oca-ocb-event/event/models/event_mail_slot.py
Normal 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)
|
||||
102
odoo-bringout-oca-ocb-event/event/models/event_question.py
Normal file
102
odoo-bringout-oca-ocb-event/event/models/event_question.py
Normal 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
|
||||
|
|
@ -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.'))
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
144
odoo-bringout-oca-ocb-event/event/models/event_slot.py
Normal file
144
odoo-bringout-oca-ocb-event/event/models/event_slot.py
Normal 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'))))
|
||||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
62
odoo-bringout-oca-ocb-event/event/models/event_type.py
Normal file
62
odoo-bringout-oca-ocb-event/event/models/event_type.py
Normal 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
|
||||
44
odoo-bringout-oca-ocb-event/event/models/event_type_mail.py
Normal file
44
odoo-bringout-oca-ocb-event/event/models/event_type_mail.py
Normal 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),
|
||||
}
|
||||
|
|
@ -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']
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue