[MERGE] event[_sale]: use event tickets without event_sale

PURPOSE

Integration between event and eCommerce is required only when users handle the
entire selling process online. However users may require ticketing support
while managing payments outside of Odoo. Purpose of this commit is to support
tickets directly in event application without need of sales.

RATIONALE

Remove the need to have event_sale installed to manage basic multi ticket
event type. Integration with eCommerce is needed only when one wants to handle
the entire selling flow online, i.e. order, payment, ... Integration with
Sales is needed only when one wants to create sale orders linked to attendees.

Many event users do not need all of this. Their attendees pay through bank
transfers or they simply manage payments outside of Odoo while still
requiring tickets management.

See sub commits for more details. Here are a sample of main specifications
linked to this task.

SPECIFICATIONS: TICKET MOVE / SPLIT

Remove the need to have event_sale installed to manage basic ticketing on
events.

Move ticket model (event.event.ticket) directly into event, copying most
fields from event_sale. Only sale specific fields and behavior should be kept
in event_sale :

  * keep product_id and price information in event_sale;
  * keep sales analysis in event_sale;

We also split tickets model used for event type (event.type.ticket) and
events (event.event.ticket). Indeed previously to this commit both are
modeled in the same table, with the following issues :

  * tickets on templates use only a subset of fields: name, seats availability,
    product, price;
  * a ticket has either an event_id, either an event_type_id, and there are
    constraints to try to avoid having lost tickets. This leads to a strange
    model where m2o fields are required only in some cases with a dual
    behavior;
  * tickets are not shared between event.type and event.event. They are copied
    and having a single model is therefore not necessary;

We therefore choose to have a light model for event.type.ticket. It is linked
to event.type when configuring template tickets. They are copied in the
onchange copying event template configuration to the event itself, leading
to event.event.ticket creation.

Some tests are moved / completed accordingly.

Access rights are copied from website_event_sale to website_even concerning
ticket access for public / portal. Currently they are kept as they are with
some rewording as it is not the purpose of this commit to rewrite them.

SPECIFICATIONS: FRONTEND

In this commit we move frontend part of ticket support from website_event_sale
to website_event. Now eCommerce / event integration adds only payment
information when registering.

About tickets

  * if there is no ticket -> generic registration allowed;
  * if there is one ticket -> quick registration box;
  * more than one ticket -> unfolding registration box with all available
    tickets;

About price

  * sale not installed -> no mention of price. A ticket without price is not
    free. Its description allow to tell how to pay for example;
  * a price is set: price is displayed;
  * no price is set: FREE is displayed;

Most event frontend templates and controllers are therefore moved from
website_event_sale to website_event. Only part about pricing and sale order
creation is now located in website_event_sale.

Buy flow remains mainly untouched. Indeed this commit is mainly about moving
template to support tickets.

SPECIFICATIONS: improve ticket model and fields propagation

Remove date fields from event.type.ticket
Propagate seats definition from ticket template to tickets
Add a description on tickets to use in frontend
Improve naming of event tickets

LINKS

