diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/event/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/event/models')
| -rw-r--r-- | addons/event/models/__init__.py | 11 | ||||
| -rw-r--r-- | addons/event/models/event_event.py | 575 | ||||
| -rw-r--r-- | addons/event/models/event_mail.py | 218 | ||||
| -rw-r--r-- | addons/event/models/event_registration.py | 305 | ||||
| -rw-r--r-- | addons/event/models/event_stage.py | 27 | ||||
| -rw-r--r-- | addons/event/models/event_tag.py | 34 | ||||
| -rw-r--r-- | addons/event/models/event_ticket.py | 150 | ||||
| -rw-r--r-- | addons/event/models/res_config_settings.py | 27 | ||||
| -rw-r--r-- | addons/event/models/res_partner.py | 25 |
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 |
