summaryrefslogtreecommitdiff
path: root/addons/event/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/event/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/event/models')
-rw-r--r--addons/event/models/__init__.py11
-rw-r--r--addons/event/models/event_event.py575
-rw-r--r--addons/event/models/event_mail.py218
-rw-r--r--addons/event/models/event_registration.py305
-rw-r--r--addons/event/models/event_stage.py27
-rw-r--r--addons/event/models/event_tag.py34
-rw-r--r--addons/event/models/event_ticket.py150
-rw-r--r--addons/event/models/res_config_settings.py27
-rw-r--r--addons/event/models/res_partner.py25
9 files changed, 1372 insertions, 0 deletions
diff --git a/addons/event/models/__init__.py b/addons/event/models/__init__.py
new file mode 100644
index 00000000..6dcac187
--- /dev/null
+++ b/addons/event/models/__init__.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import event_event
+from . import event_mail
+from . import event_registration
+from . import event_stage
+from . import event_tag
+from . import event_ticket
+from . import res_config_settings
+from . import res_partner
diff --git a/addons/event/models/event_event.py b/addons/event/models/event_event.py
new file mode 100644
index 00000000..28398cb5
--- /dev/null
+++ b/addons/event/models/event_event.py
@@ -0,0 +1,575 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import pytz
+
+from odoo import _, api, fields, models
+from odoo.addons.base.models.res_partner import _tz_get
+from odoo.tools import format_datetime
+from odoo.exceptions import ValidationError
+from odoo.tools.translate import html_translate
+
+_logger = logging.getLogger(__name__)
+
+try:
+ import vobject
+except ImportError:
+ _logger.warning("`vobject` Python module not found, iCal file generation disabled. Consider installing this module if you want to generate iCal files")
+ vobject = None
+
+
+class EventType(models.Model):
+ _name = 'event.type'
+ _description = 'Event Template'
+ _order = 'sequence, id'
+
+ name = fields.Char('Event Template', required=True, translate=True)
+ sequence = fields.Integer()
+ # tickets
+ use_ticket = fields.Boolean('Ticketing')
+ event_type_ticket_ids = fields.One2many(
+ 'event.type.ticket', 'event_type_id',
+ string='Tickets', compute='_compute_event_type_ticket_ids',
+ readonly=False, store=True)
+ tag_ids = fields.Many2many('event.tag', string="Tags")
+ # registration
+ has_seats_limitation = fields.Boolean('Limited Seats')
+ seats_max = fields.Integer(
+ 'Maximum Registrations', compute='_compute_default_registration',
+ 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.")
+ # location
+ use_timezone = fields.Boolean('Use Default Timezone')
+ default_timezone = fields.Selection(
+ _tz_get, string='Timezone', default=lambda self: self.env.user.tz or 'UTC')
+ # communication
+ use_mail_schedule = fields.Boolean(
+ 'Automatically Send Emails', default=True)
+ event_type_mail_ids = fields.One2many(
+ 'event.type.mail', 'event_type_id',
+ string='Mail Schedule', compute='_compute_event_type_mail_ids',
+ readonly=False, store=True)
+
+ @api.depends('use_mail_schedule')
+ def _compute_event_type_mail_ids(self):
+ for template in self:
+ if not template.use_mail_schedule:
+ template.event_type_mail_ids = [(5, 0)]
+ elif not template.event_type_mail_ids:
+ template.event_type_mail_ids = [(0, 0, {
+ 'notification_type': 'mail',
+ 'interval_unit': 'now',
+ 'interval_type': 'after_sub',
+ 'template_id': self.env.ref('event.event_subscription').id,
+ }), (0, 0, {
+ 'notification_type': 'mail',
+ 'interval_nbr': 10,
+ 'interval_unit': 'days',
+ 'interval_type': 'before_event',
+ 'template_id': self.env.ref('event.event_reminder').id,
+ })]
+
+ @api.depends('use_ticket')
+ def _compute_event_type_ticket_ids(self):
+ for template in self:
+ if not template.use_ticket:
+ template.event_type_ticket_ids = [(5, 0)]
+ elif not template.event_type_ticket_ids:
+ template.event_type_ticket_ids = [(0, 0, {
+ 'name': _('Registration'),
+ })]
+
+ @api.depends('has_seats_limitation')
+ def _compute_default_registration(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'
+
+ def _get_default_stage_id(self):
+ event_stages = self.env['event.stage'].search([])
+ return event_stages[0] if event_stages else False
+
+ def _default_description(self):
+ return self.env['ir.ui.view']._render_template('event.event_default_descripton')
+
+ name = fields.Char(string='Event', translate=True, required=True)
+ note = fields.Text(string='Note')
+ description = fields.Html(string='Description', translate=html_translate, sanitize_attributes=False, sanitize_form=False, default=_default_description)
+ active = fields.Boolean(default=True)
+ user_id = fields.Many2one(
+ 'res.users', string='Responsible', tracking=True,
+ default=lambda self: self.env.user)
+ company_id = fields.Many2one(
+ 'res.company', string='Company', change_default=True,
+ default=lambda self: self.env.company,
+ required=False)
+ 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')
+ 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")
+ # Kanban fields
+ kanban_state = fields.Selection([('normal', 'In Progress'), ('done', 'Done'), ('blocked', 'Blocked')], default='normal')
+ kanban_state_label = fields.Char(
+ string='Kanban State Label', compute='_compute_kanban_state_label',
+ 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)
+ 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)
+ # Seats and computation
+ seats_max = fields.Integer(
+ string='Maximum Attendees Number',
+ 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.")
+ seats_limited = fields.Boolean('Maximum Attendees', required=True, compute='_compute_seats_limited',
+ readonly=False, store=True)
+ seats_reserved = fields.Integer(
+ string='Reserved Seats',
+ store=True, readonly=True, compute='_compute_seats')
+ seats_available = fields.Integer(
+ string='Available Seats',
+ store=True, readonly=True, compute='_compute_seats')
+ seats_unconfirmed = fields.Integer(
+ string='Unconfirmed Seat Reservations',
+ store=True, readonly=True, compute='_compute_seats')
+ seats_used = fields.Integer(
+ string='Number of Participants',
+ store=True, readonly=True, compute='_compute_seats')
+ seats_expected = fields.Integer(
+ string='Number of Expected Attendees',
+ compute_sudo=True, readonly=True, compute='_compute_seats_expected')
+ # 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')
+ event_ticket_ids = fields.One2many(
+ 'event.event.ticket', 'event_id', string='Event Ticket', copy=True,
+ compute='_compute_event_ticket_ids', readonly=False, store=True)
+ 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"
+ "- there are seats available on event\n"
+ "- the tickets are sellable (if ticketing is used)")
+ event_registrations_sold_out = fields.Boolean(
+ 'Sold Out', compute='_compute_event_registrations_sold_out', compute_sudo=True,
+ help='The event is sold out if no more seats are available on event. If ticketing is used and all tickets are sold out, the event will be sold out.')
+ start_sale_date = fields.Date(
+ '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='Timezone', required=True,
+ compute='_compute_date_tz', readonly=False, store=True)
+ date_begin = fields.Datetime(string='Start Date', required=True, tracking=True)
+ 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')
+ # 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)]")
+ country_id = fields.Many2one(
+ 'res.country', 'Country', related='address_id.country_id', readonly=False, store=True)
+ # badge fields
+ badge_front = fields.Html(string='Badge Front')
+ badge_back = fields.Html(string='Badge Back')
+ badge_innerleft = fields.Html(string='Badge Inner Left')
+ badge_innerright = fields.Html(string='Badge Inner Right')
+ event_logo = fields.Html(string='Event Logo')
+
+ @api.depends('stage_id', 'kanban_state')
+ def _compute_kanban_state_label(self):
+ 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
+
+ @api.depends('seats_max', 'registration_ids.state')
+ def _compute_seats(self):
+ """ Determine reserved, available, reserved but unconfirmed and used seats. """
+ # initialize fields to 0
+ for event in self:
+ event.seats_unconfirmed = 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',
+ }
+ base_vals = dict((fname, 0) for fname in state_field.values())
+ results = dict((event_id, dict(base_vals)) for event_id in self.ids)
+ 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')
+ GROUP BY event_id, state
+ """
+ self.env['event.registration'].flush(['event_id', 'state'])
+ self._cr.execute(query, (tuple(self.ids),))
+ res = self._cr.fetchall()
+ for event_id, state, num in res:
+ results[event_id][state_field[state]] += num
+
+ # compute seats_available
+ 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)
+
+ @api.depends('seats_unconfirmed', 'seats_reserved', 'seats_used')
+ def _compute_seats_expected(self):
+ for event in self:
+ event.seats_expected = event.seats_unconfirmed + event.seats_reserved + event.seats_used
+
+ @api.depends('date_tz', 'start_sale_date', 'date_end', 'seats_available', 'seats_limited', 'event_ticket_ids.sale_available')
+ def _compute_event_registrations_open(self):
+ """ Compute whether people may take registrations for this event
+
+ * event.date_end -> if event is done, registrations are not open anymore;
+ * event.start_sale_date -> lowest start date of tickets (if any; start_sale_date
+ is False if no ticket are defined, see _compute_start_sale_date);
+ * any ticket is available for sale (seats available) if any;
+ * seats are unlimited or seats are available;
+ """
+ for event in self:
+ 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.start_sale_date <= current_datetime.date() if event.start_sale_date else True) and \
+ (date_end_tz >= current_datetime if date_end_tz else True) and \
+ (not event.seats_limited or event.seats_available) and \
+ (not event.event_ticket_ids or any(ticket.sale_available for ticket in event.event_ticket_ids))
+
+ @api.depends('event_ticket_ids.start_sale_date')
+ def _compute_start_sale_date(self):
+ """ Compute the start sale date of an event. Currently lowest starting sale
+ date of tickets if they are used, of False. """
+ for event in self:
+ start_dates = [ticket.start_sale_date for ticket in event.event_ticket_ids if not ticket.is_expired]
+ event.start_sale_date = min(start_dates) if start_dates and all(start_dates) else False
+
+ @api.depends('event_ticket_ids.sale_available')
+ def _compute_event_registrations_sold_out(self):
+ for event in self:
+ if event.seats_limited and not event.seats_available:
+ event.event_registrations_sold_out = True
+ elif event.event_ticket_ids:
+ event.event_registrations_sold_out = not any(
+ ticket.seats_available > 0 if ticket.seats_limited else True for ticket in event.event_ticket_ids
+ )
+ else:
+ event.event_registrations_sold_out = False
+
+ @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()
+ for event in self:
+ event.is_ongoing = event.date_begin <= now < event.date_end
+
+ def _search_is_ongoing(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_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)]
+
+ @api.depends('date_begin', 'date_end', 'date_tz')
+ def _compute_field_is_one_day(self):
+ for event in self:
+ # Need to localize because it could begin late and finish early in
+ # another timezone
+ event = event._set_tz_context()
+ begin_tz = fields.Datetime.context_timestamp(event, event.date_begin)
+ end_tz = fields.Datetime.context_timestamp(event, event.date_end)
+ event.is_one_day = (begin_tz.date() == end_tz.date())
+
+ @api.depends('event_type_id')
+ def _compute_date_tz(self):
+ for event in self:
+ if event.event_type_id.use_timezone and event.event_type_id.default_timezone:
+ event.date_tz = event.event_type_id.default_timezone
+ if not event.date_tz:
+ event.date_tz = self.env.user.tz or 'UTC'
+
+ # seats
+
+ @api.depends('event_type_id')
+ def _compute_seats_max(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:
+ if not event.event_type_id:
+ event.seats_max = event.seats_max or 0
+ else:
+ event.seats_max = event.event_type_id.seats_max or 0
+
+ @api.depends('event_type_id')
+ def _compute_seats_limited(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:
+ if event.event_type_id.has_seats_limitation != event.seats_limited:
+ event.seats_limited = event.event_type_id.has_seats_limitation
+ 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
+ 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.
+
+ When synchronizing mails:
+
+ * lines that are not sent and have no registrations linked are remove;
+ * type lines are added;
+ """
+ for event in self:
+ if not event.event_type_id and not event.event_mail_ids:
+ event.event_mail_ids = False
+ continue
+
+ # lines to keep: those with already sent emails or registrations
+ mails_toremove = event._origin.event_mail_ids.filtered(lambda mail: not mail.mail_sent and not(mail.mail_registration_ids))
+ command = [(3, mail.id) for mail in mails_toremove]
+ if event.event_type_id.use_mail_schedule:
+ command += [
+ (0, 0, {
+ attribute_name: line[attribute_name] if not isinstance(line[attribute_name], models.BaseModel) else line[attribute_name].id
+ for attribute_name in self.env['event.type.mail']._get_event_mail_fields_whitelist()
+ }) for line in event.event_type_id.event_type_mail_ids
+ ]
+ if command:
+ event.event_mail_ids = command
+
+ @api.depends('event_type_id')
+ def _compute_tag_ids(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:
+ if not event.tag_ids and event.event_type_id.tag_ids:
+ event.tag_ids = event.event_type_id.tag_ids
+
+ @api.depends('event_type_id')
+ def _compute_event_ticket_ids(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.
+
+ When synchronizing tickets:
+
+ * lines that have no registrations linked are remove;
+ * type lines are added;
+
+ Note that updating event_ticket_ids triggers _compute_start_sale_date
+ (start_sale_date computation) so ensure result to avoid cache miss.
+ """
+ if self.ids or self._origin.ids:
+ # lines to keep: those with already sent emails or registrations
+ tickets_tokeep_ids = self.env['event.registration'].search(
+ [('event_id', 'in', self.ids or self._origin.ids)]
+ ).event_ticket_id.ids
+ else:
+ tickets_tokeep_ids = []
+ for event in self:
+ if not event.event_type_id and not event.event_ticket_ids:
+ event.event_ticket_ids = False
+ continue
+
+ # lines to keep: those with existing registrations
+ if tickets_tokeep_ids:
+ tickets_toremove = event._origin.event_ticket_ids.filtered(lambda ticket: ticket.id not in tickets_tokeep_ids)
+ command = [(3, ticket.id) for ticket in tickets_toremove]
+ else:
+ command = [(5, 0)]
+ if event.event_type_id.use_ticket:
+ command += [
+ (0, 0, {
+ attribute_name: line[attribute_name] if not isinstance(line[attribute_name], models.BaseModel) else line[attribute_name].id
+ for attribute_name in self.env['event.type.ticket']._get_event_ticket_fields_whitelist()
+ }) for line in event.event_type_id.event_type_ticket_ids
+ ]
+ event.event_ticket_ids = command
+
+ @api.constrains('seats_max', 'seats_available', 'seats_limited')
+ def _check_seats_limit(self):
+ if any(event.seats_limited and event.seats_max and event.seats_available < 0 for event in self):
+ raise ValidationError(_('No more available seats.'))
+
+ @api.constrains('date_begin', 'date_end')
+ def _check_closing_date(self):
+ for event in self:
+ if event.date_end < event.date_begin:
+ raise ValidationError(_('The closing date cannot be earlier than the beginning date.'))
+
+ @api.depends('name', 'date_begin', 'date_end')
+ def name_get(self):
+ result = []
+ for event in self:
+ date_begin = fields.Datetime.from_string(event.date_begin)
+ date_end = fields.Datetime.from_string(event.date_end)
+ dates = [fields.Date.to_string(fields.Datetime.context_timestamp(event, dt)) for dt in [date_begin, date_end] if dt]
+ dates = sorted(set(dates))
+ result.append((event.id, '%s (%s)' % (event.name, ' - '.join(dates))))
+ return result
+
+ @api.model
+ def _read_group_stage_ids(self, stages, domain, order):
+ return self.env['event.stage'].search([])
+
+ @api.model
+ def create(self, vals):
+ # Temporary fix for ``seats_limited`` and ``date_tz`` required fields
+ vals.update(self._sync_required_computed(vals))
+
+ res = super(EventEvent, self).create(vals)
+ if res.organizer_id:
+ res.message_subscribe([res.organizer_id.id])
+ res.flush()
+ return res
+
+ def write(self, vals):
+ res = super(EventEvent, self).write(vals)
+ if vals.get('organizer_id'):
+ self.message_subscribe([vals['organizer_id']])
+ return res
+
+ @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 _sync_required_computed(self, values):
+ # TODO: See if the change to seats_limited affects this ?
+ """ Call compute fields in cache to find missing values for required fields
+ (seats_limited and date_tz) in case they are not given in values """
+ missing_fields = list(set(['seats_limited', 'date_tz']).difference(set(values.keys())))
+ if missing_fields and values:
+ cache_event = self.new(values)
+ cache_event._compute_seats_limited()
+ cache_event._compute_date_tz()
+ return dict((fname, cache_event[fname]) for fname in missing_fields)
+ else:
+ return {}
+
+ def _set_tz_context(self):
+ self.ensure_one()
+ return self.with_context(tz=self.date_tz or 'UTC')
+
+ def action_set_done(self):
+ """
+ Action which will move the events
+ into the first next (by sequence) stage defined as "Ended"
+ (if they are not already in an ended stage)
+ """
+ first_ended_stage = self.env['event.stage'].search([('pipe_end', '=', True)], order='sequence')
+ if first_ended_stage:
+ self.write({'stage_id': first_ended_stage[0].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_ics_file(self):
+ """ Returns iCalendar file for the event invitation.
+ :returns a dict of .ics file content for each event
+ """
+ result = {}
+ if not vobject:
+ return result
+
+ for event in self:
+ cal = vobject.iCalendar()
+ cal_event = cal.add('vevent')
+
+ cal_event.add('created').value = fields.Datetime.now().replace(tzinfo=pytz.timezone('UTC'))
+ cal_event.add('dtstart').value = fields.Datetime.from_string(event.date_begin).replace(tzinfo=pytz.timezone('UTC'))
+ cal_event.add('dtend').value = fields.Datetime.from_string(event.date_end).replace(tzinfo=pytz.timezone('UTC'))
+ cal_event.add('summary').value = event.name
+ if event.address_id:
+ cal_event.add('location').value = event.sudo().address_id.contact_address
+
+ result[event.id] = cal.serialize().encode('utf-8')
+ return result
+
+ @api.autovacuum
+ def _gc_mark_events_done(self):
+ """ move every ended events in the next 'ended stage' """
+ ended_events = self.env['event.event'].search([
+ ('date_end', '<', fields.Datetime.now()),
+ ('stage_id.pipe_end', '=', False),
+ ])
+ if ended_events:
+ ended_events.action_set_done()
diff --git a/addons/event/models/event_mail.py b/addons/event/models/event_mail.py
new file mode 100644
index 00000000..9d9df8fa
--- /dev/null
+++ b/addons/event/models/event_mail.py
@@ -0,0 +1,218 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import random
+import threading
+
+from datetime import datetime
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models, tools
+from odoo.tools import exception_to_unicode
+from odoo.tools.translate import _
+
+_logger = logging.getLogger(__name__)
+
+_INTERVALS = {
+ 'hours': lambda interval: relativedelta(hours=interval),
+ 'days': lambda interval: relativedelta(days=interval),
+ 'weeks': lambda interval: relativedelta(days=7*interval),
+ 'months': lambda interval: relativedelta(months=interval),
+ 'now': lambda interval: relativedelta(hours=0),
+}
+
+
+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)
+ 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_id = fields.Many2one(
+ 'mail.template', string='Email Template',
+ domain=[('model', '=', 'event.registration')], ondelete='restrict',
+ help='This field contains the template of the mail that will be automatically sent')
+
+ @api.model
+ def _get_event_mail_fields_whitelist(self):
+ """ Whitelist of fields that are copied from event_type_mail_ids to event_mail_ids when
+ changing the event_type_id field of event.event """
+ return ['notification_type', 'template_id', 'interval_nbr', 'interval_unit', 'interval_type']
+
+
+class EventMailScheduler(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. """
+ _name = 'event.mail'
+ _rec_name = 'event_id'
+ _description = 'Event Automated Mailing'
+
+ event_id = fields.Many2one('event.event', string='Event', required=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'),
+ ('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_id = fields.Many2one(
+ 'mail.template', string='Email Template',
+ domain=[('model', '=', 'event.registration')], ondelete='restrict',
+ help='This field contains the template of the mail that will be automatically sent')
+ scheduled_date = fields.Datetime('Scheduled Sent Mail', compute='_compute_scheduled_date', store=True)
+ mail_registration_ids = fields.One2many('event.mail.registration', 'scheduler_id')
+ mail_sent = fields.Boolean('Mail Sent on Event', copy=False)
+ done = fields.Boolean('Sent', compute='_compute_done', store=True)
+
+ @api.depends('mail_sent', 'interval_type', 'event_id.registration_ids', 'mail_registration_ids')
+ def _compute_done(self):
+ for mail in self:
+ if mail.interval_type in ['before_event', 'after_event']:
+ mail.done = mail.mail_sent
+ else:
+ mail.done = len(mail.mail_registration_ids) == len(mail.event_id.registration_ids) and all(mail.mail_sent for mail in mail.mail_registration_ids)
+
+ @api.depends('event_id.date_begin', 'interval_type', 'interval_unit', 'interval_nbr')
+ def _compute_scheduled_date(self):
+ for mail in self:
+ if mail.interval_type == 'after_sub':
+ date, sign = mail.event_id.create_date, 1
+ elif mail.interval_type == 'before_event':
+ date, sign = mail.event_id.date_begin, -1
+ else:
+ date, sign = mail.event_id.date_end, 1
+
+ mail.scheduled_date = date + _INTERVALS[mail.interval_unit](sign * mail.interval_nbr) if date else False
+
+ def execute(self):
+ for mail in self:
+ now = fields.Datetime.now()
+ if mail.interval_type == 'after_sub':
+ # update registration lines
+ lines = [
+ (0, 0, {'registration_id': registration.id})
+ for registration in (mail.event_id.registration_ids - mail.mapped('mail_registration_ids.registration_id'))
+ ]
+ if lines:
+ mail.write({'mail_registration_ids': lines})
+ # execute scheduler on registrations
+ mail.mail_registration_ids.execute()
+ else:
+ # Do not send emails if the mailing was scheduled before the event but the event is over
+ if not mail.mail_sent and mail.scheduled_date <= now and mail.notification_type == 'mail' and \
+ (mail.interval_type != 'before_event' or mail.event_id.date_end > now):
+ mail.event_id.mail_attendees(mail.template_id.id)
+ mail.write({'mail_sent': True})
+ return True
+
+ @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_id
+ 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)
+
+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,
+ )
+ 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
+
+ @api.model
+ def run(self, autocommit=False):
+ schedulers = self.search([('done', '=', False), ('scheduled_date', '<=', datetime.strftime(fields.datetime.now(), tools.DEFAULT_SERVER_DATETIME_FORMAT))])
+ for scheduler in schedulers:
+ try:
+ with self.env.cr.savepoint():
+ # Prevent a mega prefetch of the registration ids of all the events of all the schedulers
+ self.browse(scheduler.id).execute()
+ except Exception as e:
+ _logger.exception(e)
+ self.invalidate_cache()
+ self._warn_template_error(scheduler, e)
+ else:
+ if autocommit and not getattr(threading.currentThread(), 'testing', False):
+ 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'
+ )
+ for reg_mail in todo:
+ reg_mail.scheduler_id.template_id.send_mail(reg_mail.registration_id.id)
+ todo.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:
+ date_open = mail.registration_id.date_open
+ date_open_datetime = date_open or fields.Datetime.now()
+ mail.scheduled_date = date_open_datetime + _INTERVALS[mail.scheduler_id.interval_unit](mail.scheduler_id.interval_nbr)
+ else:
+ mail.scheduled_date = False
diff --git a/addons/event/models/event_registration.py b/addons/event/models/event_registration.py
new file mode 100644
index 00000000..5b14153b
--- /dev/null
+++ b/addons/event/models/event_registration.py
@@ -0,0 +1,305 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import _, api, fields, models
+from odoo.tools import format_datetime
+from odoo.exceptions import AccessError, ValidationError
+
+
+class EventRegistration(models.Model):
+ _name = 'event.registration'
+ _description = 'Event Registration'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+ _order = 'id desc'
+
+ # event
+ event_id = fields.Many2one(
+ 'event.event', string='Event', required=True,
+ readonly=True, states={'draft': [('readonly', False)]})
+ event_ticket_id = fields.Many2one(
+ 'event.event.ticket', string='Event Ticket', readonly=True, ondelete='restrict',
+ states={'draft': [('readonly', False)]})
+ # utm informations
+ 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',
+ states={'done': [('readonly', True)]})
+ name = fields.Char(
+ string='Attendee Name', index=True,
+ 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)
+ # organization
+ date_open = fields.Datetime(string='Registration Date', readonly=True, default=lambda self: fields.Datetime.now()) # weird crash is directly now
+ 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)
+ company_id = fields.Many2one(
+ 'res.company', string='Company', related='event_id.company_id',
+ store=True, readonly=True, states={'draft': [('readonly', False)]})
+ state = fields.Selection([
+ ('draft', 'Unconfirmed'), ('cancel', 'Cancelled'),
+ ('open', 'Confirmed'), ('done', 'Attended')],
+ string='Status', default='draft', readonly=True, copy=False, tracking=True)
+
+ @api.onchange('partner_id')
+ def _onchange_partner_id(self):
+ """ Keep an explicit onchange on partner_id. Rationale : if user explicitly
+ changes the partner in interface, he want to update the whole customer
+ information. If partner_id is updated in code (e.g. updating your personal
+ information after having registered in website_event_sale) fields with a
+ value should not be reset as we don't know which one is the right one.
+
+ In other words
+ * computed fields based on partner_id should only update missing
+ information. Indeed automated code cannot decide which information
+ is more accurate;
+ * interface should allow to update all customer related information
+ at once. We consider event users really want to update all fields
+ related to the partner;
+ """
+ for registration in self:
+ if registration.partner_id:
+ registration.update(registration._synchronize_partner_values(registration.partner_id))
+
+ @api.depends('partner_id')
+ def _compute_name(self):
+ for registration in self:
+ if not registration.name and registration.partner_id:
+ registration.name = registration._synchronize_partner_values(
+ registration.partner_id,
+ fnames=['name']
+ ).get('name') or False
+
+ @api.depends('partner_id')
+ def _compute_email(self):
+ for registration in self:
+ if not registration.email and registration.partner_id:
+ registration.email = registration._synchronize_partner_values(
+ registration.partner_id,
+ 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(
+ registration.partner_id,
+ fnames=['phone']
+ ).get('phone') or False
+
+ @api.depends('partner_id')
+ def _compute_mobile(self):
+ for registration in self:
+ if not registration.mobile and registration.partner_id:
+ registration.mobile = registration._synchronize_partner_values(
+ registration.partner_id,
+ fnames=['mobile']
+ ).get('mobile') or False
+
+ @api.depends('state')
+ def _compute_date_closed(self):
+ for registration in self:
+ if not registration.date_closed:
+ if registration.state == 'done':
+ registration.date_closed = fields.Datetime.now()
+ else:
+ registration.date_closed = False
+
+ @api.constrains('event_id', 'state')
+ def _check_seats_limit(self):
+ for registration in self:
+ if registration.event_id.seats_limited and registration.event_id.seats_max and registration.event_id.seats_available < (1 if registration.state == 'draft' else 0):
+ raise ValidationError(_('No more seats available for this event.'))
+
+ @api.constrains('event_ticket_id', 'state')
+ def _check_ticket_seats_limit(self):
+ for record in self:
+ if record.event_ticket_id.seats_max and record.event_ticket_id.seats_available < 0:
+ raise ValidationError(_('No more available seats for this ticket'))
+
+ @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):
+ raise ValidationError(_('Invalid event / ticket choice'))
+
+ def _synchronize_partner_values(self, partner, fnames=None):
+ if fnames is None:
+ fnames = ['name', 'email', 'phone', 'mobile']
+ if partner:
+ contact_id = partner.address_get().get('contact', False)
+ if contact_id:
+ contact = self.env['res.partner'].browse(contact_id)
+ return dict((fname, contact[fname]) for fname in fnames if contact[fname])
+ return {}
+
+ # ------------------------------------------------------------
+ # CRUD
+ # ------------------------------------------------------------
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ registrations = super(EventRegistration, self).create(vals_list)
+ if registrations._check_auto_confirmation():
+ registrations.sudo().action_confirm()
+
+ return registrations
+
+ def write(self, vals):
+ ret = super(EventRegistration, self).write(vals)
+
+ if vals.get('state') == 'open':
+ # auto-trigger after_sub (on subscribe) mail schedulers, if needed
+ onsubscribe_schedulers = self.mapped('event_id.event_mail_ids').filtered(lambda s: s.interval_type == 'after_sub')
+ onsubscribe_schedulers.sudo().execute()
+
+ 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
+ """
+ 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 _check_auto_confirmation(self):
+ if any(not registration.event_id.auto_confirm or
+ (not registration.event_id.seats_available and registration.event_id.seats_limited) for registration in self):
+ return False
+ return True
+
+ # ------------------------------------------------------------
+ # ACTIONS / BUSINESS
+ # ------------------------------------------------------------
+
+ def action_set_draft(self):
+ self.write({'state': 'draft'})
+
+ def action_confirm(self):
+ self.write({'state': 'open'})
+
+ def action_set_done(self):
+ """ Close Registration """
+ self.write({'state': 'done'})
+
+ def action_cancel(self):
+ self.write({'state': 'cancel'})
+
+ 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
+
+ def _message_get_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': r.email,
+ 'email_cc': False}
+ for r in self}
+
+ def _message_post_after_hook(self, message, msg_vals):
+ if self.email and not self.partner_id:
+ # we consider that posting a message with a specified recipient (not a follower, a specific one)
+ # on a document without customer means that it was created through the chatter using
+ # suggested recipients. This heuristic allows to avoid ugly hacks in JS.
+ new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email)
+ if new_partner:
+ self.search([
+ ('partner_id', '=', False),
+ ('email', '=', new_partner.email),
+ ('state', 'not in', ['cancel']),
+ ]).write({'partner_id': new_partner.id})
+ return super(EventRegistration, self)._message_post_after_hook(message, msg_vals)
+
+ def action_send_badge_email(self):
+ """ Open a window to compose an email, with the template - 'event_badge'
+ message loaded by default
+ """
+ self.ensure_one()
+ template = self.env.ref('event.event_registration_mail_template_badge')
+ 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.id,
+ default_composition_mode='comment',
+ custom_layout="mail.mail_notification_light",
+ )
+ return {
+ 'name': _('Compose Email'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'mail.compose.message',
+ 'views': [(compose_form.id, 'form')],
+ 'view_id': compose_form.id,
+ 'target': 'new',
+ 'context': ctx,
+ }
+
+ def get_date_range_str(self):
+ self.ensure_one()
+ today = fields.Datetime.now()
+ event_date = self.event_begin_date
+ diff = (event_date.date() - today.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.month == (today + relativedelta(months=+1)).month:
+ return _('next month')
+ else:
+ return _('on %(date)s', date=format_datetime(self.env, self.event_begin_date, tz=self.event_id.date_tz, dt_format='medium'))
+
+ def _get_registration_summary(self):
+ self.ensure_one()
+ return {
+ 'id': self.id,
+ 'name': self.name,
+ 'partner_id': self.partner_id.id,
+ 'ticket_name': self.event_ticket_id.name or _('None'),
+ '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,
+ }
diff --git a/addons/event/models/event_stage.py b/addons/event/models/event_stage.py
new file mode 100644
index 00000000..d535ea75
--- /dev/null
+++ b/addons/event/models/event_stage.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, fields, models
+
+
+class EventStage(models.Model):
+ _name = 'event.stage'
+ _description = 'Event Stage'
+ _order = 'sequence, name'
+
+ name = fields.Char(string='Stage Name', required=True, translate=True)
+ description = fields.Text(string='Stage description', translate=True)
+ sequence = fields.Integer('Sequence', default=1)
+ fold = fields.Boolean(string='Folded in Kanban', default=False)
+ 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, 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, 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, required=True,
+ help='Override the default value displayed for the normal state for kanban selection.')
diff --git a/addons/event/models/event_tag.py b/addons/event/models/event_tag.py
new file mode 100644
index 00000000..dd097c33
--- /dev/null
+++ b/addons/event/models/event_tag.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from random import randint
+
+from odoo import api, fields, models
+
+
+class EventTagCategory(models.Model):
+ _name = "event.tag.category"
+ _description = "Event Tag Category"
+ _order = "sequence"
+
+ name = fields.Char("Name", required=True, translate=True)
+ sequence = fields.Integer('Sequence', default=0)
+ tag_ids = fields.One2many('event.tag', 'category_id', string="Tags")
+
+class EventTag(models.Model):
+ _name = "event.tag"
+ _description = "Event Tag"
+ _order = "sequence"
+
+ def _default_color(self):
+ return randint(1, 11)
+
+ 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')
+ color = fields.Integer(
+ string='Color Index', default=lambda self: self._default_color(),
+ help='Tag color. No color means no display in kanban or front-end, to distinguish internal tags from public categorization tags.')
+
+ def name_get(self):
+ return [(tag.id, "%s: %s" % (tag.category_id.name, tag.name)) for tag in self]
diff --git a/addons/event/models/event_ticket.py b/addons/event/models/event_ticket.py
new file mode 100644
index 00000000..b7358fef
--- /dev/null
+++ b/addons/event/models/event_ticket.py
@@ -0,0 +1,150 @@
+# -*- 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
+
+
+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='Seats Limit', readonly=True, store=True,
+ compute='_compute_seats_limited')
+ seats_max = fields.Integer(
+ string='Maximum Seats',
+ 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
+ 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'
+ _description = 'Event Ticket'
+
+ @api.model
+ def default_get(self, fields):
+ res = super(EventTicket, self).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
+
+ # description
+ event_type_id = fields.Many2one(ondelete='set null', required=False)
+ event_id = fields.Many2one(
+ 'event.event', string="Event",
+ ondelete='cascade', required=True)
+ company_id = fields.Many2one('res.company', related='event_id.company_id')
+ # sale
+ start_sale_date = fields.Date(string="Registration Start")
+ end_sale_date = fields.Date(string="Registration End")
+ is_expired = fields.Boolean(string='Is Expired', compute='_compute_is_expired')
+ sale_available = fields.Boolean(string='Is Available', compute='_compute_sale_available', compute_sudo=True)
+ registration_ids = fields.One2many('event.registration', 'event_ticket_id', string='Registrations')
+ # seats
+ seats_reserved = fields.Integer(string='Reserved Seats', compute='_compute_seats', store=True)
+ seats_available = fields.Integer(string='Available Seats', compute='_compute_seats', store=True)
+ seats_unconfirmed = fields.Integer(string='Unconfirmed Seats', compute='_compute_seats', store=True)
+ seats_used = fields.Integer(string='Used Seats', compute='_compute_seats', store=True)
+
+ @api.depends('end_sale_date', 'event_id.date_tz')
+ def _compute_is_expired(self):
+ for ticket in self:
+ ticket = ticket._set_tz_context()
+ current_date = fields.Date.context_today(ticket)
+ if ticket.end_sale_date:
+ ticket.is_expired = ticket.end_sale_date < current_date
+ else:
+ ticket.is_expired = False
+
+ @api.depends('is_expired', 'start_sale_date', 'event_id.date_tz', 'seats_available', 'seats_max')
+ def _compute_sale_available(self):
+ for ticket in self:
+ if not ticket.is_launched() or ticket.is_expired or (ticket.seats_max and ticket.seats_available <= 0):
+ ticket.sale_available = False
+ else:
+ ticket.sale_available = True
+
+ @api.depends('seats_max', 'registration_ids.state')
+ def _compute_seats(self):
+ """ Determine reserved, available, reserved but unconfirmed and used 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
+ # aggregate registrations by ticket and by state
+ 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')
+ GROUP BY event_ticket_id, state
+ """
+ self.env['event.registration'].flush(['event_id', 'event_ticket_id', 'state'])
+ self.env.cr.execute(query, (tuple(self.ids),))
+ for event_ticket_id, state, num in self.env.cr.fetchall():
+ ticket = self.browse(event_ticket_id)
+ ticket[state_field[state]] += num
+ # compute seats_available
+ for ticket in self:
+ if ticket.seats_max > 0:
+ ticket.seats_available = ticket.seats_max - (ticket.seats_reserved + ticket.seats_used)
+
+ @api.constrains('start_sale_date', 'end_sale_date')
+ def _constrains_dates_coherency(self):
+ for ticket in self:
+ if ticket.start_sale_date and ticket.end_sale_date and ticket.start_sale_date > ticket.end_sale_date:
+ raise UserError(_('The stop date cannot be earlier than the start date.'))
+
+ @api.constrains('seats_available', 'seats_max')
+ def _constrains_seats_available(self):
+ if any(record.seats_max and record.seats_available < 0 for record in self):
+ raise ValidationError(_('No more available seats for this ticket.'))
+
+ def _get_ticket_multiline_description(self):
+ """ Compute a multiline description of this ticket. It is used when ticket
+ description are necessary without having to encode it manually, like sales
+ information. """
+ return '%s\n%s' % (self.display_name, self.event_id.display_name)
+
+ def _set_tz_context(self):
+ self.ensure_one()
+ return self.with_context(tz=self.event_id.date_tz or 'UTC')
+
+ def is_launched(self):
+ # TDE FIXME: in master, make a computed field, easier to use
+ self.ensure_one()
+ if self.start_sale_date:
+ ticket = self._set_tz_context()
+ current_date = fields.Date.context_today(ticket)
+ return ticket.start_sale_date <= current_date
+ else:
+ return True
diff --git a/addons/event/models/res_config_settings.py b/addons/event/models/res_config_settings.py
new file mode 100644
index 00000000..14f5aebf
--- /dev/null
+++ b/addons/event/models/res_config_settings.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ module_event_sale = fields.Boolean("Tickets")
+ module_website_event_meet = fields.Boolean("Discussion Rooms")
+ 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_track_exhibitor = fields.Boolean("Advanced Sponsors")
+ module_website_event_questions = fields.Boolean("Registration Survey")
+ module_event_barcode = fields.Boolean("Barcode")
+ module_website_event_sale = fields.Boolean("Online Ticketing")
+
+ @api.onchange('module_website_event_track')
+ def _onchange_module_website_event_track(self):
+ """ Reset sub-modules, otherwise you may have track to False but still
+ have track_live or track_quiz to True, meaning track will come back due
+ to dependencies of modules. """
+ for config in self:
+ if not config.module_website_event_track:
+ config.module_website_event_track_live = False
+ config.module_website_event_track_quiz = False
+ config.module_website_event_track_exhibitor = False
diff --git a/addons/event/models/res_partner.py b/addons/event/models/res_partner.py
new file mode 100644
index 00000000..1311e15d
--- /dev/null
+++ b/addons/event/models/res_partner.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ event_count = fields.Integer(
+ '# Events', compute='_compute_event_count', groups='event.group_event_user',
+ help='Number of events the partner has participated.')
+
+ def _compute_event_count(self):
+ self.event_count = 0
+ if not self.user_has_groups('event.group_event_user'):
+ return
+ for partner in self:
+ partner.event_count = self.env['event.event'].search_count([('registration_ids.partner_id', 'child_of', partner.ids)])
+
+ 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