Task ID 2177281
Community PR #43488

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2020-01-30 17:59:39 +01:00
committed by GitHub
43 changed files with 1357 additions and 732 deletions
+4
View File
@@ -21,6 +21,8 @@ Key Features
'data': [
'security/event_security.xml',
'security/ir.model.access.csv',
'views/event_menu_views.xml',
'views/event_ticket_views.xml',
'views/event_views.xml',
'views/event_stage_views.xml',
'report/event_event_templates.xml',
@@ -32,7 +34,9 @@ Key Features
'views/res_partner_views.xml',
],
'demo': [
'data/res_users_demo.xml',
'data/event_demo.xml',
'data/event_registration_demo.xml',
],
'installable': True,
'auto_install': False,
+6
View File
@@ -27,6 +27,12 @@
<field name="auto_confirm" eval="True"/>
<field name="use_mail_schedule" eval="False"/>
</record>
<record id="event_type_data_ticket" model="event.type">
<field name="name">Ticketing</field>
<field name="auto_confirm" eval="False"/>
<field name="use_mail_schedule" eval="True"/>
<field name="use_ticket" eval="True"/>
</record>
<!-- Event stages -->
<record id="event_stage_new" model="event.stage">
+57 -18
View File
@@ -1,18 +1,17 @@
<?xml version="1.0"?>
<odoo>
<data>
<record id="base.user_demo" model="res.users">
<field name="groups_id" eval="[(4, ref('event.group_event_user'))]"/>
</record>
<!-- Event Type -->
<record id="event_type_0" model="event.type">
<field name="name">Exhibition</field>
<field name="use_mail_schedule" eval="False"/>
<field name="auto_confirm" eval="False"/>
</record>
<record id="event_type_1" model="event.type">
<field name="name">Training</field>
<field name="use_mail_schedule" eval="True"/>
<field name="auto_confirm" eval="True"/>
</record>
<record id="event_type_2" model="event.type">
<field name="name">Sport</field>
@@ -22,15 +21,40 @@
<!-- Event -->
<record id="event_0" model="event.event">
<field name="name">Design Fair Los Angeles</field>
<field name="user_id" ref="base.user_admin"/>
<field eval="(DateTime.now() + timedelta(days=1)).strftime('%Y-%m-%d 08:00:00')" name="date_begin"/>
<field eval="(DateTime.now() + timedelta(days=5)).strftime('%Y-%m-%d 18:00:00')" name="date_end"/>
<field name="user_id" ref="base.user_demo"/>
<field name="date_begin" eval="(DateTime.now() + timedelta(days=2)).strftime('%Y-%m-%d 08:00:00')"/>
<field name="date_end" eval="(DateTime.now() + timedelta(days=6)).strftime('%Y-%m-%d 18:00:00')"/>
<field name="seats_availability">limited</field>
<field name="seats_max">500</field>
<field name="seats_max">50</field>
<field name="address_id" ref="base.res_partner_1"/>
<field name="date_tz">US/Pacific</field>
<field name="event_type_id" ref="event_type_0"/>
<field name="auto_confirm" eval="False"/>
<field name="twitter_hashtag">DesignFairLA</field>
<field name="stage_id" ref="event_stage_booked"/>
</record>
<record id="event_0_ticket_0" model="event.event.ticket">
<field name="name">Free</field>
<field name="description">Free entrance, no food !</field>
<field name="event_id" ref="event.event_0"/>
<field name="start_sale_date" eval="(DateTime.today() - timedelta(days=5)).strftime('%Y-%m-%d')"/>
<field name="end_sale_date" eval="(DateTime.today() + timedelta(days=5)).strftime('%Y-%m-%d')"/>
<field name="seats_max">0</field>
</record>
<record id="event_0_ticket_1" model="event.event.ticket">
<field name="name">Standard</field>
<field name="description">For only 10, you gain access to catering. Yum yum.</field>
<field name="event_id" ref="event.event_0"/>
<field name="end_sale_date" eval="(DateTime.today() + timedelta(days=2)).strftime('%Y-%m-%d')"/>
<field name="seats_max">50</field>
</record>
<record id="event_0_ticket_2" model="event.event.ticket">
<field name="name">VIP</field>
<field name="description">You are truly among the best.</field>
<field name="event_id" ref="event.event_0"/>
<field name="end_sale_date" eval="(DateTime.today() + timedelta(days=3)).strftime('%Y-%m-%d')"/>
<field name="seats_max">10</field>
</record>
<record id="event.event_1" model="event.event">
<field name="name">Great Reno Ballon Race</field>
@@ -54,6 +78,18 @@
<field name="seats_max">200</field>
<field name="stage_id" ref="event_stage_booked"/>
</record>
<record id="event_2_ticket_1" model="event.event.ticket">
<field name="name">Standard</field>
<field name="event_id" ref="event.event_2"/>
<field name="end_sale_date" eval="(DateTime.today() + timedelta(90)).strftime('%Y-%m-%d')"/>
<field name="seats_max">50</field>
</record>
<record id="event_2_ticket_2" model="event.event.ticket">
<field name="name">VIP</field>
<field name="event_id" ref="event.event_2"/>
<field name="end_sale_date" eval="(DateTime.today() + timedelta(60)).strftime('%Y-%m-%d')"/>
<field name="seats_max">5</field>
</record>
<record id="event.event_3" model="event.event">
<field name="name">Live Music Festival</field>
@@ -66,15 +102,26 @@
<field name="stage_id" ref="event_stage_announced"/>
</record>
<!-- EVENT_4: very limited, intended to test seats reservation -->
<record id="event.event_4" model="event.event">
<field name="name">Business workshops</field>
<field name="user_id" ref="base.user_admin"/>
<field eval="(DateTime.today()+ timedelta(days=50)).strftime('%Y-%m-%d 18:00:00')" name="date_begin"/>
<field eval="(DateTime.today()+ timedelta(days=50)).strftime('%Y-%m-%d 22:30:00')" name="date_end"/>
<field name="event_type_id" ref="event_type_1"/>
<field name="date_begin" eval="(DateTime.today()+ timedelta(days=50)).strftime('%Y-%m-%d 18:00:00')"/>
<field name="date_end" eval="(DateTime.today()+ timedelta(days=50)).strftime('%Y-%m-%d 22:30:00')"/>
<field name="seats_availability">limited</field>
<field name="seats_max">4</field>
<field name="address_id" ref="base.res_partner_4"/>
<field name="date_tz">US/Pacific</field>
<field name="event_type_id" ref="event_type_1"/>
<field name="auto_confirm" eval="True"/>
<field name="stage_id" ref="event_stage_announced"/>
</record>
<record id="event_4_ticket_0" model="event.event.ticket">
<field name="name">General Admission</field>
<field name="event_id" ref="event.event_4"/>
<field name="end_sale_date" eval="(DateTime.today() + timedelta(30)).strftime('%Y-%m-%d')"/>
<field name="seats_max">4</field>
</record>
<record id="event.event_5" model="event.event">
<field name="name">Hockey Tournament</field>
@@ -94,13 +141,5 @@
<field name="address_id" ref="base.res_partner_1"/>
</record>
<!-- Attendee -->
<record id="reg_2_0" model="event.registration">
<field name="name">Camptocamp</field>
<field name="email">odoo@camptocamp.com</field>
<field name="phone">+41 21 619 10 04 </field>
<field name="event_id" ref="event_2"/>
<field name="partner_id" ref="base.res_partner_12"/>
</record>
</data>
</odoo>
@@ -0,0 +1,44 @@
<?xml version="1.0"?>
<odoo><data>
<record id="event_registration_0_0" model="event.registration">
<field name="event_id" ref="event.event_0"/>
<field name="event_ticket_id" ref="event.event_0_ticket_1"/>
<field name="partner_id" ref="base.res_partner_address_1"/>
</record>
<record id="event_registration_0_1" model="event.registration">
<field name="event_id" ref="event.event_0"/>
<field name="event_ticket_id" ref="event.event_0_ticket_1"/>
<field name="partner_id" ref="base.res_partner_address_2"/>
</record>
<record id="event_registration_0_2" model="event.registration">
<field name="event_id" ref="event.event_0"/>
<field name="event_ticket_id" ref="event.event_0_ticket_0"/>
<field name="name">Tucker Carlson</field>
<field name="name">tuck@test.example.com</field>
<field name="partner_id" eval="False"/>
</record>
<record id="event_registration_4_0" model="event.registration">
<field name="event_id" ref="event.event_4"/>
<field name="event_ticket_id" ref="event.event_4_ticket_0"/>
<field name="partner_id" ref="base.res_partner_address_7"/>
</record>
<record id="event_registration_4_1" model="event.registration">
<field name="event_id" ref="event.event_4"/>
<field name="event_ticket_id" ref="event.event_4_ticket_0"/>
<field name="partner_id" ref="base.res_partner_address_13"/>
</record>
<record id="event_registration_4_2" model="event.registration">
<field name="event_id" ref="event.event_4"/>
<field name="event_ticket_id" ref="event.event_4_ticket_0"/>
<field name="partner_id" ref="base.res_partner_address_14"/>
</record>
<function model="event.registration"
name="action_confirm"
eval="[[ref('event_registration_0_0'), ref('event_registration_0_1'),
ref('event_registration_4_0'), ref('event_registration_4_1'), ref('event_registration_4_2')]]"
/>
</data></odoo>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0"?>
<odoo>
<record id="base.user_demo" model="res.users">
<field name="groups_id" eval="[(4, ref('event.group_event_user'))]"/>
</record>
</odoo>
+1
View File
@@ -5,6 +5,7 @@ from . import event_event
from . import event_mail
from . import event_registration
from . import event_stage
from . import event_ticket
from . import res_config_settings
from . import res_partner
from . import ir_autovacuum
+42 -2
View File
@@ -41,6 +41,12 @@ class EventType(models.Model):
name = fields.Char('Event Category', 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)
# registration
has_seats_limitation = fields.Boolean('Limited Seats')
default_registration_min = fields.Integer(
@@ -69,6 +75,16 @@ class EventType(models.Model):
copy=False,
default=lambda self: self._get_default_event_type_mail_ids())
@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.onchange('has_seats_limitation')
def _onchange_has_seats_limitation(self):
if not self.has_seats_limitation:
@@ -143,6 +159,9 @@ class EventEvent(models.Model):
auto_confirm = fields.Boolean(string='Autoconfirm Registrations')
registration_ids = fields.One2many('event.registration', 'event_id', string='Attendees')
event_registrations_open = fields.Boolean('Registration open', compute='_compute_event_registrations_open')
event_ticket_ids = fields.One2many(
'event.event.ticket', 'event_id', string='Event Ticket',
copy=True)
# Date fields
date_tz = fields.Selection(
_tz_get, string='Timezone', required=True,
@@ -152,6 +171,7 @@ class EventEvent(models.Model):
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_one_day = fields.Boolean(compute='_compute_field_is_one_day')
start_sale_date = fields.Date('Start sale date', compute='_compute_start_sale_date')
# Location and communication
is_online = fields.Boolean('Online Event')
address_id = fields.Many2one(
@@ -196,10 +216,12 @@ class EventEvent(models.Model):
event.seats_available = event.seats_max - (event.seats_reserved + event.seats_used)
event.seats_expected = event.seats_unconfirmed + event.seats_reserved + event.seats_used
@api.depends('date_end', 'seats_available', 'seats_availability')
@api.depends('date_end', 'seats_available', 'seats_availability', 'event_ticket_ids.sale_available')
def _compute_event_registrations_open(self):
for event in self:
event.event_registrations_open = event.date_end > fields.Datetime.now() and (event.seats_available or event.seats_availability == 'unlimited')
event.event_registrations_open = (event.date_end > fields.Datetime.now()) and \
(event.seats_available or event.seats_availability == 'unlimited') and \
(not event.event_ticket_ids or any(ticket.sale_available for ticket in event.event_ticket_ids))
@api.depends('stage_id', 'kanban_state')
def _compute_kanban_state_label(self):
@@ -239,6 +261,12 @@ class EventEvent(models.Model):
end_tz = fields.Datetime.context_timestamp(event, event.date_end)
event.is_one_day = (begin_tz.date() == end_tz.date())
@api.depends('event_ticket_ids.start_sale_date')
def _compute_start_sale_date(self):
for event in self:
start_dates = [ticket.start_sale_date for ticket in event.event_ticket_ids if ticket.start_sale_date]
event.start_sale_date = min(start_dates) if start_dates else False
@api.onchange('is_online')
def _onchange_is_online(self):
if self.is_online:
@@ -271,6 +299,18 @@ class EventEvent(models.Model):
})
for line in self.event_type_id.event_type_mail_ids]
# compute tickets information
if self.event_type_id.use_ticket:
all_ticket_values = []
for ticket in self.event_type_id.event_type_ticket_ids:
ticket_vals = dict(
(attribute_name, ticket[attribute_name] if not isinstance(ticket[attribute_name], models.BaseModel) else ticket[attribute_name].id)
for attribute_name in self.env['event.type.ticket']._get_event_ticket_fields_whitelist()
)
all_ticket_values.append(ticket_vals)
self.event_ticket_ids = [(5, 0, 0)] + [(0, 0, item) for item in all_ticket_values]
@api.constrains('seats_min', 'seats_max', 'seats_availability')
def _check_seats_min_max(self):
if any(event.seats_availability == 'limited' and event.seats_min > event.seats_max for event in self):
+64 -39
View File
@@ -22,6 +22,9 @@ class EventRegistration(models.Model):
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,
states={'draft': [('readonly', False)]})
# attendee
partner_id = fields.Many2one(
'res.partner', string='Contact',
@@ -43,58 +46,71 @@ class EventRegistration(models.Model):
('open', 'Confirmed'), ('done', 'Attended')],
string='Status', default='draft', readonly=True, copy=False, tracking=True)
@api.onchange('event_id')
def _onchange_event_id(self):
# We reset the ticket when keeping it would lead to an inconstitent state.
if self.event_ticket_id and (not self.event_id or self.event_id != self.event_ticket_id.event_id):
self.event_ticket_id = None
@api.constrains('event_id', 'state')
def _check_seats_limit(self):
for registration in self:
if registration.event_id.seats_availability == '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.'))
def _check_auto_confirmation(self):
if self._context.get('registration_force_draft'):
return False
if any(not registration.event_id.auto_confirm or
(not registration.event_id.seats_available and registration.event_id.seats_availability == 'limited') for registration in self):
return False
return True
@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.onchange('partner_id')
def _onchange_partner(self):
if self.partner_id:
self.update(self._synchronize_partner_values(self.partner_id))
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
@api.model
def create(self, vals):
# update missing pieces of information from partner
if vals.get('partner_id'):
partner_vals = self._synchronize_partner_values(
self.env['res.partner'].browse(vals['partner_id'])
)
vals = dict(partner_vals, **vals)
registration = super(EventRegistration, self).create(vals)
if registration._check_auto_confirmation():
registration.sudo().action_confirm()
return registration
def write(self, vals):
if vals.get('state') == 'done' and 'date_closed' not in vals:
vals['date_closed'] = fields.Datetime.now()
ret = super(EventRegistration, self).write(vals)
# update missing pieces of information from partner
if vals.get('partner_id'):
partner_vals = self._synchronize_partner_values(
self.env['res.partner'].browse(vals['partner_id'])
)
for registration in self:
partner_info = dict((key, val) for key, val in partner_vals.items() if not registration[key])
if partner_info:
registration.write(partner_info)
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.execute()
elif vals.get('state') == 'done' and 'date_closed' not in vals:
vals['date_closed'] = fields.Datetime.now()
return ret
@api.model
def _prepare_attendee_values(self, registration):
""" Method preparing the values to create new attendees based on a
sales order line. It takes some registration data (dict-based) that are
optional values coming from an external input like a web page. This method
is meant to be inherited in various addons that sell events. """
partner_id = registration.pop('partner_id', self.env.user.partner_id)
event_id = registration.pop('event_id', False)
data = {
'name': registration.get('name', partner_id.name),
'phone': registration.get('phone', partner_id.phone),
'mobile': registration.get('mobile', partner_id.mobile),
'email': registration.get('email', partner_id.email),
'partner_id': partner_id.id,
'event_id': event_id and event_id.id or False,
}
data.update({key: value for key, value in registration.items() if key in self._fields})
return data
def name_get(self):
""" Custom name_get implementation to better differentiate registrations
linked to a given partner but with different name (one partner buying
@@ -116,6 +132,26 @@ class EventRegistration(models.Model):
ret_list.append((registration.id, name))
return ret_list
def _check_auto_confirmation(self):
if self._context.get('registration_force_draft'):
return False
if any(not registration.event_id.auto_confirm or
(not registration.event_id.seats_available and registration.event_id.seats_availability == 'limited') for registration in self):
return False
return True
def _synchronize_partner_values(self, partner):
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 ['name', 'email', 'phone', 'mobile'] if contact[fname])
return {}
# ------------------------------------------------------------
# ACTIONS / BUSINESS
# ------------------------------------------------------------
def action_set_draft(self):
self.write({'state': 'draft'})
@@ -129,17 +165,6 @@ class EventRegistration(models.Model):
def action_cancel(self):
self.write({'state': 'cancel'})
@api.onchange('partner_id')
def _onchange_partner(self):
if self.partner_id:
contact_id = self.partner_id.address_get().get('contact', False)
if contact_id:
contact = self.env['res.partner'].browse(contact_id)
self.name = contact.name or self.name
self.email = contact.email or self.email
self.phone = contact.phone or self.phone
self.mobile = contact.mobile or self.mobile
def _message_get_suggested_recipients(self):
recipients = super(EventRegistration, self)._message_get_suggested_recipients()
public_users = self.env['res.users'].sudo()
+141
View File
@@ -0,0 +1,141 @@
# -*- 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_availability = fields.Selection([
('limited', 'Limited'), ('unlimited', 'Unlimited')], string='Seats Limit',
readonly=True, store=True, compute='_compute_seats_availability')
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_availability(self):
for ticket in self:
ticket.seats_availability = 'limited' if ticket.seats_max else 'unlimited'
@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')
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:
if ticket.end_sale_date:
current_date = fields.Date.context_today(ticket.with_context(tz=ticket._get_ticket_tz()))
ticket.is_expired = ticket.end_sale_date < current_date
else:
ticket.is_expired = False
@api.depends('start_sale_date', 'end_sale_date', 'event_id.date_tz')
def _compute_sale_available(self):
for ticket in self:
current_date = fields.Date.context_today(ticket.with_context(tz=ticket._get_ticket_tz()))
if (ticket.start_sale_date and ticket.start_sale_date > current_date) or \
ticket.end_sale_date and ticket.end_sale_date < current_date:
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 _get_ticket_tz(self):
return self.event_id.date_tz or self.env.user.tz
@@ -1,9 +1,13 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_event_type,event.type,model_event_type,event.group_event_user,1,0,0,0
access_event_type_manager,event.type manager,model_event_type,event.group_event_manager,1,1,1,1
access_event_type_ticket,event.type.ticket.user,model_event_type_ticket,event.group_event_user,1,0,0,0
access_event_type_ticket_manager,event.type.ticket.manager,model_event_type_ticket,event.group_event_manager,1,1,1,1
access_event_event_portal,event.event.portal,model_event_event,,1,0,0,0
access_event_event_user,event.event.user,model_event_event,event.group_event_user,1,0,0,0
access_event_event_manager,event.event.manager,model_event_event,event.group_event_manager,1,1,1,1
access_event_event_ticket_user,event.event.ticket.user,model_event_event_ticket,event.group_event_user,1,0,0,0
access_event_event_ticket_manager,event.event.ticket.manager,model_event_event_ticket,event.group_event_manager,1,1,1,1
access_event_registration,event.registration,model_event_registration,event.group_event_user,1,1,1,1
access_event_registration_employee,event.registration,model_event_registration,base.group_user,1,0,0,0
access_event_registration_portal,event.registration,model_event_registration,base.group_portal,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_event_type event.type model_event_type event.group_event_user 1 0 0 0
3 access_event_type_manager event.type manager model_event_type event.group_event_manager 1 1 1 1
4 access_event_type_ticket event.type.ticket.user model_event_type_ticket event.group_event_user 1 0 0 0
5 access_event_type_ticket_manager event.type.ticket.manager model_event_type_ticket event.group_event_manager 1 1 1 1
6 access_event_event_portal event.event.portal model_event_event 1 0 0 0
7 access_event_event_user event.event.user model_event_event event.group_event_user 1 0 0 0
8 access_event_event_manager event.event.manager model_event_event event.group_event_manager 1 1 1 1
9 access_event_event_ticket_user event.event.ticket.user model_event_event_ticket event.group_event_user 1 0 0 0
10 access_event_event_ticket_manager event.event.ticket.manager model_event_event_ticket event.group_event_manager 1 1 1 1
11 access_event_registration event.registration model_event_registration event.group_event_user 1 1 1 1
12 access_event_registration_employee event.registration model_event_registration base.group_user 1 0 0 0
13 access_event_registration_portal event.registration model_event_registration base.group_portal 1 0 0 0
+3 -2
View File
@@ -1,7 +1,8 @@
.o_kanban_view.o_event_kanban_view {
.o_kanban_record {
min-height: 140px;
}
.o_kanban_content {
min-height: 130px;
.o_event_fontweight_500 {
font-weight: 500;
}
+7
View File
@@ -59,6 +59,13 @@ class TestEventCommon(common.SavepointCase):
'default_timezone': 'Europe/Paris',
'use_hashtag': True,
'default_hashtag': 'amazing',
'use_ticket': True,
'event_type_ticket_ids': [(0, 0, {
'name': 'First Ticket',
}), (0, 0, {
'name': 'Second Ticket',
})
],
'use_mail_schedule': True,
'event_type_mail_ids': [
(0, 0, { # right at subscription
+108 -2
View File
@@ -1,11 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from unittest.mock import patch
from odoo.addons.event.tests.common import TestEventCommon
from odoo.fields import Datetime as FieldsDatetime
from odoo import exceptions
from odoo.fields import Datetime as FieldsDatetime, Date as FieldsDate
from odoo.tests.common import users
from odoo.tools import mute_logger
@@ -149,6 +150,105 @@ class TestEventData(TestEventCommon):
self.assertEqual(event.event_mail_ids.interval_type, 'before_event')
self.assertEqual(event.event_mail_ids.template_id, self.env.ref('event.event_reminder'))
@users('user_eventmanager')
def test_event_registrable(self):
"""Test if `_compute_event_registrations_open` works properly."""
event = self.event_0.with_user(self.env.user)
self.assertTrue(event.event_registrations_open)
# ticket without dates boundaries -> ok
ticket = self.env['event.event.ticket'].create({
'name': 'TestTicket',
'event_id': event.id,
})
self.assertTrue(event.event_registrations_open)
# even with valid tickets, date limits registrations
event.write({
'date_begin': datetime.now() - timedelta(days=3),
'date_end': datetime.now() - timedelta(days=1),
})
self.assertFalse(event.event_registrations_open)
# no more seats available
registration = self.env['event.registration'].create({
'name': 'Albert Test',
'event_id': event.id,
})
registration.action_confirm()
event.write({
'date_end': datetime.now() + timedelta(days=3),
'seats_max': 1,
'seats_availability': 'limited',
})
self.assertEqual(event.seats_available, 0)
self.assertFalse(event.event_registrations_open)
# seats available are back
registration.unlink()
self.assertEqual(event.seats_available, 1)
self.assertTrue(event.event_registrations_open)
# but tickets are expired
ticket.write({'end_sale_date': datetime.now() - timedelta(days=2)})
self.assertTrue(ticket.is_expired)
self.assertFalse(event.event_registrations_open)
class TestEventTicketData(TestEventCommon):
def setUp(self):
super(TestEventTicketData, self).setUp()
self.ticket_date_patcher = patch('odoo.addons.event.models.event_ticket.fields.Date', wraps=FieldsDate)
self.ticket_date_patcher_mock = self.ticket_date_patcher.start()
self.ticket_date_patcher_mock.context_today.return_value = date(2020, 1, 31)
def tearDown(self):
super(TestEventTicketData, self).tearDown()
self.ticket_date_patcher.stop()
@users('user_eventmanager')
def test_event_ticket_fields(self):
""" Test event ticket fields synchronization """
event = self.event_0.with_user(self.env.user)
event.write({
'event_ticket_ids': [
(5, 0),
(0, 0, {
'name': 'First Ticket',
'seats_max': 30,
}), (0, 0, { # limited in time, available (01/10 (start) < 01/31 (today) < 02/10 (end))
'name': 'Second Ticket',
'start_sale_date': date(2020, 1, 10),
'end_sale_date': date(2020, 2, 10),
})
],
})
first_ticket = event.event_ticket_ids.filtered(lambda t: t.name == 'First Ticket')
second_ticket = event.event_ticket_ids.filtered(lambda t: t.name == 'Second Ticket')
self.assertEqual(first_ticket.seats_availability, 'limited')
self.assertTrue(first_ticket.sale_available)
self.assertFalse(first_ticket.is_expired)
self.assertEqual(second_ticket.seats_availability, 'unlimited')
self.assertTrue(second_ticket.sale_available)
self.assertFalse(second_ticket.is_expired)
# sale is ended
second_ticket.write({'end_sale_date': date(2020, 1, 20)})
self.assertFalse(second_ticket.sale_available)
self.assertTrue(second_ticket.is_expired)
# sale has not started
second_ticket.write({
'start_sale_date': date(2020, 2, 10),
'end_sale_date': date(2020, 2, 20),
})
self.assertFalse(second_ticket.sale_available)
self.assertFalse(second_ticket.is_expired)
# incoherent dates are invalid
with self.assertRaises(exceptions.UserError):
second_ticket.write({'end_sale_date': date(2020, 1, 20)})
class TestEventTypeData(TestEventCommon):
@@ -161,11 +261,13 @@ class TestEventTypeData(TestEventCommon):
'has_seats_limitation': True,
'default_registration_min': 5,
'default_registration_max': 30,
'use_ticket': True,
})
event_type._onchange_has_seats_limitation()
self.assertTrue(event_type.has_seats_limitation)
self.assertEqual(event_type.default_registration_min, 5)
self.assertEqual(event_type.default_registration_max, 30)
self.assertEqual(event_type.event_type_ticket_ids.mapped('name'), ['Registration'])
# reset seats limitation
event_type.write({'has_seats_limitation': False})
@@ -173,3 +275,7 @@ class TestEventTypeData(TestEventCommon):
self.assertFalse(event_type.has_seats_limitation)
self.assertEqual(event_type.default_registration_min, 0)
self.assertEqual(event_type.default_registration_max, 0)
# reset tickets
event_type.write({'use_ticket': False})
self.assertEqual(event_type.event_type_ticket_ids, self.env['event.type.ticket'])
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0"?>
<odoo><data>
<!-- MAIN MENU -->
<menuitem name="Events"
id="event_main_menu"
sequence="65"
groups="event.group_event_user"
web_icon="event,static/description/icon.png"/>
<!-- HEADER: EVENTS -->
<menuitem name="Events"
id="menu_event_event"
sequence="1"
parent="event.event_main_menu"
groups="event.group_event_user"/>
<!-- HEADER: REPORTING -->
<menuitem name="Reporting"
id="menu_reporting_events"
sequence="50"
parent="event_main_menu"
groups="event.group_event_manager"/>
<!-- HEADER: CONFIGURATION -->
<menuitem name="Configuration"
id="menu_event_configuration"
sequence="99"
parent="event_main_menu"
groups="event.group_event_user"/>
<menuitem name="Event Categories"
id="menu_event_type"
sequence="1"
parent="menu_event_configuration"/>
<menuitem name="Mail Schedulers"
id="menu_event_mail_schedulers"
sequence="10"
parent="menu_event_configuration"
groups="base.group_no_one"/>
</data></odoo>
+182
View File
@@ -0,0 +1,182 @@
<?xml version="1.0"?>
<odoo><data>
<!-- EVENT.TYPE.TICKET -->
<record id="event_type_ticket_view_tree_from_type" model="ir.ui.view">
<field name="name">event.type.ticket.view.tree.from.type</field>
<field name="model">event.type.ticket</field>
<field name="priority" eval="20"/>
<field name="arch" type="xml">
<tree string="Event Template Tickets" editable="bottom">
<field name="name"/>
<field name="description"/>
<field name="seats_max"/>
<field name="seats_availability"/>
</tree>
</field>
</record>
<record id="event_type_ticket_view_form_from_type" model="ir.ui.view">
<field name="name">event.type.ticket.view.form.from.type</field>
<field name="model">event.type.ticket</field>
<field name="priority" eval="20"/>
<field name="arch" type="xml">
<form string="Event Template Ticket">
<sheet>
<group>
<field name="name"/>
<field name="description"/>
<field name="seats_availability"/>
<field name="seats_max"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="event_type_ticket_view_tree" model="ir.ui.view">
<field name="name">event.type.ticket.view.tree</field>
<field name="model">event.type.ticket</field>
<field name="inherit_id" ref="event_type_ticket_view_tree_from_type"/>
<field name="mode">primary</field>
<field name="priority" eval="10"/>
<field name="arch" type="xml">
<xpath expr="//tree" position="attributes">
<attribute name="editable"></attribute>
</xpath>
<xpath expr="//field[@name='name']" position="after">
<field name="event_type_id"/>
</xpath>
</field>
</record>
<record id="event_type_ticket_view_form" model="ir.ui.view">
<field name="name">event.type.ticket.view.form</field>
<field name="model">event.type.ticket</field>
<field name="inherit_id" ref="event_type_ticket_view_form_from_type"/>
<field name="mode">primary</field>
<field name="priority" eval="10"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="event_type_id"/>
</xpath>
</field>
</record>
<!-- EVENT.TICKET -->
<record id="event_event_ticket_view_tree_from_event" model="ir.ui.view">
<field name="name">event.event.ticket.view.tree.from.event</field>
<field name="model">event.event.ticket</field>
<field name="priority" eval="20"/>
<field name="arch" type="xml">
<tree string="Tickets" editable="bottom">
<field name="name"/>
<field name="description"/>
<field name="start_sale_date"/>
<field name="end_sale_date"/>
<field name="seats_max"/>
<field name="seats_reserved"/>
<field name="seats_unconfirmed"/>
</tree>
</field>
</record>
<record id="event_event_ticket_view_form_from_event" model="ir.ui.view">
<field name="name">event.event.ticket.view.form.from.event</field>
<field name="model">event.event.ticket</field>
<field name="priority" eval="20"/>
<field name="arch" type="xml">
<form string="Ticket">
<sheet>
<group>
<group>
<field name="name"/>
<field name="description"/>
<field name="start_sale_date"/>
<field name="end_sale_date"/>
</group><group>
<field name="seats_max"/>
<field name="seats_reserved"/>
<field name="seats_unconfirmed"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="event_event_ticket_view_kanban_from_event" model="ir.ui.view">
<field name="name">event.event.ticket.view.kanban.from.event</field>
<field name="model">event.event.ticket</field>
<field name="priority" eval="20"/>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile">
<field name="name"/>
<field name="seats_max"/>
<field name="seats_reserved"/>
<field name="seats_unconfirmed"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card oe_kanban_global_click">
<div class="row">
<div class="col-8">
<strong><t t-esc="record.name.value"/></strong>
</div>
</div>
<div><i>
<t t-esc="record.seats_reserved.value"/> reserved + <t t-esc="record.seats_reserved.value"/> unconfirmed
</i></div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="event_event_ticket_view_tree" model="ir.ui.view">
<field name="name">event.event.ticket.view.tree</field>
<field name="model">event.event.ticket</field>
<field name="inherit_id" ref="event_event_ticket_view_tree_from_event"/>
<field name="mode">primary</field>
<field name="priority" eval="10"/>
<field name="arch" type="xml">
<xpath expr="//tree" position="attributes">
<attribute name="editable"></attribute>
</xpath>
<field name="name" position="after">
<field name="event_id"/>
</field>
</field>
</record>
<record id="event_event_ticket_form_view" model="ir.ui.view">
<field name="name">event.event.ticket.view.form</field>
<field name="model">event.event.ticket</field>
<field name="arch" type="xml">
<form string="Event's Ticket">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="Event Name"/></h1>
</div>
<group>
<group>
<field name="event_id"/>
<field name="seats_availability"/>
<field name="seats_available"/>
<field name="start_sale_date"/>
<field name="end_sale_date"/>
</group>
<group>
<field name="seats_max"/>
<field name="seats_reserved"/>
<field name="seats_unconfirmed"/>
<field name="seats_used"/>
<field name="is_expired"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
</data></odoo>
+48 -12
View File
@@ -2,10 +2,6 @@
<odoo>
<data>
<!-- Main menu items -->
<menuitem name="Events" id="event_main_menu" sequence="65" groups="event.group_event_user" web_icon="event,static/description/icon.png"/>
<menuitem parent="event_main_menu" id="menu_reporting_events" sequence="99" groups="event.group_event_manager" name="Reporting"/>
<!-- EVENT.TYPE VIEWS -->
<record model="ir.ui.view" id="view_event_type_form">
<field name="name">event.type.form</field>
@@ -111,6 +107,24 @@
</div>
<h2>Attendees</h2>
<div class="row mt16 o_settings_container" name="event_type_attendees">
<div class="col-12 o_setting_box" name="event_type_attendees_tickets">
<div class="o_setting_left_pane">
<field name="use_ticket"/>
</div>
<div class="o_setting_right_pane">
<label for="use_ticket"/>
<div class="row mt16" attrs="{'invisible': [('use_ticket', '=', False)]}">
<div class="col">
<field name="event_type_ticket_ids"
class="w-100"
context="{
'tree_view_ref': 'event.event_type_ticket_view_tree_from_type',
'form_view_ref': 'event.event_type_ticket_view_form_from_type'
}"/>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" name="event_type_attendees_auto_confirm">
<div class="o_setting_left_pane">
<field name="auto_confirm"/>
@@ -158,9 +172,9 @@
<field name="res_model">event.type</field>
</record>
<menuitem id="menu_event_configuration" name="Configuration" parent="event_main_menu"
sequence="100"/>
<menuitem name="Event Categories" id="menu_event_type" action="action_event_type" parent="menu_event_configuration"/>
<record id="menu_event_type" model="ir.ui.menu">
<field name="action" ref="event.action_event_type"/>
</record>
<!-- EVENT.REGISTRATION ACTIONS -->
<record id="act_event_registration_from_event" model="ir.actions.act_window">
@@ -263,6 +277,13 @@
</group>
</group>
<notebook>
<page string="Tickets" name="tickets">
<field name="event_ticket_ids" context="{
'default_event_name': name,
'tree_view_ref': 'event.event_event_ticket_view_tree_from_event',
'form_view_ref': 'event.event_event_ticket_view_form_from_event',
'kanban_view_ref': 'event.event_event_ticket_view_kanban_from_event'}" mode="tree,kanban"/>
</page>
<page string="Communication" name="event_communication">
<field name="event_mail_ids">
<tree string="Communication" editable="bottom">
@@ -348,8 +369,8 @@
<field name="activity_state"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="p-0 {{!selection_mode ? 'oe_kanban_color_' + kanban_getcolor(record.color.raw_value) : ''}} oe_kanban_card oe_kanban_global_click">
<div class="o_kanban_content p-0 m-0 position-relative row d-flex">
<div t-attf-class="d-flex flex-column p-0 {{!selection_mode ? 'oe_kanban_color_' + kanban_getcolor(record.color.raw_value) : ''}} oe_kanban_card oe_kanban_global_click">
<div class="o_kanban_content p-0 m-0 position-relative row d-flex flex-fill">
<div class="col-3 bg-primary p-2 text-center d-flex flex-column justify-content-center">
<div t-esc="record.date_begin.raw_value.getDate()" class="o_event_fontsize_20"/>
<div>
@@ -475,7 +496,9 @@
</field>
</record>
<menuitem name="Events" id="menu_event_event" action="action_event_view" parent="event.event_main_menu" />
<record id="event.menu_event_event" model="ir.ui.menu">
<field name="action" ref="event.action_event_view"/>
</record>
<menuitem name="Events"
id="event_event_menu_pivot_report"
action="event_event_action_pivot"
@@ -494,7 +517,10 @@
<field name="partner_id"/>
<field name="name"/>
<field name="email" optional="hide"/>
<field name="event_id" />
<field name="phone" optional="hide"/>
<field name="mobile" optional="hide"/>
<field name="event_id"/>
<field name="event_ticket_id"/>
<field name="origin" optional="hide"/>
<field name="state" readonly="0"/>
<field name="company_id" groups="base.group_multi_company" optional="hide"/>
@@ -531,6 +557,13 @@
</group>
<group string="Event Information" name="event">
<field name="event_id" attrs="{'readonly': [('state', '!=', 'draft')]}" options="{'no_create': True}"/>
<field name="event_ticket_id"
domain="[
('event_id', '=', event_id),
'|', ('seats_availability', '=', 'unlimited'), ('seats_available', '>', 0)
]"
attrs="{'invisible': [('event_id', '=', False)]}"
options="{'no_open': True, 'no_create': True}"/>
<field name="date_open" groups="base.group_no_one"/>
<field name="date_closed" groups="base.group_no_one"/>
</group>
@@ -643,6 +676,7 @@
<group expand="0" string="Group By">
<filter string="Partner" name="partner" domain="[]" context="{'group_by':'partner_id'}"/>
<filter string="Event" name="group_event" domain="[]" context="{'group_by':'event_id'}"/>
<filter string="Ticket Type" name ="group_event_ticket_id" domain="[]" context="{'group_by': 'event_ticket_id'}"/>
<filter string="Status" name="status" domain="[]" context="{'group_by':'state'}"/>
<filter string="Registration Date" name="createmonth" domain="[]" context="{'group_by': 'create_date:month'}"/>
</group>
@@ -726,6 +760,8 @@
<field name="res_model">event.mail</field>
</record>
<menuitem name="Mail Schedulers" id="menu_event_mail_schedulers" action="action_event_mail" parent="menu_event_configuration" groups="base.group_no_one"/>
<record id="menu_event_mail_schedulers" model="ir.ui.menu">
<field name="action" ref="event.action_event_mail"/>
</record>
</data>
</odoo>
+16 -33
View File
@@ -1,48 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="event_0_ticket_1" model="event.event.ticket">
<field name="name">Standard</field>
<field name="event_id" ref="event.event_0"/>
<record id="event.event_0_ticket_0" model="event.event.ticket">
<field name="product_id" ref="product_product_event"/>
<field name="price">0</field>
</record>
<record id="event.event_0_ticket_1" model="event.event.ticket">
<field name="product_id" ref="product_product_event"/>
<field name="end_sale_date" eval="(DateTime.today() + timedelta(90)).strftime('%Y-%m-%d')"/>
<field name="price">1000.0</field>
<field name="seats_max">50</field>
</record>
<record id="event_0_ticket_2" model="event.event.ticket">
<field name="name">VIP</field>
<field name="event_id" ref="event.event_0"/>
<record id="event.event_0_ticket_2" model="event.event.ticket">
<field name="product_id" ref="product_product_event"/>
<field name="end_sale_date" eval="(DateTime.today() + timedelta(60)).strftime('%Y-%m-%d')"/>
<field name="price">1500.0</field>
<field name="seats_max">10</field>
</record>
<record id="event_4_ticket_1" model="event.event.ticket">
<field name="name">General Admission</field>
<field name="event_id" ref="event.event_4"/>
<record id="event.event_2_ticket_1" model="event.event.ticket">
<field name="product_id" ref="product_product_event"/>
<field name="price">1000.0</field>
</record>
<record id="event.event_2_ticket_2" model="event.event.ticket">
<field name="product_id" ref="product_product_event"/>
<field name="price">1500.0</field>
</record>
<record id="event.event_4_ticket_0" model="event.event.ticket">
<field name="product_id" ref="product_product_event"/>
<field name="end_sale_date" eval="(DateTime.today() + timedelta(30)).strftime('%Y-%m-%d')"/>
<field name="price">99.0</field>
<field name="seats_max">100</field>
</record>
<record id="event_2_ticket_1" model="event.event.ticket">
<field name="name">Standard</field>
<field name="event_id" ref="event.event_2"/>
<field name="product_id" ref="product_product_event"/>
<field name="end_sale_date" eval="(DateTime.today() + timedelta(90)).strftime('%Y-%m-%d')"/>
<field name="price">1000.0</field>
<field name="seats_max">50</field>
</record>
<record id="event_2_ticket_2" model="event.event.ticket">
<field name="name">VIP</field>
<field name="event_id" ref="event.event_2"/>
<field name="product_id" ref="product_product_event"/>
<field name="end_sale_date" eval="(DateTime.today() + timedelta(60)).strftime('%Y-%m-%d')"/>
<field name="price">1500.0</field>
<field name="seats_max">5</field>
</record>
</odoo>
+1 -7
View File
@@ -13,17 +13,11 @@
<field name="uom_id" ref="uom.product_uom_unit"/>
<field name="uom_po_id" ref="uom.product_uom_unit"/>
<field name="name">Event Registration</field>
<field name="description_sale" eval="False"/>
<field name="default_code">EVENT_REG</field>
<field name="categ_id" ref="event_sale.product_category_events"/>
<field name="type">service</field>
</record>
<record id="event_type_data_sale" model="event.type">
<field name="name">Ticketing</field>
<field name="auto_confirm" eval="False"/>
<field name="use_mail_schedule" eval="True"/>
<field name="use_ticketing" eval="True"/>
</record>
</data>
</odoo>
+1 -56
View File
@@ -1,42 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
class EventType(models.Model):
_inherit = 'event.type'
@api.model
def _get_default_event_ticket_ids(self):
product = self.env.ref('event_sale.product_product_event', raise_if_not_found=False)
if not product:
return False
return [(0, 0, {
'name': _('Registration'),
'product_id': product.id,
'price': 0,
})]
use_ticketing = fields.Boolean('Ticketing')
event_ticket_ids = fields.One2many(
'event.event.ticket', 'event_type_id',
string='Tickets', default=_get_default_event_ticket_ids)
@api.onchange('name')
def _onchange_name(self):
if self.name:
self.event_ticket_ids.filtered(lambda ticket: ticket.name == _('Registration')).update({
'name': _('Registration for %s') % self.name
})
from odoo import api, fields, models
class Event(models.Model):
_inherit = 'event.event'
event_ticket_ids = fields.One2many(
'event.event.ticket', 'event_id', string='Event Ticket',
copy=True)
sale_order_lines_ids = fields.One2many(
'sale.order.line', 'event_id',
string='All sale order lines pointing to this event')
@@ -44,25 +14,6 @@ class Event(models.Model):
currency_id = fields.Many2one(
'res.currency', string='Currency',
related='company_id.currency_id', readonly=True)
start_sale_date = fields.Date('Start sale date', compute='_compute_start_sale_date')
@api.onchange('event_type_id')
def _onchange_type(self):
super(Event, self)._onchange_type()
if self.event_type_id.use_ticketing:
self.event_ticket_ids = [(5, 0, 0)] + [
(0, 0, {
'name': self.name and _('Registration for %s') % self.name or ticket.name,
'product_id': ticket.product_id.id,
'price': ticket.price,
})
for ticket in self.event_type_id.event_ticket_ids]
@api.depends('event_ticket_ids.start_sale_date')
def _compute_start_sale_date(self):
for event in self:
start_dates = [ticket.start_sale_date for ticket in event.event_ticket_ids if ticket.start_sale_date]
event.start_sale_date = min(start_dates) if start_dates else False
@api.depends('company_id.currency_id',
'sale_order_lines_ids.price_subtotal', 'sale_order_lines_ids.currency_id',
@@ -78,12 +29,6 @@ class Event(models.Model):
for sale_order_line_id in event.sale_order_lines_ids
])
@api.depends('event_ticket_ids.sale_available')
def _compute_event_registrations_open(self):
non_open_events = self.filtered(lambda event: not any(event.event_ticket_ids.mapped('sale_available')))
non_open_events.event_registrations_open = False
super(Event, self - non_open_events)._compute_event_registrations_open()
def action_view_linked_orders(self):
""" Redirects to the orders linked to the current events """
sale_order_action = self.env.ref('sale.action_orders').read()[0]
+26 -31
View File
@@ -2,14 +2,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.tools import float_is_zero
class EventRegistration(models.Model):
_inherit = 'event.registration'
event_ticket_id = fields.Many2one('event.event.ticket', string='Event Ticket', readonly=True, states={'draft': [('readonly', False)]})
# in addition to origin generic fields, add real relational fields to correctly
# handle attendees linked to sales orders and their lines
# TDE FIXME: maybe add an onchange on sale_order_id + origin
@@ -19,18 +17,6 @@ class EventRegistration(models.Model):
source_id = fields.Many2one('utm.source', 'Source', related="sale_order_id.source_id", store=True)
medium_id = fields.Many2one('utm.medium', 'Medium', related="sale_order_id.medium_id", store=True)
@api.onchange('event_id')
def _onchange_event_id(self):
# We reset the ticket when keeping it would lead to an inconstitent state.
if self.event_ticket_id and (not self.event_id or self.event_id != self.event_ticket_id.event_id):
self.event_ticket_id = None
@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'))
def _check_auto_confirmation(self):
res = super(EventRegistration, self)._check_auto_confirmation()
if res:
@@ -41,29 +27,38 @@ class EventRegistration(models.Model):
@api.model
def create(self, vals):
if vals.get('sale_order_line_id'):
so_line_vals = self._synchronize_so_line_values(
self.env['sale.order.line'].browse(vals['sale_order_line_id'])
)
vals.update(so_line_vals)
res = super(EventRegistration, self).create(vals)
if res.origin or res.sale_order_id:
res.message_post_with_view('mail.message_origin_link',
res.message_post_with_view(
'mail.message_origin_link',
values={'self': res, 'origin': res.sale_order_id},
subtype_id=self.env.ref('mail.mt_note').id)
return res
@api.model
def _prepare_attendee_values(self, registration):
""" Override to add sale related stuff """
line_id = registration.get('sale_order_line_id')
if line_id:
registration.setdefault('partner_id', line_id.order_id.partner_id)
att_data = super(EventRegistration, self)._prepare_attendee_values(registration)
if line_id and line_id.event_ticket_id.sale_available:
att_data.update({
'event_id': line_id.event_id.id,
'event_ticket_id': line_id.event_ticket_id.id,
'origin': line_id.order_id.name,
'sale_order_id': line_id.order_id.id,
'sale_order_line_id': line_id.id,
})
return att_data
def write(self, vals):
if vals.get('sale_order_line_id'):
so_line_vals = self._synchronize_so_line_values(
self.env['sale.order.line'].browse(vals['sale_order_line_id'])
)
vals.update(so_line_vals)
return super(EventRegistration, self).write(vals)
def _synchronize_so_line_values(self, so_line):
if so_line:
return {
'partner_id': so_line.order_id.partner_id.id,
'event_id': so_line.event_id.id,
'event_ticket_id': so_line.event_ticket_id.id,
'origin': so_line.order_id.name,
'sale_order_id': so_line.order_id.id,
'sale_order_line_id': so_line.id,
}
return {}
def summary(self):
res = super(EventRegistration, self).summary()
+78 -118
View File
@@ -1,145 +1,105 @@
# -*- 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
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class EventTicket(models.Model):
_name = 'event.event.ticket'
_description = 'Event Ticket'
class EventTemplateTicket(models.Model):
_inherit = 'event.type.ticket'
def _default_product_id(self):
return self.env.ref('event_sale.product_product_event', raise_if_not_found=False)
name = fields.Char(string='Name', required=True, translate=True)
event_type_id = fields.Many2one('event.type', string='Event Category', ondelete='cascade')
event_id = fields.Many2one('event.event', string="Event", ondelete='cascade')
company_id = fields.Many2one('res.company', related='event_id.company_id')
# product
product_id = fields.Many2one('product.product', string='Product',
required=True, domain=[("event_ok", "=", True)],
product_id = fields.Many2one(
'product.product', string='Product', required=True, domain=[("event_ok", "=", True)],
default=_default_product_id)
price = fields.Float(string='Price', digits='Product Price')
price_reduce = fields.Float(string="Price Reduce", compute="_compute_price_reduce", digits='Product Price')
price_reduce_taxinc = fields.Float(compute='_get_price_reduce_tax', string='Price Reduce Tax inc')
# sale
start_sale_date = fields.Date(string="Sales Start")
end_sale_date = fields.Date(string="Sales End")
is_expired = fields.Boolean(string='Is Expired', compute='_compute_is_expired')
sale_available = fields.Boolean(string='Is Available', compute='_compute_sale_available')
registration_ids = fields.One2many('event.registration', 'event_ticket_id', string='Registrations')
# seats fields
seats_availability = fields.Selection([('limited', 'Limited'), ('unlimited', 'Unlimited')],
string='Available Seat', required=True, store=True, compute='_compute_seats', default="limited")
seats_max = fields.Integer(string='Maximum Available Seats',
help="Define the number of available tickets. If you have too much registrations you will "
"not be able to sell tickets anymore. Set 0 to ignore this rule set as unlimited.")
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 Seat Reservations', compute='_compute_seats', store=True)
seats_used = fields.Integer(compute='_compute_seats', store=True)
def _compute_is_expired(self):
for ticket in self:
if ticket.end_sale_date:
current_date = fields.Date.context_today(ticket.with_context(tz=ticket.event_id.date_tz))
ticket.is_expired = ticket.end_sale_date < current_date
else:
ticket.is_expired = False
@api.depends('product_id.active', 'start_sale_date', 'end_sale_date')
def _compute_sale_available(self):
for ticket in self:
current_date = fields.Date.context_today(ticket.with_context(tz=ticket.event_id.date_tz))
if not ticket.product_id.active:
ticket.sale_available = False
elif ticket.start_sale_date and ticket.start_sale_date > current_date:
ticket.sale_available = False
elif ticket.end_sale_date and ticket.end_sale_date < current_date:
ticket.sale_available = False
else:
ticket.sale_available = True
@api.depends('product_id')
def _compute_price_reduce(self):
for record in self:
product = record.product_id
discount = product.lst_price and (product.lst_price - product.price) / product.lst_price or 0.0
record.price_reduce = (1.0 - discount) * record.price
def _get_price_reduce_tax(self):
@api.onchange('product_id')
def _onchange_product_id(self):
self.price = self.product_id.list_price or 0
if not self.description and self.product_id.description_sale:
self.description = self.product_id.description_sale
def _init_column(self, column_name):
if column_name != "product_id":
return super(EventTemplateTicket, self)._init_column(column_name)
# fetch void columns
self.env.cr.execute("SELECT id FROM %s WHERE product_id IS NULL" % self._table)
ticket_type_ids = self.env.cr.fetchall()
if not ticket_type_ids:
return
# update existing columns
_logger.debug("Table '%s': setting default value of new column %s to unique values for each row",
self._table, column_name)
default_event_product = self.env.ref('event_sale.product_product_event', raise_if_not_found=False)
if default_event_product:
product_id = default_event_product.id
else:
product_id = self.env['product.product'].create({
'name': 'Generic Registration Product',
'list_price': 0,
'standard_price': 0,
'type': 'service',
'default_code': 'EVENT_REG',
'type': 'service',
}).id
self.env['ir.model.data'].create({
'name': 'product_product_event',
'module': 'event_sale',
'model': 'product.product',
'res_id': product_id,
})
self.env.cr._obj.execute(
f'UPDATE {self._table} SET product_id = %s WHERE id IN %s;',
(product_id, tuple(ticket_type_ids))
)
@api.model
def _get_event_ticket_fields_whitelist(self):
""" Add sale specific fields to copy from template to ticket """
return super(EventTemplateTicket, self)._get_event_ticket_fields_whitelist() + ['product_id', 'price']
class EventTicket(models.Model):
_inherit = 'event.event.ticket'
# product
price_reduce_taxinc = fields.Float(string='Price Reduce Tax inc', compute='_compute_price_reduce_taxinc')
def _compute_price_reduce_taxinc(self):
for record in self:
# sudo necessary here since the field is most probably accessed through the website
tax_ids = record.sudo().product_id.taxes_id.filtered(lambda r: r.company_id == record.event_id.company_id)
taxes = tax_ids.compute_all(record.price_reduce, record.event_id.company_id.currency_id, 1.0, product=record.product_id)
record.price_reduce_taxinc = taxes['total_included']
@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_availability = 'unlimited' if ticket.seats_max == 0 else 'limited'
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.depends('product_id.active')
def _compute_sale_available(self):
inactive_product_tickets = self.filtered(lambda ticket: not ticket.product_id.active)
for ticket in inactive_product_tickets:
ticket.sale_available = False
super(EventTicket, self - inactive_product_tickets)._compute_sale_available()
@api.constrains('registration_ids', 'seats_max')
def _check_seats_limit(self):
for record in self:
if record.seats_max and record.seats_available < 0:
raise ValidationError(_('No more available seats for this ticket type.'))
@api.constrains('event_type_id', 'event_id')
def _constrains_event(self):
if any(ticket.event_type_id and ticket.event_id for ticket in self):
raise UserError(_('Ticket cannot belong to both the event category and the event itself.'))
@api.onchange('product_id')
def _onchange_product_id(self):
self.price = self.product_id.list_price or 0
def _get_ticket_multiline_description_sale(self):
""" Compute a multiline description of this ticket, in the context of sales.
It will often be used as the default description of a sales order line referencing this ticket.
1. the first line is the ticket name
2. the second line is the event name (if it exists, which should be the case with a normal workflow) or the product name (if it exists)
We decided to ignore entirely the product name and the product description_sale because they are considered to be replaced by the ticket name and event name.
-> the workflow of creating a new event also does not lead to filling them correctly, as the product is created through the event interface
"""
name = self.display_name
if self.event_id:
name += '\n' + self.event_id.display_name
elif self.product_id:
name += '\n' + self.product_id.display_name
return name
@api.constrains('start_sale_date', 'end_sale_date')
def _check_start_sale_date_and_end_sale_date(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.'))
def _get_ticket_multiline_description(self):
""" If people set a description on their product it has more priority
than the ticket name itself for the SO description. """
self.ensure_one()
if self.product_id.description_sale:
return '%s\n%s' % (self.product_id.description_sale, self.event_id.display_name)
return super(EventTicket, self)._get_ticket_multiline_description()
+5 -6
View File
@@ -60,13 +60,12 @@ class SaleOrderLine(models.Model):
existing_registrations.filtered(lambda self: self.state == 'cancel').action_set_draft()
for count in range(int(so_line.product_uom_qty) - len(existing_registrations)):
registration = {}
registration_vals = {}
if registration_data:
registration = registration_data.pop()
registration_vals = registration_data.pop()
# TDE CHECK: auto confirmation
registration['sale_order_line_id'] = so_line
Registration.with_context(registration_force_draft=True).create(
Registration._prepare_attendee_values(registration))
registration_vals['sale_order_line_id'] = so_line.id
Registration.with_context(registration_force_draft=True).create(registration_vals)
return True
@api.onchange('product_id')
@@ -103,7 +102,7 @@ class SaleOrderLine(models.Model):
lang=self.order_id.partner_id.lang,
)
return ticket._get_ticket_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants()
return ticket._get_ticket_multiline_description() + self._get_sale_order_line_multiline_description_variants()
else:
return super(SaleOrderLine, self).get_sale_order_line_multiline_description_sale(product)
@@ -1,5 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_event_event_ticket_user,event.event.ticket.user,event_sale.model_event_event_ticket,event.group_event_user,1,0,0,0
access_event_event_ticket_admin,event.event.ticket.admin,event_sale.model_event_event_ticket,event.group_event_manager,1,1,1,1
access_product_template_event_manager,product.template.event.manager,product.model_product_template,event.group_event_manager,1,1,1,1
access_product_product_event_manager,product.product.event.manager,product.model_product_product,event.group_event_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
access_event_event_ticket_user event.event.ticket.user event_sale.model_event_event_ticket event.group_event_user 1 0 0 0
access_event_event_ticket_admin event.event.ticket.admin event_sale.model_event_event_ticket event.group_event_manager 1 1 1 1
2 access_product_template_event_manager product.template.event.manager product.model_product_template event.group_event_manager 1 1 1 1
3 access_product_product_event_manager product.product.event.manager product.model_product_product event.group_event_manager 1 1 1 1
+1
View File
@@ -12,6 +12,7 @@ class TestEventSaleCommon(TestEventCommon):
cls.event_product = cls.env['product.product'].create({
'name': 'Test Registration Product',
'description_sale': 'Mighty Description',
'list_price': 10,
'event_ok': True,
'standard_price': 30.0,
+101 -36
View File
@@ -2,13 +2,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date, datetime, timedelta
from unittest.mock import patch
from odoo.addons.event_sale.tests.common import TestEventSaleCommon
from odoo.fields import Datetime as FieldsDatetime
from odoo.fields import Datetime as FieldsDatetime, Date as FieldsDate
from odoo.tests.common import users
class EventSaleTest(TestEventSaleCommon):
class TestEventData(TestEventSaleCommon):
@users('user_eventmanager')
def test_event_configuration_from_type(self):
@@ -17,11 +18,9 @@ class EventSaleTest(TestEventSaleCommon):
event_type = self.event_type_complex.with_user(self.env.user)
event_type.write({
'use_mail_schedule': False,
'use_ticketing': False,
'use_ticket': False,
})
# Event type does not use tickets but data is kept for compatibility and avoid recreating them
self.assertEqual(len(event_type.event_ticket_ids), 1)
self.assertEqual(event_type.event_ticket_ids.name, 'Registration')
self.assertEqual(event_type.event_type_ticket_ids, self.env['event.type.ticket'])
event = self.env['event.event'].create({
'name': 'Event Update Type',
@@ -33,53 +32,119 @@ class EventSaleTest(TestEventSaleCommon):
self.assertEqual(event.event_ticket_ids, self.env['event.event.ticket'])
event_type.write({
'use_ticketing': True,
'event_ticket_ids': [(5, 0), (0, 0, {
'use_ticket': True,
'event_type_ticket_ids': [(5, 0), (0, 0, {
'name': 'First Ticket',
'product_id': self.event_product.id,
'seats_max': 5,
})]
})
event_type.event_ticket_ids._onchange_product_id()
event_type.event_type_ticket_ids._onchange_product_id()
self.assertEqual(event_type.event_type_ticket_ids.description, self.event_product.description_sale)
# synchronize event
event._onchange_type()
self.assertEqual(event.event_ticket_ids.name, 'Registration for %s' % event.name)
self.assertEqual(event.event_ticket_ids.name, event.event_type_id.event_type_ticket_ids.name)
self.assertEqual(event.event_ticket_ids.seats_availability, 'limited')
self.assertEqual(event.event_ticket_ids.seats_max, 5)
self.assertEqual(event.event_ticket_ids.product_id, self.event_product)
self.assertEqual(event.event_ticket_ids.price, self.event_product.list_price)
self.assertEqual(event.event_ticket_ids.description, self.event_product.description_sale)
def test_event_registrable(self):
"""Test if `_compute_event_registrations_open` works properly."""
test_event = self.env['event.event'].create({
'name': 'TestEvent',
'date_begin': datetime.now() - timedelta(days=5),
'date_end': datetime.now() + timedelta(days=5),
})
test_event_ticket = self.env['event.event.ticket'].create({
"""Test if `_compute_event_registrations_open` works properly with additional
product active conditions compared to base tests (see event) """
event = self.event_0.with_user(self.env.user)
self.assertTrue(event.event_registrations_open)
# ticket without dates boundaries -> ok
ticket = self.env['event.event.ticket'].create({
'name': 'TestTicket',
'event_id': test_event.id,
'product_id': self.env['product.product'].search([], limit=1).id,
'event_id': event.id,
'product_id': self.event_product.id,
})
self.assertTrue(event.event_registrations_open)
self.assertEqual(test_event.event_registrations_open, True)
# ticket has inactive product -> ko
ticket.product_id.action_archive()
self.assertFalse(event.event_registrations_open)
test_event.write({
'date_begin': datetime.now() - timedelta(days=5),
'date_end': datetime.now() - timedelta(days=1),
# at least one valid ticket -> ok is back
event_product = self.env['product.product'].create({'name': 'Test Registration Product New',})
new_ticket = self.env['event.event.ticket'].create({
'name': 'TestTicket 2',
'event_id': event.id,
'product_id': event_product.id,
'end_sale_date': datetime.now() + timedelta(days=2),
})
self.assertTrue(new_ticket.sale_available)
self.assertTrue(event.event_registrations_open)
test_event.date_end = datetime.now() - timedelta(days=1)
self.assertEqual(test_event.event_registrations_open, False)
test_event.write({
'date_begin': datetime.now() - timedelta(days=1),
'date_end': datetime.now() + timedelta(days=5),
class TestEventTicketData(TestEventSaleCommon):
def setUp(self):
super(TestEventTicketData, self).setUp()
self.ticket_date_patcher = patch('odoo.addons.event.models.event_ticket.fields.Date', wraps=FieldsDate)
self.ticket_date_patcher_mock = self.ticket_date_patcher.start()
self.ticket_date_patcher_mock.context_today.return_value = date(2020, 1, 31)
def tearDown(self):
super(TestEventTicketData, self).tearDown()
self.ticket_date_patcher.stop()
@users('user_eventmanager')
def test_event_ticket_fields(self):
""" Test event ticket fields synchronization """
event = self.event_0.with_user(self.env.user)
event.write({
'event_ticket_ids': [
(5, 0),
(0, 0, {
'name': 'First Ticket',
'product_id': self.event_product.id,
'seats_max': 30,
}), (0, 0, { # limited in time, available (01/10 (start) < 01/31 (today) < 02/10 (end))
'name': 'Second Ticket',
'product_id': self.event_product.id,
'start_sale_date': date(2020, 1, 10),
'end_sale_date': date(2020, 2, 10),
})
],
})
first_ticket = event.event_ticket_ids.filtered(lambda t: t.name == 'First Ticket')
first_ticket._onchange_product_id()
second_ticket = event.event_ticket_ids.filtered(lambda t: t.name == 'Second Ticket')
second_ticket._onchange_product_id()
# force second ticket price, after calling the onchange
second_ticket.write({'price': 8.0})
test_event.write({'event_ticket_ids': [(5, 0)]})
self.assertEqual(test_event.event_registrations_open, False, 'cannot register if no tickets')
# price coming from product
self.assertEqual(first_ticket.price, self.event_product.list_price)
self.assertEqual(second_ticket.price, 8.0)
test_event_ticket = self.env['event.event.ticket'].create({
'name': 'TestTicket',
'event_id': test_event.id,
'product_id': self.env['product.product'].search([], limit=1).id,
# default availability
self.assertEqual(first_ticket.seats_availability, 'limited')
self.assertTrue(first_ticket.sale_available)
self.assertFalse(first_ticket.is_expired)
self.assertEqual(second_ticket.seats_availability, 'unlimited')
self.assertTrue(second_ticket.sale_available)
self.assertFalse(second_ticket.is_expired)
# product archived
self.event_product.action_archive()
self.assertFalse(first_ticket.sale_available)
self.assertFalse(second_ticket.sale_available)
# sale is ended
self.event_product.action_unarchive()
second_ticket.write({'end_sale_date': date(2020, 1, 20)})
self.assertFalse(second_ticket.sale_available)
self.assertTrue(second_ticket.is_expired)
# sale has not started
second_ticket.write({
'start_sale_date': date(2020, 2, 10),
'end_sale_date': date(2020, 2, 20),
})
test_event_ticket.product_id.active = False
self.assertEqual(test_event.event_registrations_open, False, 'cannot register if product linked to the tickets are all archived')
self.assertFalse(second_ticket.sale_available)
self.assertFalse(second_ticket.is_expired)
@@ -1,24 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="view_event_registration_ticket_search" model="ir.ui.view">
<field name="name">event.registration.search.inherit</field>
<field name="model">event.registration</field>
<field name="inherit_id" ref="event.view_registration_search" />
<field name="arch" type="xml">
<filter name="group_event" position="after">
<filter string="Ticket Type" name ="tickettype" domain="['|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]" context="{'group_by':'event_ticket_id'}"/>
</filter>
</field>
</record>
<record id="view_event_registration_ticket_tree" model="ir.ui.view">
<field name="name">event.registration.tree.inherit</field>
<field name="model">event.registration</field>
<field name="inherit_id" ref="event.view_event_registration_tree" />
<field name="arch" type="xml">
<field name="event_id" position="after">
<field name="event_ticket_id"/>
<field name="origin"/>
</field>
</field>
@@ -41,17 +29,6 @@
<field name="model">event.registration</field>
<field name="inherit_id" ref="event.view_event_registration_form" />
<field name="arch" type="xml">
<field name="event_id" position="after">
<field
name="event_ticket_id"
domain="[
('event_id', '=', event_id),
'|', ('seats_availability', '=', 'unlimited'), ('seats_available', '>', 0)
]"
attrs="{'invisible': [('event_id', '=', False)]}"
options="{'no_open': True, 'no_create': True}"
/>
</field>
<group name="event" position="after">
<group string="Origin">
<field name="sale_order_id"/>
+67 -78
View File
@@ -1,106 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<odoo><data>
<!-- EVENT.TYPE.TICKET -->
<record id="event_type_ticket_view_tree_from_type" model="ir.ui.view">
<field name="name">event.type.ticket.view.tree.inherit.sale</field>
<field name="model">event.type.ticket</field>
<field name="inherit_id" ref="event.event_type_ticket_view_tree_from_type"/>
<field name="arch" type="xml">
<field name="name" position="after">
<field name="product_id"/>
</field>
<field name="description" position="after">
<field name="price"/>
</field>
</field>
</record>
<record id="event_type_ticket_view_form_from_type" model="ir.ui.view">
<field name="name">event.type.ticket.view.form.inherit.sale</field>
<field name="model">event.type.ticket</field>
<field name="inherit_id" ref="event.event_type_ticket_view_form_from_type"/>
<field name="arch" type="xml">
<field name="name" position="after">
<field name="product_id"/>
</field>
<field name="description" position="after">
<field name="price"/>
</field>
</field>
</record>
<!-- EVENT.TICKET -->
<record id="event_event_ticket_view_tree_from_event" model="ir.ui.view">
<field name="name">event.event.ticket.view.tree.from_event</field>
<field name="name">event.event.ticket.view.tree.from.event.inherit.sale</field>
<field name="model">event.event.ticket</field>
<field name="priority" eval="20"/>
<field name="inherit_id" ref="event.event_event_ticket_view_tree_from_event"/>
<field name="arch" type="xml">
<tree string="Tickets" editable="bottom">
<field name="name"/>
<field name="product_id" context="{'default_event_ok':1}"/>
<field name="start_sale_date"/>
<field name="end_sale_date"/>
<field name="name" position="after">
<field name="product_id" context="{'default_event_ok': 1}"/>
</field>
<field name="description" position="after">
<field name="price"/>
<field name="seats_max"/>
<field name="seats_reserved" readonly="1"/>
<field name="seats_unconfirmed" readonly="1"/>
</tree>
</field>
</field>
</record>
<record id="event_event_ticket_view_form_from_event" model="ir.ui.view">
<field name="name">event.event.ticket.view.form.from.event</field>
<field name="name">event.event.ticket.view.form.from.event.inherit.sale</field>
<field name="model">event.event.ticket</field>
<field name="priority" eval="20"/>
<field name="inherit_id" ref="event.event_event_ticket_view_form_from_event"/>
<field name="arch" type="xml">
<form string="Ticket">
<group>
<group>
<field name="name"/>
<field name="product_id" context="{'default_event_ok':1}"/>
<field name="end_sale_date"/>
<field name="price"/>
</group><group>
<field name="seats_max"/>
<field name="seats_reserved" readonly="1"/>
<field name="seats_unconfirmed" readonly="1"/>
</group>
</group>
</form>
<field name="name" position="after">
<field name="product_id" context="{'default_event_ok':1}"/>
</field>
<field name="description" position="after">
<field name="price"/>
</field>
</field>
</record>
<record id="event_event_ticket_view_kanban_from_event" model="ir.ui.view">
<field name="name">event.event.ticket.view.kanban.from.event</field>
<field name="model">event.event.ticket</field>
<field name="priority" eval="20"/>
<field name="inherit_id" ref="event.event_event_ticket_view_kanban_from_event"/>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile">
<field name="name" position="after">
<field name="product_id"/>
<field name="name"/>
<field name="price"/>
<field name="seats_max"/>
<field name="seats_reserved"/>
<field name="seats_unconfirmed"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card oe_kanban_global_click">
<div class="row">
<div class="col-8">
<strong><t t-esc="record.name.value"/></strong>
</div>
<div class="col-4 text-right"><strong> <t t-esc="record.price.value"/></strong></div>
</div>
<div t-esc="record.product_id.value"/>
<div><i>
<t t-esc="record.seats_reserved.value"/> reserved + <t t-esc="record.seats_reserved.value"/> unconfirmed
</i></div>
</div>
</t>
</templates>
</kanban>
</field>
<xpath expr="//div[hasclass('col-8')]" position="after">
<div class="col-4 text-right"><strong> <t t-esc="record.price.value"/></strong></div>
</xpath>
<xpath expr="//div[hasclass('row')]" position="after">
<div t-esc="record.product_id.value"/>
</xpath>
</field>
</record>
<record id="event_event_ticket_view_form" model="ir.ui.view">
<field name="name">event.event.ticket.view.form</field>
<record id="event_event_ticket_form_view" model="ir.ui.view">
<field name="name">event.event.ticket.view.form.inherit.sale</field>
<field name="model">event.event.ticket</field>
<field name="inherit_id" ref="event.event_event_ticket_form_view"/>
<field name="arch" type="xml">
<form string="Event's Ticket">
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="Event Name"/></h1>
</div>
<group>
<group>
<field name="event_id"/>
<field name="seats_availability"/>
<field name="seats_available"/>
<field name="end_sale_date"/>
<field name="price"/>
<field name="price_reduce" groups="base.group_no_one"/>
</group>
<group>
<field name="seats_max"/>
<field name="seats_reserved"/>
<field name="seats_unconfirmed"/>
<field name="seats_used"/>
<field name="product_id"/>
<field name="is_expired"/>
</group>
</group>
</form>
<field name="end_sale_date" position="after">
<field name="price"/>
<field name="price_reduce" groups="base.group_no_one"/>
</field>
<field name="seats_used" position="after">
<field name="product_id"/>
</field>
</field>
</record>
</odoo>
</data></odoo>
+1 -37
View File
@@ -1,33 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="event_type_view_form_inherit_sale" model="ir.ui.view">
<field name="name">event.type.view.form.inherit.sale</field>
<field name="model">event.type</field>
<field name="inherit_id" ref="event.view_event_type_form"/>
<field name="arch" type="xml">
<div name="event_type_attendees_auto_confirm" position="after">
<div class="col-12 col-lg-12 o_setting_box">
<div class="o_setting_left_pane">
<field name="use_ticketing"/>
</div>
<div class="o_setting_right_pane">
<label for="use_ticketing"/>
<div class="row mt16" attrs="{'invisible': [('use_ticketing', '=', False)]}">
<div class="col-lg-9">
<field name="event_ticket_ids">
<tree string="Tickets" editable="bottom">
<field name="name"/>
<field name="product_id"/>
<field name="price"/>
</tree>
</field>
</div>
</div>
</div>
</div>
</div>
</field>
</record>
<record id="view_event_form_inherit_ticket" model="ir.ui.view">
<field name="name">event.form.inherit</field>
@@ -49,15 +21,7 @@
</div>
</button>
</xpath>
<page name="event_communication" position="before">
<page string="Tickets" name="tickets">
<field name="event_ticket_ids" context="{
'default_name': name,
'tree_view_ref': 'event_sale.event_event_ticket_view_tree_from_event',
'form_view_ref': 'event_sale.event_event_ticket_view_form_from_event',
'kanban_view_ref': 'event_sale.event_event_ticket_view_kanban_from_event'}" mode="tree,kanban"/>
</page>
</page>
</field>
</record>
</odoo>
+60 -17
View File
@@ -254,25 +254,62 @@ class WebsiteEventController(http.Controller):
"url": event.website_url})
return request.env['ir.ui.view'].render_template("website_event.country_events_list", result)
def _process_tickets_details(self, data):
nb_register = int(data.get('nb_register-0', 0))
if nb_register:
return [{'id': 0, 'name': 'Registration', 'quantity': nb_register, 'price': 0}]
return []
def _process_tickets_form(self, event, form_details):
""" Process posted data about ticket order. Generic ticket are supported
for event without tickets (generic registration).
:return: list of order per ticket: [{
'id': if of ticket if any (0 if no ticket),
'ticket': browse record of ticket if any (None if no ticket),
'name': ticket name (or generic 'Registration' name if no ticket),
'quantity': number of registrations for that ticket,
}, {...}]
"""
ticket_order = {}
for key, value in form_details.items():
registration_items = key.split('nb_register-')
if len(registration_items) != 2:
continue
ticket_order[int(registration_items[1])] = int(value)
ticket_dict = dict((ticket.id, ticket) for ticket in request.env['event.event.ticket'].search([
('id', 'in', [tid for tid in ticket_order.keys() if tid]),
('event_id', '=', event.id)
]))
return [{
'id': tid if ticket_dict.get(tid) else 0,
'ticket': ticket_dict.get(tid),
'name': ticket_dict[tid]['name'] if ticket_dict.get(tid) else _('Registration'),
'quantity': count,
} for tid, count in ticket_order.items() if count]
@http.route(['/event/<model("event.event"):event>/registration/new'], type='json', auth="public", methods=['POST'], website=True)
def registration_new(self, event, **post):
tickets = self._process_tickets_details(post)
tickets = self._process_tickets_form(event, post)
if not tickets:
return False
return request.env['ir.ui.view'].render_template("website_event.registration_attendee_details", {'tickets': tickets, 'event': event})
def _process_registration_details(self, details):
''' Process data posted from the attendee details form. '''
def _process_attendees_form(self, event, form_details):
""" Process data posted from the attendee details form.
:param details: posted data from frontend registration form, like
{'1-name': 'r', '1-email': 'r@r.com', '1-phone': '', '1-event_ticket_id': '1'}
"""
registration_fields = request.env['event.registration']._fields
registrations = {}
global_values = {}
for key, value in details.items():
for key, value in form_details.items():
counter, field_name = key.split('-', 1)
if field_name not in registration_fields:
continue
elif isinstance(registration_fields[field_name], (fields.Many2one, fields.Integer)):
value = int(value)
else:
value = value
if counter == '0':
global_values[field_name] = value
else:
@@ -282,22 +319,28 @@ class WebsiteEventController(http.Controller):
registration[key] = value
return list(registrations.values())
def _create_attendees_from_registration_post(self, event, registration_data):
attendees_sudo = request.env['event.registration'].sudo()
for registration_values in registration_data:
registration_values['event_id'] = event.id
if not registration_values.get('partner_id'):
registration_values['partner_id'] = request.env.user.partner_id.id
attendees_sudo += request.env['event.registration'].sudo().create(registration_values)
return attendees_sudo
@http.route(['''/event/<model("event.event"):event>/registration/confirm'''], type='http', auth="public", methods=['POST'], website=True)
def registration_confirm(self, event, **post):
if not event.can_access_from_current_website():
raise werkzeug.exceptions.NotFound()
Attendees = request.env['event.registration']
registrations = self._process_registration_details(post)
for registration in registrations:
registration['event_id'] = event
Attendees += Attendees.sudo().create(
Attendees._prepare_attendee_values(registration))
registrations = self._process_attendees_form(event, post)
attendees = self._create_attendees_from_registration_post(event, registrations)
urls = event._get_event_resource_urls()
return request.render("website_event.registration_complete", {
'attendees': Attendees.sudo(),
'attendees': attendees,
'event': event,
'google_url': urls.get('google_url'),
'iCal_url': urls.get('iCal_url')
@@ -11,6 +11,17 @@
<field name="perm_unlink" eval="False"/>
</record>
<record id="ir_rule_event_event_ticket_public" model="ir.rule">
<field name="name">Event ticket: public/portal may read published</field>
<field name="model_id" ref="event.model_event_event_ticket"/>
<field name="domain_force">[('event_id.website_published', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_public')), (4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="event.group_event_manager" model="res.groups">
<field name="implied_ids" eval="[(4, ref('website.group_website_publisher'))]"/>
</record>
@@ -1,4 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_event_event_public,event.event.public,event.model_event_event,base.group_public,1,0,0,0
access_event_event_portal,event.event.portal,event.model_event_event,base.group_portal,1,0,0,0
access_event_type_public,event.type.public,event.model_event_type,,1,0,0,0
access_event_type_public,event.type.public,event.model_event_type,,1,0,0,0
access_event_event_ticket_public,event.event.ticket.public,event.model_event_event_ticket,base.group_public,1,0,0,0
access_event_event_ticket_portal,event.event.ticket.portal,event.model_event_event_ticket,base.group_portal,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_event_event_public event.event.public event.model_event_event base.group_public 1 0 0 0
3 access_event_event_portal event.event.portal event.model_event_event base.group_portal 1 0 0 0
4 access_event_type_public event.type.public event.model_event_type 1 0 0 0
5 access_event_event_ticket_public event.event.ticket.public event.model_event_event_ticket base.group_public 1 0 0 0
6 access_event_event_ticket_portal event.event.ticket.portal event.model_event_event_ticket base.group_portal 1 0 0 0
@@ -269,3 +269,9 @@ $o-wevent-event-title-sizes-variants: (
}
}
}
.modal-open {
> .modal-backdrop {
z-index: $zindex-dropdown - 1;
}
}
+115 -45
View File
@@ -209,7 +209,18 @@
</main>
<!-- Footer -->
<footer t-attf-class="small #{opt_events_list_cards and 'card-footer' or ((not opt_events_list_columns) and 'border-top mx-4 mt-auto pt-2' or 'border-top py-2')}">
<div itemprop="price"><span content="0" class="font-weight-bold text-uppercase">Free</span></div>
<t t-if="event.event_registrations_open">
<span class="text-danger"
t-if="event.seats_availability == 'limited' and not event.seats_available">
Sold Out
</span>
<span class="text-muted"
t-if="event.seats_availability == 'limited' and
event.seats_available and
(event.seats_available &lt;5 or event.seats_available &lt;= ((event.seats_max or 0) / 4))">
<em>Only <t t-esc="event.seats_available"/> Remaining</em>
</span>
</t>
</footer>
</div>
</div>
@@ -505,22 +516,109 @@
<!-- Event - Registration -->
<template id="registration_template">
<form t-if="event.event_registrations_open" id="registration_form" t-attf-action="/event/#{slug(event)}/registration/new" method="post" itemscope="itemscope" itemprop="offers" itemtype="http://schema.org/AggregateOffer" class="mb-5">
<div t-if="event.start_sale_date and event.start_sale_date &gt; datetime.date.today() and not event.event_registrations_open"
class="alert alert-info mb-5" role="status">
<em >Ticket Sales starting on <t t-esc="event.start_sale_date"/></em>
</div>
<form t-if="event.event_registrations_open and (not event.event_ticket_ids or not all([ticket.is_expired for ticket in event.event_ticket_ids]))"
id="registration_form"
t-attf-action="/event/#{slug(event)}/registration/new" method="post"
class="mb-5" itemscope="itemscope" itemprop="offers" itemtype="http://schema.org/AggregateOffer">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div id="o_wevent_tickets" class="bg-white rounded shadow-sm">
<t t-set="quantity">
<select name="nb_register-0" class="custom-select w-auto">
<t t-foreach="range(0, (event.seats_availability == 'unlimited' or event.seats_available &gt; 9) and 10 or event.seats_available+1)" t-as="nb">
<option t-esc="nb" t-att-selected="nb == 1 and 'selected'"/>
</t>
</select>
</t>
<t t-id="tickets" t-call="website_event.ticket">
<t t-set="name">Registration</t>
<t t-set="price">
<span content="0" itemprop="price" class="font-weight-bold text-uppercase">Free</span>
</t>
<t t-set="tickets" t-value="event.event_ticket_ids.filtered(lambda ticket: not ticket.is_expired)"/>
<!-- If some tickets expired and there is only one type left, we keep the same layout -->
<t t-if="len(event.event_ticket_ids) &gt; 1">
<div class="d-flex align-items-center py-2 pl-3 pr-2 border-bottom">
<span t-if="not event.event_registrations_open" class="text-danger">
<i class="fa fa-ban mr-2"/>Sold Out
</span>
<div class="ml-auto pr-3">
<span t-if="event.is_participating" class="text-success ml-3">
<i class="fa fa-check mr-2"/>Registered
</span>
</div>
<a href="#" role="button" class="btn btn-primary o_wevent_registration_btn collapsed" data-toggle="collapse" data-target="#o_wevent_tickets_collapse">
Tickets
<span class="close">×</span>
</a>
</div>
<div id="o_wevent_tickets_collapse" class="collapse">
<div t-foreach="tickets" t-as="ticket" class="row px-3 mx-0 bg-light border-bottom">
<div class="col-8 py-3" itemscope="itemscope" itemtype="http://schema.org/Offer">
<h5 itemprop="name" t-field="ticket.name" class="my-0"/>
<t t-if="ticket.description">
<small t-field="ticket.description" class="text-muted py-2"/>
<br/>
</t>
<small t-if="ticket.end_sale_date and ticket.sale_available and not ticket.is_expired" class="text-muted mr-3" itemprop="availabilityEnds">Sales end on <span itemprop="priceValidUntil" t-field="ticket.end_sale_date"/></small>
<small t-if="ticket.start_sale_date and not ticket.sale_available and not ticket.is_expired" class="text-muted mr-3" itemprop="availabilityEnds">Sales start on <span itemprop="priceValidUntil" t-field="ticket.start_sale_date"/></small>
</div>
<div class="col-4 d-flex align-items-center">
<div class="o_wevent_registration_multi_select"/>
<div class="w-auto ml-auto">
<select t-if="not ticket.is_expired and ticket.sale_available"
t-attf-name="nb_register-#{ticket.id}"
class="custom-select">
<t t-set="seats_max_ticket" t-value="(ticket.seats_availability == 'unlimited' or ticket.seats_available &gt; 9) and 10 or ticket.seats_available + 1"/>
<t t-set="seats_max_event" t-value="(event.seats_availability == 'unlimited' or event.seats_available &gt; 9) and 10 or event.seats_available + 1"/>
<t t-set="seats_max" t-value="min(seats_max_ticket, seats_max_event)"/>
<t t-foreach="range(0, seats_max)" t-as="nb">
<option t-esc="nb" t-att-selected="len(ticket) == 0 and nb == 0 and 'selected'"/>
</t>
</select>
</div>
</div>
</div>
<div class="row no-gutters">
<div class="col-md-4 offset-md-8 py-2 pl-md-0 pr-md-2">
<button type="submit" class="btn btn-primary o_wait_lazy_js btn-block a-submit" t-att-disabled="not event.event_registrations_open or None" t-attf-id="#{event.id}">Register</button>
</div>
</div>
</div>
</t>
<div t-else="" class="o_wevent_registration_single">
<div class="row p-2 pl-3">
<div class="col-lg-8 d-flex flex-columns align-items-center" itemscope="itemscope" itemtype="http://schema.org/Offer">
<h6 itemprop="name" class="my-0 pr-3 border-right">
<span t-if="tickets" t-field="tickets.name"/>
<span t-else="">Registration</span>
</h6>
<small t-if="tickets.start_sale_date" class="text-muted ml-3" itemprop="availabilityEnds">
Sales end on <span itemprop="priceValidUntil" t-field="tickets.end_sale_date"/>
</small>
<div class="ml-auto">
<t t-if="event.event_registrations_open">
<span class="font-weight-bold align-middle pr-2">Qty</span>
<link itemprop="availability" content="http://schema.org/InStock"/>
<select t-att-name="'nb_register-%s' % (tickets.id if tickets else 0)" class="w-auto custom-select">
<t t-set="seats_max_ticket" t-value="(not tickets or tickets.seats_availability == 'unlimited' or tickets.seats_available &gt; 9) and 10 or tickets.seats_available + 1"/>
<t t-set="seats_max_event" t-value="(event.seats_availability == 'unlimited' or event.seats_available &gt; 9) and 10 or event.seats_available + 1"/>
<t t-set="seats_max" t-value="min(seats_max_ticket, seats_max_event) if tickets else seats_max_event"/>
<t t-foreach="range(0, seats_max)" t-as="nb">
<option t-esc="nb" t-att-selected="nb == 1 and 'selected'"/>
</t>
</select>
</t>
<t t-else="">
<span itemprop="availability" content="http://schema.org/SoldOut" class="text-danger">
<i class="fa fa-ban mr-2"/>Sold Out
</span>
</t>
</div>
</div>
<div class="col-lg-4 pt-3 pt-lg-0 pl-2 pl-lg-0">
<button type="submit" class="btn btn-primary o_wait_lazy_js btn-block a-submit" t-attf-id="#{event.id}">Register</button>
</div>
</div>
<div t-if="tickets.description" class="row mx-0">
<div class="col-12 bg-200 rounded-bottom">
<p itemprop="description" t-field="tickets.description" class="small my-2"/>
</div>
</div>
</div>
</div>
</form>
<div t-elif="request.env.user.has_group('event.group_event_manager')" class="alert alert-info mb-5 o_website_event_configuration" role="status">
@@ -530,36 +628,6 @@
</div>
</template>
<template id="ticket" name="Ticket offer template">
<div class="row p-2 pl-3">
<div class="col-lg-8 d-flex flex-columns align-items-center" itemscope="itemscope" itemtype="http://schema.org/Offer">
<h6 t-raw="name" itemprop="name" class="my-0 pr-3"/>
<div class="border-left border-right px-3"><t t-raw="price"/></div>
<small t-raw="registration_end" class="text-muted ml-3" itemprop="availabilityEnds"/>
<div class="ml-auto">
<t t-if="event.event_registrations_open">
<span class="font-weight-bold align-middle pr-2">Qty</span>
<link itemprop="availability" content="http://schema.org/InStock"/>
<t t-raw="quantity"/>
</t>
<t t-else="">
<span itemprop="availability" content="http://schema.org/SoldOut" class="text-danger">
<i class="fa fa-ban mr-2"/>Sold Out
</span>
</t>
</div>
</div>
<div class="col-lg-4 pt-3 pt-lg-0 pl-2 pl-lg-0">
<button type="submit" class="btn btn-primary o_wait_lazy_js btn-block a-submit" t-attf-id="#{event.id}">Register</button>
</div>
</div>
<div t-if="description" class="row mx-0">
<div class="col-12 bg-200 rounded-bottom">
<p itemprop="description" t-raw="description" class="small my-2"/>
</div>
</div>
</template>
<template id="registration_attendee_details" name="Registration Attendee Details">
<div id="modal_attendees_registration" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
@@ -590,7 +658,7 @@
<label>Phone <small>(Optional)</small></label>
<input class="form-control" type="tel" t-attf-name="#{counter}-phone"/>
</div>
<input class="d-none" type="text" t-attf-name="#{counter}-ticket_id" t-attf-value="#{ticket['id']}"/>
<input class="d-none" type="text" t-attf-name="#{counter}-event_ticket_id" t-attf-value="#{ticket['id']}"/>
</div>
</div>
</t>
@@ -616,6 +684,7 @@
<thead class="bg-secondary">
<tr>
<th>Name</th>
<th>Ticket</th>
<th>E-mail</th>
<th>Phone</th>
<th>Reference</th>
@@ -625,6 +694,7 @@
<t t-foreach="attendees" t-as="attendee">
<tr>
<td><t t-if="attendee.name" t-esc="attendee.name"/><t t-else="">N/A</t></td>
<td><i class="fa fa-ticket"></i> <t t-if='attendee.event_ticket_id'><t t-esc="attendee.event_ticket_id.name"/></t><t t-if='not attendee.event_ticket_id'>N/A</t></td>
<td><t t-if="attendee.email" t-esc="attendee.email"/><t t-else="">N/A</t></td>
<td><t t-if="attendee.phone" t-esc="attendee.phone"/><t t-else="">N/A</t></td>
<td><t t-esc="attendee.id"/></td>
@@ -12,5 +12,9 @@
'report/report_event_question_view.xml',
'security/ir.model.access.csv',
],
'demo': [
'data/event_demo.xml',
'data/event_registration_demo.xml',
],
'installable': True,
}
@@ -6,9 +6,9 @@ from odoo.addons.website_event.controllers.main import WebsiteEventController
class WebsiteEvent(WebsiteEventController):
def _process_registration_details(self, details):
def _process_attendees_form(self, event, form_details):
''' Process data posted from the attendee details form. '''
registrations = super(WebsiteEvent, self)._process_registration_details(details)
registrations = super(WebsiteEvent, self)._process_attendees_form(event, form_details)
for registration in registrations:
answer_ids = []
for key, value in registration.items():
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="event_0_question_0" model="event.question">
<field name="title">Meal Type</field>
<field name="is_individual" eval="False"/>
<field name="event_id" ref="event.event_0"/>
</record>
<record id="event_0_question_0_answer_0" model="event.answer">
<field name="name">Mixed</field>
<field name="sequence">1</field>
<field name="question_id" ref="website_event_questions.event_0_question_0"/>
</record>
<record id="event_0_question_0_answer_1" model="event.answer">
<field name="name">Vegetarian</field>
<field name="sequence">2</field>
<field name="question_id" ref="website_event_questions.event_0_question_0"/>
</record>
<record id="event_0_question_0_answer_2" model="event.answer">
<field name="name">Pastafarian</field>
<field name="sequence">3</field>
<field name="question_id" ref="website_event_questions.event_0_question_0"/>
</record>
</data></odoo>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="event.event_registration_0_0" model="event.registration">
<field name="answer_ids" eval="[
(5, 0),
(4, ref('website_event_questions.event_0_question_0_answer_0'))]"/>
</record>
<record id="event.event_registration_0_1" model="event.registration">
<field name="answer_ids" eval="[
(5, 0),
(4, ref('website_event_questions.event_0_question_0_answer_1'))]"/>
</record>
<record id="event.event_registration_0_2" model="event.registration">
<field name="answer_ids" eval="[
(5, 0),
(4, ref('website_event_questions.event_0_question_0_answer_2'))]"/>
</record>
</data></odoo>
+34 -32
View File
@@ -17,44 +17,46 @@ class WebsiteEventSaleController(WebsiteEventController):
event = event.with_context(pricelist=pricelist.id)
return super(WebsiteEventSaleController, self).event_register(event, **post)
def _process_tickets_details(self, data):
ticket_post = {}
for key, value in data.items():
if not key.startswith('nb_register') or '-' not in key:
continue
items = key.split('-')
if len(items) < 2:
continue
ticket_post[int(items[1])] = int(value)
tickets = request.env['event.event.ticket'].browse(tuple(ticket_post))
return [{'id': ticket.id, 'name': ticket.name, 'quantity': ticket_post[ticket.id], 'price': ticket.price} for ticket in tickets if ticket_post[ticket.id]]
def _process_tickets_form(self, event, form_details):
""" Add price information on ticket order """
res = super(WebsiteEventSaleController, self)._process_tickets_form(event, form_details)
for item in res:
item['price'] = item['ticket']['price'] if item['ticket'] else 0
return res
def _create_attendees_from_registration_post(self, event, registration_data):
# we have at least one registration linked to a ticket -> sale mode activate
if any(info.get('event_ticket_id') for info in registration_data):
order = request.website.sale_get_order(force_create=1)
order_attendee_ids = set()
for info in [r for r in registration_data if r.get('event_ticket_id')]:
ticket = request.env['event.event.ticket'].sudo().browse(info['event_ticket_id'])
cart_values = order.with_context(event_ticket_id=ticket.id, fixed_price=True)._cart_update(product_id=ticket.product_id.id, add_qty=1, registration_data=[info])
order_attendee_ids |= set(cart_values.get('attendee_ids', []))
attendees_sudo = super(WebsiteEventSaleController, self)._create_attendees_from_registration_post(event, [r for r in registration_data if not r['event_ticket_id']])
if order_attendee_ids:
attendees_sudo |= request.env['event.registration'].sudo().browse(order_attendee_ids)
return attendees_sudo
@http.route()
def registration_confirm(self, event, **post):
order = request.website.sale_get_order(force_create=1)
attendee_ids = set()
res = super(WebsiteEventSaleController, self).registration_confirm(event, **post)
registrations = self._process_registration_details(post)
for registration in registrations:
ticket = request.env['event.event.ticket'].sudo().browse(int(registration['ticket_id']))
cart_values = order.with_context(event_ticket_id=ticket.id, fixed_price=True)._cart_update(product_id=ticket.product_id.id, add_qty=1, registration_data=[registration])
attendee_ids |= set(cart_values.get('attendee_ids', []))
registrations = self._process_attendees_form(event, post)
# free tickets -> order with amount = 0: auto-confirm, no checkout
if not order.amount_total:
order.action_confirm() # tde notsure: email sending ?
attendees = request.env['event.registration'].browse(list(attendee_ids)).sudo()
# clean context and session, then redirect to the confirmation page
request.website.sale_reset()
urls = event._get_event_resource_urls()
return request.render("website_event.registration_complete", {
'attendees': attendees,
'event': event,
'google_url': urls.get('google_url'),
'iCal_url': urls.get('iCal_url')
})
# we have at least one registration linked to a ticket -> sale mode activate
if any(info['event_ticket_id'] for info in registrations):
order = request.website.sale_get_order(force_create=False)
if order.amount_total:
return request.redirect("/shop/checkout")
# free tickets -> order with amount = 0: auto-confirm, no checkout
elif order:
order.action_confirm() # tde notsure: email sending ?
return request.redirect("/shop/checkout")
return res
def _add_event(self, event_name="New Event", context=None, **kwargs):
product = request.env.ref('event_sale.product_product_event', raise_if_not_found=False)
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="event_sale.event_type_data_sale" model="event.type">
<record id="event.event_type_data_ticket" model="event.type">
<field name="name">Sell Online</field>
<field name="website_menu" eval="True"/>
</record>
@@ -43,7 +43,7 @@ class SaleOrder(models.Model):
values['price_unit'] = ticket.price
else:
values['price_unit'] = ticket.price_reduce
values['name'] = ticket._get_ticket_multiline_description_sale()
values['name'] = ticket._get_ticket_multiline_description()
# avoid writing related values that end up locking the product record
values.pop('event_ok', None)
@@ -1,4 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_event_event_ticket_public,event.event.ticket.public,event_sale.model_event_event_ticket,,1,0,0,0
access_event_product_product_public,event.product.product.public,product.model_product_product,base.group_public,1,0,0,0
access_event_product_template_public,event.product.template.public,product.model_product_template,base.group_public,1,0,0,0
access_event_product_template_public,event.product.template.public,product.model_product_template,base.group_public,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
access_event_event_ticket_public event.event.ticket.public event_sale.model_event_event_ticket 1 0 0 0
2 access_event_product_product_public event.product.product.public product.model_product_product base.group_public 1 0 0 0
3 access_event_product_template_public event.product.template.public product.model_product_template base.group_public 1 0 0 0
@@ -1,16 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="event_event_ticket_public" model="ir.rule">
<field name="name">event ticket: Public</field>
<field name="model_id" ref="event_sale.model_event_event_ticket"/>
<field name="domain_force">[('event_id.website_published', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_public')), (4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="event_product_template_public" model="ir.rule">
<field name="name">Product template linked to event: Public</field>
<field name="model_id" ref="product.model_product_template"/>
@@ -1,130 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="index" inherit_id="website_event.events_list" name="Event's Ticket">
<xpath expr="//div[@t-foreach='event_ids']//footer" position="inside">
<t t-if="event.event_registrations_open and event.event_ticket_ids">
<span t-if="event.seats_availability == 'limited' and not event.seats_available" class="text-danger">Sold Out</span>
<span t-if="event.seats_availability == 'limited' and event.seats_available and event.seats_available &lt;= ((event.seats_max or 0) / 4)" class="text-muted">
<em>Only <t t-esc="event.seats_available"/> Remaining</em>
</span>
</t>
</xpath>
</template>
<template id="registration_template" inherit_id="website_event.registration_template">
<xpath expr="//t[@t-id='tickets']" position="replace">
<t t-set="tickets" t-value="event.event_ticket_ids.filtered(lambda ticket: not ticket.is_expired)"/>
<t t-if="len(event.event_ticket_ids) &gt; 1"> <!-- If some tickets expired and there is only one type left, we keep the same layout -->
<div class="d-flex align-items-center py-2 pl-3 pr-2 border-bottom">
<span t-if="not event.event_registrations_open" class="text-danger">
<i class="fa fa-ban mr-2"/>Sold Out
</span>
<div class="ml-auto pr-3">
<span t-if="event.is_participating" class="text-success ml-3">
<i class="fa fa-check mr-2"/>Registered
</span>
</div>
<a href="#" role="button" class="btn btn-primary o_wevent_registration_btn collapsed" data-toggle="collapse" data-target="#o_wevent_tickets_collapse">
Tickets
<span class="close">×</span>
</a>
</div>
<div id="o_wevent_tickets_collapse" class="collapse">
<div t-foreach="tickets" t-as="ticket" class="row mx-0 bg-light border-bottom">
<div class="col-md-8 py-3" itemscope="itemscope" itemtype="http://schema.org/Offer">
<h5 itemprop="name" t-field="ticket.name" class="my-0"/>
<small t-if="ticket.end_sale_date and ticket.sale_available and not ticket.is_expired" class="text-muted mr-3" itemprop="availabilityEnds">Sales end on <span itemprop="priceValidUntil" t-field="ticket.end_sale_date"/></small>
<small t-if="ticket.start_sale_date and not ticket.sale_available and not ticket.is_expired" class="text-muted mr-3" itemprop="availabilityEnds">Sales start on <span itemprop="priceValidUntil" t-field="ticket.start_sale_date"/></small>
</div>
<div class="col-md-4 py-3 pl-md-0">
<div class="d-flex align-items-center">
<t t-if="ticket.price or editable">
<t t-if="(ticket.price-website.get_current_pricelist().currency_id._convert(ticket.price_reduce, event.company_id.sudo().currency_id, event.company_id, datetime.date.today())) &gt; 1 and website.get_current_pricelist().discount_policy == 'without_discount'">
<del class="text-danger mr-1" t-field="ticket.price" t-options="{'widget': 'monetary', 'from_currency': event.company_id.sudo().currency_id, 'display_currency': website.get_current_pricelist().currency_id}"/>
</t>
<span t-field="ticket.price_reduce" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}" groups="account.group_show_line_subtotals_tax_excluded"/>
<span t-field="ticket.price_reduce_taxinc" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}" groups="account.group_show_line_subtotals_tax_included"/>
<span itemprop="price" class="d-none" t-esc="ticket.price"/>
<span itemprop="priceCurrency" class="d-none" t-esc="website.pricelist_id.currency_id.name"/>
</t>
<span t-if="not ticket.price and not editable" class="font-weight-bold text-uppercase">Free</span>
<t t-if="not ticket.is_expired and ticket.sale_available">
<select t-attf-name="nb_register-#{ticket.id}" class="w-auto ml-auto custom-select">
<t t-set="seats_max_ticket" t-value="(ticket.seats_availability == 'unlimited' or ticket.seats_available &gt; 9) and 10 or ticket.seats_available + 1"/>
<t t-set="seats_max_event" t-value="(event.seats_availability == 'unlimited' or event.seats_available &gt; 9) and 10 or event.seats_available + 1"/>
<t t-set="seats_max" t-value="min(seats_max_ticket, seats_max_event)"/>
<t t-foreach="range(0, seats_max)" t-as="nb">
<option t-esc="nb" t-att-selected="len(ticket) == 0 and nb == 0 and 'selected'"/>
</t>
</select>
</t>
</div>
</div>
<div t-if="ticket.product_id.description_sale" class="col-12">
<p itemprop="description" t-field="ticket.product_id.description_sale" class="small py-2 border-top"/>
</div>
</div>
<div class="row no-gutters">
<div class="col-md-4 offset-md-8 py-2 pl-md-0 pr-md-2">
<button type="submit" class="btn btn-primary o_wait_lazy_js btn-block a-submit" t-att-disabled="not event.event_registrations_open or None" t-attf-id="#{event.id}">Register</button>
</div>
</div>
</div>
</t>
<t t-else="">
<t t-call="website_event.ticket">
<t t-set="name"><span t-field="tickets.name"/></t>
<t t-set="price">
<t t-if="tickets.price or editable">
<t t-if="(tickets.price-website.get_current_pricelist().currency_id._convert(tickets.price_reduce, event.company_id.sudo().currency_id, event.company_id, datetime.date.today())) &gt; 1 and website.get_current_pricelist().discount_policy == 'without_discount'">
<del class="text-danger mr-1" t-field="tickets.price" t-options="{'widget': 'monetary', 'from_currency': event.company_id.sudo().currency_id, 'display_currency': website.get_current_pricelist().currency_id}"/>
</t>
<span t-field="tickets.price_reduce" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}" groups="account.group_show_line_subtotals_tax_excluded"/>
<span t-field="tickets.price_reduce_taxinc" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}" groups="account.group_show_line_subtotals_tax_included"/>
<span itemprop="price" class="d-none" t-esc="tickets.price"/>
<span itemprop="priceCurrency" class="d-none" t-esc="website.pricelist_id.currency_id.name"/>
</t>
<span t-if="not tickets.price and not editable" class="font-weight-bold text-uppercase">Free</span>
</t>
<t t-if="tickets.product_id.description_sale" t-set="description">
<span t-field="tickets.product_id.description_sale"/>
</t>
<t t-set="registration_end">
<t t-if="tickets.start_sale_date">Sales end on <span itemprop="priceValidUntil" t-field="tickets.end_sale_date"/></t>
</t>
<t t-if="not tickets.is_expired and tickets.sale_available" t-set="quantity">
<select t-attf-name="nb_register-#{tickets.id}" class="w-auto custom-select">
<t t-set="seats_max_ticket" t-value="(tickets.seats_availability == 'unlimited' or tickets.seats_available &gt; 9) and 10 or tickets.seats_available + 1"/>
<t t-set="seats_max_event" t-value="(event.seats_availability == 'unlimited' or event.seats_available &gt; 9) and 10 or event.seats_available + 1"/>
<t t-set="seats_max" t-value="min(seats_max_ticket, seats_max_event)"/>
<t t-foreach="range(0, seats_max)" t-as="nb">
<option t-esc="nb" t-att-selected="len(tickets) == 1 and nb == 1 and 'selected'"/>
</t>
</select>
</t>
<!-- Add price information on tickets (multi tickets, aka in collapse) -->
<xpath expr="//div[hasclass('o_wevent_registration_multi_select')]" position="inside">
<t t-if="ticket.price">
<t t-if="(ticket.price-website.get_current_pricelist().currency_id._convert(ticket.price_reduce, event.company_id.sudo().currency_id, event.company_id, datetime.date.today())) &gt; 1 and website.get_current_pricelist().discount_policy == 'without_discount'">
<del class="text-danger mr-1" t-field="ticket.price" t-options="{'widget': 'monetary', 'from_currency': event.company_id.sudo().currency_id, 'display_currency': website.get_current_pricelist().currency_id}"/>
</t>
<span t-field="ticket.price_reduce" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}" groups="account.group_show_line_subtotals_tax_excluded"/>
<span t-field="ticket.price_reduce_taxinc" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}" groups="account.group_show_line_subtotals_tax_included"/>
<span itemprop="price" class="d-none" t-esc="ticket.price"/>
<span itemprop="priceCurrency" class="d-none" t-esc="website.pricelist_id.currency_id.name"/>
</t>
<span t-else="" class="font-weight-bold text-uppercase">Free</span>
</xpath>
<xpath expr="//form[@id='registration_form']" position="attributes">
<attribute name="t-if">event.event_registrations_open and not all([ticket.is_expired for ticket in event.event_ticket_ids])</attribute>
</xpath>
<xpath expr="//form[@id='registration_form']" position="before">
<div t-if="event.start_sale_date and event.start_sale_date &gt; datetime.date.today() and not event.event_registrations_open" class="alert alert-info mb-5" role="status">
<em >Ticket Sales starting on <t t-esc="event.start_sale_date"/></em>
<!-- Add price information on tickets (mono ticket, aka not in collapse) -->
<xpath expr="//div[hasclass('o_wevent_registration_single')]//h6" position="after">
<div class="border-right px-3">
<t t-if="tickets.price">
<t t-if="(tickets.price-website.get_current_pricelist().currency_id._convert(tickets.price_reduce, event.company_id.sudo().currency_id, event.company_id, datetime.date.today())) &gt; 1 and website.get_current_pricelist().discount_policy == 'without_discount'">
<del class="text-danger mr-1" t-field="tickets.price" t-options="{'widget': 'monetary', 'from_currency': event.company_id.sudo().currency_id, 'display_currency': website.get_current_pricelist().currency_id}"/>
</t>
<span t-field="tickets.price_reduce" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}" groups="account.group_show_line_subtotals_tax_excluded"/>
<span t-field="tickets.price_reduce_taxinc" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}" groups="account.group_show_line_subtotals_tax_included"/>
<span itemprop="price" class="d-none" t-esc="tickets.price"/>
<span itemprop="priceCurrency" class="d-none" t-esc="website.pricelist_id.currency_id.name"/>
</t>
<span t-else="" class="font-weight-bold text-uppercase">Free</span>
</div>
</xpath>
</template>
<!-- Add the ticket name on the registration complete page. -->
<template id="registration_complete_inherit_website_event_sale" inherit_id="website_event.registration_complete" name="Sale Registration Completed">
<xpath expr="//thead/tr/th[1]" position="after">
<th>Ticket</th>
</xpath>
<xpath expr="//tbody/t/tr/td[1]" position="after">
<td><i class="fa fa-ticket"></i> <t t-if='attendee.event_ticket_id'><t t-esc="attendee.event_ticket_id.name"/></t><t t-if='not attendee.event_ticket_id'>N/A</t></td>
</xpath>
</template>
</odoo>