diff --git a/portal_event_tickets/README.rst b/portal_event_tickets/README.rst new file mode 100644 index 0000000..a598537 --- /dev/null +++ b/portal_event_tickets/README.rst @@ -0,0 +1,154 @@ +===================== +Customer Event Portal +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:26a1b1272c217905969b3d3a159e7dff8e4ce0d0fc8c18e9c227338d7fc32c84 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-it--projects--llc%2Fwebsite--addons-lightgray.png?logo=github + :target: https://github.com/it-projects-llc/website-addons/tree/14.0/portal_event_tickets + :alt: it-projects-llc/website-addons + +|badge1| |badge2| |badge3| + +Allows to customers see tickets for events at Portal. + +- Only confirmed tickets with attendee_partner_id as current user are + shown + +Additional features: + +- Ticket transferring feature + + - To decrease chance of transferring to a wrong email, partner with + the email must exist before transferring. + + - New *When to Run* values for Email Schedule: + + - transferring_started + - transferring_finished + + - New attendee receives email with a link to finish ticket + transferring + +- Tracks changes in key registration fields (via ``tracking=True``) + +- Tickets can be changed to other products (including other tickets) + + - When old ticket is canceled, a message with a reference to new + Sale Order is posted + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Ticket transferring configuration +--------------------------------- + +At event form: + +- Activate ``[x] Enable Ticket transferring`` + +- At ``Email Schedule`` Tab add record: + + - **Email To Send**: *Event: Transferring started* + - **Unit**: *Immediately* + - **When to Run**: *Transferring started* + +Ticket changing configuration +----------------------------- + +At event form: + +- Activate ``[x] Enable Ticket changing`` + +Usage +===== + +- Open link ``/my`` +- RESULT: there is sections Tickets + +Ticket transferring +------------------- + +Feature allows for attendees to transfer ticket ownership to another +partner by email. + +- Login to portal as current ticket attendee +- Select a ticket +- Click button ``[Transfer to another person]`` +- Specify person's email. The partner must be already registered +- Click ``[Confrim]`` + +Now second person receives an email. If you use test deployment without +mail servers, then you can find email at menu +``[[ Settings ]] >> Technical >> Email >> Messages``. + +- Login to portal as new ticket attendee +- Open then link at email +- Fill the form +- Click ``[Confrim]`` +- RESULT: Ticket has new owner + +Ticket changing +--------------- + +Feature allows to change the ticket to new ticket or product + +- Login to portal +- Select a ticket +- Click button ``[Upgrade / Change ticket]`` +- You are redirected to original event page. You can select new ticket + or navigate to ``/shop`` page and fill the cart +- Follow checkout process +- When the order is confirmed (e.g. after payment), old ticket is + canceled and new one is confirmed + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* IT-Projects LLC + +Contributors +------------ + +- Ivan Yelizariev (https://github.com/yelizariev) +- Alexandr Kolushov (https://github.com/KolushovAlexandr) +- Eugene Molotov (https://github.com/em230418) +- Victor Bykov (https://github.com/BykovVik) +- Ilmir Karamov (https://github.com/ilmir-k) + +Maintainers +----------- + +This module is part of the `it-projects-llc/website-addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/portal_event_tickets/__init__.py b/portal_event_tickets/__init__.py new file mode 100644 index 0000000..91c5580 --- /dev/null +++ b/portal_event_tickets/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/portal_event_tickets/__manifest__.py b/portal_event_tickets/__manifest__.py new file mode 100644 index 0000000..bf2ca1a --- /dev/null +++ b/portal_event_tickets/__manifest__.py @@ -0,0 +1,27 @@ +{ + "name": """Customer Event Portal""", + "summary": """Allows to customers see their tickets for events at the Portal""", + "category": "Marketing", + "images": ["images/banner.jpg"], + "version": "14.0.1.0.0", + "author": "IT-Projects LLC", + "support": "apps@itpp.dev", + "website": "https://github.com/it-projects-llc/website-addons", + "license": "AGPL-3", + "depends": [ + "portal", + "partner_event", + "website_event_sale", + "website_event_attendee_fields", + "website_sale_refund", + ], + "data": [ + "views/portal_templates.xml", + "views/event_registration.xml", + "views/event_event.xml", + "data/mail_template_data.xml", + "views/assets.xml", + ], + "qweb": [], + "demo": ["data/res_users_demo.xml"], +} diff --git a/portal_event_tickets/controllers/__init__.py b/portal_event_tickets/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/portal_event_tickets/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/portal_event_tickets/controllers/main.py b/portal_event_tickets/controllers/main.py new file mode 100644 index 0000000..d90ddf8 --- /dev/null +++ b/portal_event_tickets/controllers/main.py @@ -0,0 +1,344 @@ +import logging + +from werkzeug.exceptions import Forbidden, NotFound + +from odoo import SUPERUSER_ID, _, http +from odoo.exceptions import AccessError +from odoo.http import request + +from odoo.addons.http_routing.models.ir_http import slug +from odoo.addons.portal.controllers.portal import CustomerPortal +from odoo.addons.website_event.controllers.main import WebsiteEventController +from odoo.addons.website_sale.controllers.main import WebsiteSale + +_logger = logging.getLogger(__name__) + + +class PortalEvent(CustomerPortal): + def _prepare_home_portal_values(self, counters): + values = super(PortalEvent, self)._prepare_home_portal_values(counters) + if "tickets_count" in counters: + domain = self._tickets_domain() + values["tickets_count"] = ( + request.env["event.registration"].sudo().search_count(domain) + ) + return values + + def _tickets_domain(self, partner=None): + partner = partner or request.env.user.partner_id + return [ + ("attendee_partner_id", "=", partner.id), + ("state", "=", "open"), + ] + + @http.route() + def account(self, *args, **kw): + """Add sales documents to main account page""" + response = super(PortalEvent, self).account(*args, **kw) + domain = self._tickets_domain() + tickets_count = request.env["event.registration"].search_count(domain) + + response.qcontext.update({"tickets_count": tickets_count}) + return response + + @http.route( + ["/my/tickets", "/my/tickets/page/"], + type="http", + auth="user", + website=True, + ) + def portal_my_tickets(self, page=1, date_begin=None, date_end=None, **kw): + values = self._prepare_portal_layout_values() + Registration = request.env["event.registration"].sudo() + + domain = self._tickets_domain() + if date_begin and date_end: + domain += [ + ("create_date", ">", date_begin), + ("create_date", "<=", date_end), + ] + + # count for pager + ticket_count = Registration.search_count(domain) + # make pager + pager = request.website.pager( + url="/my/tickets", + url_args={"date_begin": date_begin, "date_end": date_end}, + total=ticket_count, + page=page, + step=self._items_per_page, + ) + # search the count to display, according to the pager data + tickets = Registration.search( + domain, limit=self._items_per_page, offset=pager["offset"] + ) + values.update( + { + "date": date_begin, + "page_name": "tickets", + "tickets": tickets, + "pager": pager, + "default_url": "/my/tickets", + } + ) + return request.render("portal_event_tickets.portal_my_tickets", values) + + def _has_ticket_access(self, ticket, to_update=False): + """Ticket must not be sudo`ed""" + env = ticket.env + if not ticket.exists(): + _logger.info("ticket doesn't exist: %s", ticket) + return False + + try: + # We check only ir.rule records, because ir.model.access actually + # doesn't allow portal user to read + ticket.check_access_rule("read") + except AccessError: + groups = env.user.groups_id.mapped("name") + _logger.info( + "Ticket access rights check is not passed! User groups: %s", + groups, + exc_info=True, + ) + return False + + if ticket.sudo().attendee_partner_id.id == ticket.env.user.partner_id.id: + return True + + if to_update: + _logger.info( + "No an attendee %s cannot update ticket %s, which belongs to %s", + ticket.env.user.partner_id, + ticket, + ticket.attendee_partner_id, + ) + # not an attendee, so cannot update + return False + + return env.user.has_group("event.group_event_manager") + + @http.route(["/my/tickets/"], type="http", auth="user", website=True) + def ticket_page(self, ticket=None, **kw): + values = self._prepare_portal_layout_values() + ticket = request.env["event.registration"].browse(ticket) + if not ticket or not ticket.exists(): + raise NotFound() + + if not self._has_ticket_access(ticket): + raise Forbidden() + + ticket_sudo = ticket.sudo() + values.update({"page_name": "tickets", "ticket": ticket_sudo}) + return request.render("portal_event_tickets.portal_ticket_page", values) + + @http.route( + ["/my/tickets/pdf/"], type="http", auth="user", website=True + ) + def portal_get_ticket(self, ticket_id=None, **kw): + ticket = request.env["event.registration"].browse(ticket_id) + report_template_for_portal = ticket.sudo().event_id.report_template_for_portal + + if not self._has_ticket_access(ticket): + raise Forbidden() + + registration_badge_template = ( + report_template_for_portal.get_metadata()[0].get("xmlid") + if report_template_for_portal + else "event.report_event_registration_badge" + ) + + pdf = ( + request.env.ref(registration_badge_template) + .with_user(SUPERUSER_ID) + ._render_qweb_pdf([ticket.id])[0] + ) + + pdfhttpheaders = [ + ("Content-Type", "application/pdf"), + ("Content-Length", len(pdf)), + ("Content-Disposition", "attachment; filename=ticket.pdf;"), + ] + return request.make_response(pdf, headers=pdfhttpheaders) + + @http.route( + ["/my/tickets/transfer"], + type="http", + auth="user", + methods=["GET"], + website=True, + ) + def ticket_transfer_editor(self, **kw): + """Special controller to customize result messages""" + if not request.env.user.has_group("website.group_website_designer"): + return Forbidden() + + values = self._prepare_portal_layout_values() + values.update({"editor_mode": True, "error": kw.get("error")}) + return request.render("portal_event_tickets.portal_ticket_transfer", values) + + @http.route( + ["/my/tickets/transfer"], + type="http", + auth="user", + methods=["POST"], + website=True, + ) + def ticket_transfer(self, to_email, ticket_id, **kw): + values = self._prepare_portal_layout_values() + + error = self._ticket_transfer(request.env, to_email, ticket_id) + + values.update({"to_email": to_email, "error": error}) + return request.render("portal_event_tickets.portal_ticket_transfer", values) + + def _ticket_transfer(self, env, to_email, ticket_id): + """env is passed explicitly to use this method in ci tests""" + ticket = env["event.registration"].browse(int(ticket_id)) + ticket.ensure_one() + + if not self._has_ticket_access(ticket, to_update=True): + raise Forbidden() + + ticket = ticket.sudo() + + if not ticket.event_id.ticket_transferring: + raise Forbidden() + + error = None + + # Yes, error is None here + # but let's have correct indent for possible adding conditions. + if not error: + receiver = ( + env["res.partner"] + .sudo() + .search([("email", "=ilike", to_email)], limit=1) + ) + + if not receiver: + error = "receiver_not_found" + + if not error: + domain = [ + ("attendee_partner_id", "=", receiver.id), + ("state", "not in", ["cancel"]), + ("event_id", "=", ticket.event_id.id), + ] + if env["event.registration"].sudo().search_count(domain): + error = "receiver_has_ticket" + + if not error: + # do the transfer + ticket.transferring_started(receiver) + + return error + + @http.route( + ["/my/tickets/transfer/receive"], + type="http", + auth="user", + methods=["GET", "POST"], + website=True, + ) + def ticket_transfer_receive(self, transfer_ticket=None, **kw): + if transfer_ticket: + ticket = request.env["event.registration"].browse(int(transfer_ticket)) + else: + # Just take first available ticket. Mostly for unittests + # Use sudo as portal user doesn't have access + ticket = ( + request.env["event.registration"] + .sudo() + .search( + [ + ("attendee_partner_id", "=", request.env.user.partner_id.id), + ("is_transferring", "=", True), + ], + limit=1, + ) + ) + # sudo back to original user + ticket = ticket.with_user(request.env.user) + + ticket.ensure_one() + + if not self._has_ticket_access(ticket, to_update=True): + raise Forbidden() + + # we can make sudo once access is checked + ticket = ticket.sudo() + + if not ticket.event_id.ticket_transferring: + raise Forbidden() + + values = self._prepare_portal_layout_values() + if request.httprequest.method == "GET": + tickets = WebsiteEventController()._process_tickets_form( + ticket.event_id, {f"nb_register-{ticket.event_ticket_id.id or 0}": 1} + ) + values.update( + { + "transfer_ticket": ticket, + "tickets": tickets, + "event": ticket.event_id, + "availability_check": True, + } + ) + return request.render( + "portal_event_tickets.portal_ticket_transfer_receive", values + ) + + # handle filled form + + receiver = ticket.attendee_partner_id + registration = WebsiteEventController()._process_attendees_form( + ticket.event_id, kw + )[0] + registration["event_id"] = ticket.event_id.id + partner_vals = request.env["event.registration"]._prepare_partner(registration) + assert not partner_vals.get("email") + + receiver.sudo().write(partner_vals) + + ticket.sudo().transferring_finished() + return request.redirect("/my/tickets") + + @http.route( + ["/my/tickets/change"], type="http", auth="user", methods=["POST"], website=True + ) + def ticket_change(self, ticket_id, **kw): + ticket = request.env["event.registration"].browse(int(ticket_id)) + + if not self._has_ticket_access(ticket, to_update=True): + raise Forbidden() + + if not ticket.event_id.ticket_changing: + raise Forbidden() + + ticket = ticket.sudo() + line = ticket.sale_order_line_id + assert line + product = line.product_id + + order = request.website.sale_get_order(force_create=True) + name = _("Ticket change: %s") % product.name + order.add_refund_line(line, name, 1) + + # TODO: make redirection customizable + return request.redirect("/event/%s/register" % slug(ticket.event_id)) + + +class WebsiteSaleExtended(WebsiteSale): + @http.route() + def cart(self, **post): + response = super(WebsiteSaleExtended, self).cart(**post) + if post.get("total_is_negative"): + response.qcontext.update( + { + "warning_msg": _( + "Total amount is negative. Please add more tickets or products" + ), + } + ) + return response diff --git a/portal_event_tickets/data/mail_template_data.xml b/portal_event_tickets/data/mail_template_data.xml new file mode 100644 index 0000000..b3bd935 --- /dev/null +++ b/portal_event_tickets/data/mail_template_data.xml @@ -0,0 +1,33 @@ + + + + Event: Transferring started + + Finish ticket transferring + ${object.attendee_partner_id.id} + + + ${object.partner_id.lang} + + Hello ${object.attendee_partner_id.name},
+ + The ticket transferring was initiated. Open the link below to complete to process: + + + You +

+

+

+

Best regards,

+ + ]]>
+
+
diff --git a/portal_event_tickets/data/res_users_demo.xml b/portal_event_tickets/data/res_users_demo.xml new file mode 100644 index 0000000..712ae42 --- /dev/null +++ b/portal_event_tickets/data/res_users_demo.xml @@ -0,0 +1,17 @@ + + + + portal1 name + portal1 + portal1 + portal1@example.com + + + + portal2 name + portal2 + portal2 + portal2@example.com + + + diff --git a/portal_event_tickets/i18n/portal_event_tickets.pot b/portal_event_tickets/i18n/portal_event_tickets.pot new file mode 100644 index 0000000..4b98328 --- /dev/null +++ b/portal_event_tickets/i18n/portal_event_tickets.pot @@ -0,0 +1,299 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * portal_event_tickets +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 11.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: portal_event_tickets +#: model:mail.template,body_html:portal_event_tickets.email_template_signup +msgid "\n" +"\n" +"

\n" +" Hello ${object.attendee_partner_id.name},
\n" +"\n" +" The ticket transferring was initiated. Open the link below to complete to process:\n" +"\n" +"

\n" +" \n" +"
\n" +" You \n" +"

\n" +"

\n" +"

\n" +"

Best regards,

\n" +"\n" +" " +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Warning! Once confirmed this action cannot be undone" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "&times;" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Event:" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Ticket Type:" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model,name:portal_event_tickets.model_event_registration +msgid "Attendee" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,help:portal_event_tickets.field_event_event_ticket_changing +msgid "Attendee can change ticket to new ticket or products" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,help:portal_event_tickets.field_event_event_ticket_transferring +msgid "Attendee can transfer ticket to another partner" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Close" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Confirm" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Continue" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Email" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,field_description:portal_event_tickets.field_event_event_ticket_changing +msgid "Enable Ticket Changing" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,field_description:portal_event_tickets.field_event_event_ticket_transferring +msgid "Enable Ticket transferring" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model,name:portal_event_tickets.model_event_event +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_my_tickets +msgid "Event" +msgstr "" + +#. module: portal_event_tickets +#: model:mail.template,subject:portal_event_tickets.email_template_signup +msgid "Finish ticket transferring" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,field_description:portal_event_tickets.field_website_ticket_transfer_receiver_has_ticket +msgid "Message ticket transfer: receiver already has ticket" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,field_description:portal_event_tickets.field_website_ticket_transfer_receiver_not_found +msgid "Message ticket transfer: receiver not found" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,field_description:portal_event_tickets.field_website_ticket_transfer_success +msgid "Message ticket transfer: success" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "My Tickets" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,field_description:portal_event_tickets.field_event_registration_origin_registration +msgid "Original Ticket" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_transfer +msgid "Partner already has ticket" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Print" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Receiver's Email" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model,name:portal_event_tickets.model_event_mail_registration +msgid "Registration Mail Scheduler" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model,name:portal_event_tickets.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model,name:portal_event_tickets.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_my_home_event +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_my_tickets +msgid "There are currently no tickets for your account." +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "This ticket can be changed to other tickets or products. Click continue, register new ticket (or fill the cart by other tickets\\products) and follow usual checkout process." +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.message_origin_link +msgid "This ticket has been canceled from:" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Ticket #" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_my_tickets +msgid "Ticket ID" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_my_tickets +msgid "Ticket Type" +msgstr "" + +#. module: portal_event_tickets +#: code:addons/portal_event_tickets/controllers/main.py:279 +#, python-format +msgid "Ticket change: %s" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,field_description:portal_event_tickets.field_event_registration_is_transferring +msgid "Ticket in transferring" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Ticket transferring" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,help:portal_event_tickets.field_event_registration_is_transferring +msgid "Ticket transferring is started, but not finished" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.view_event_registration_ticket_form +msgid "Ticket transferring was started, but haven't finished!" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,field_description:portal_event_tickets.field_event_registration_was_transferred +#: model:ir.model.fields,help:portal_event_tickets.field_event_registration_was_transferred +msgid "Ticket was transferred" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,help:portal_event_tickets.field_event_registration_was_updated +msgid "Ticket was transferred or updated" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model.fields,field_description:portal_event_tickets.field_event_registration_was_updated +msgid "Ticket was updated" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_my_home_menu_event +msgid "Tickets" +msgstr "" + +#. module: portal_event_tickets +#: code:addons/portal_event_tickets/controllers/main.py:292 +#, python-format +msgid "Total amount is negative. Please add more tickets or products" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Transfer to another person" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_transfer +msgid "Transfered succefully" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Upgrade / Change ticket" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_page +msgid "Upgrade / change ticket" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_transfer +msgid "Use one of following link to see page in a mode you need." +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model,name:portal_event_tickets.model_website +msgid "Website" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_my_home_event +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_my_tickets +msgid "Your Tickets" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.model,name:portal_event_tickets.model_event_mail +msgid "event.mail" +msgstr "" + +#. module: portal_event_tickets +#: model:ir.ui.view,arch_db:portal_event_tickets.portal_ticket_transfer +msgid "there is no partner with that email" +msgstr "" + diff --git a/portal_event_tickets/images/banner.jpg b/portal_event_tickets/images/banner.jpg new file mode 100644 index 0000000..1368e2c Binary files /dev/null and b/portal_event_tickets/images/banner.jpg differ diff --git a/portal_event_tickets/models/__init__.py b/portal_event_tickets/models/__init__.py new file mode 100644 index 0000000..d4d5ed8 --- /dev/null +++ b/portal_event_tickets/models/__init__.py @@ -0,0 +1,5 @@ +from . import website +from . import event_registration +from . import event_mail +from . import event +from . import sale_order diff --git a/portal_event_tickets/models/event.py b/portal_event_tickets/models/event.py new file mode 100644 index 0000000..4d9ae2a --- /dev/null +++ b/portal_event_tickets/models/event.py @@ -0,0 +1,38 @@ +from odoo import fields, models +from odoo.http import request + + +class Event(models.Model): + _inherit = "event.event" + + report_template_for_portal = fields.Many2one( + "ir.actions.report", "Badge Template For Portal" + ) + + ticket_transferring = fields.Boolean( + "Enable Ticket transferring", + help="Attendee can transfer ticket to another partner", + default=True, + ) + + ticket_changing = fields.Boolean( + "Enable Ticket Changing", + help="Attendee can change ticket to new ticket or products", + default=True, + ) + + def check_partner_for_new_ticket(self, partner_id): + registration = self.partner_is_participating(partner_id) + if registration: + ticket_order_lines = registration.mapped("sale_order_line_id") + + if request.website.sale_get_order(): + cart_refund_lines = request.website.sale_get_order().order_line.mapped( + lambda r: r.refund_source_line_id + ) + # all refund lines must be in ticket_order_lines + if all([line in cart_refund_lines for line in ticket_order_lines]): + # False means no errors + return False + + return super(Event, self).check_partner_for_new_ticket(partner_id) diff --git a/portal_event_tickets/models/event_mail.py b/portal_event_tickets/models/event_mail.py new file mode 100644 index 0000000..4716e4b --- /dev/null +++ b/portal_event_tickets/models/event_mail.py @@ -0,0 +1,106 @@ +# pylint: disable=api-one-deprecated +from datetime import datetime + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, tools + +_INTERVALS = { + "hours": lambda interval: relativedelta(hours=interval), + "days": lambda interval: relativedelta(days=interval), + "weeks": lambda interval: relativedelta(days=7 * interval), + "months": lambda interval: relativedelta(months=interval), + "now": lambda interval: relativedelta(hours=0), +} + + +class EventMailScheduler(models.Model): + _inherit = "event.mail" + + interval_type = fields.Selection( + selection_add=[ + ("transferring_started", "Transferring started"), + ("transferring_finished", "Transferring finished"), + ], + ondelete={ + "transferring_started": "cascade", + "transferring_finished": "cascade", + }, + ) + + @api.depends( + "event_id.registration_ids.state", + "event_id.date_begin", + "interval_type", + "interval_unit", + "interval_nbr", + ) + def _compute_scheduled_date(self): + for rself in self: + if rself.interval_type not in [ + "transferring_started", + "transferring_finished", + ]: + super(EventMailScheduler, rself)._compute_scheduled_date() + continue + + if rself.event_id.state not in ["confirm", "done"]: + rself.scheduled_date = False + else: + date, sign = rself.event_id.create_date, 1 + rself.scheduled_date = datetime.strptime( + date, tools.DEFAULT_SERVER_DATETIME_FORMAT + ) + _INTERVALS[rself.interval_unit](sign * rself.interval_nbr) + + def execute(self, registration=None): + for rself in self: + if rself.interval_type not in [ + "transferring_started", + "transferring_finished", + ]: + super(EventMailScheduler, rself).execute() + continue + + if registration: + rself.write( + { + "mail_registration_ids": [ + (0, 0, {"registration_id": registration.id}) + ] + } + ) + # execute scheduler on registrations + rself.mail_registration_ids.filtered( + lambda reg: reg.scheduled_date + and reg.scheduled_date + <= datetime.strftime( + fields.datetime.now(), tools.DEFAULT_SERVER_DATETIME_FORMAT + ) + ).execute() + return True + + +class EventMailRegistration(models.Model): + _inherit = "event.mail.registration" + + @api.depends( + "registration_id", "scheduler_id.interval_unit", "scheduler_id.interval_type" + ) + def _compute_scheduled_date(self): + for rself in self: + if rself.scheduler_id.interval_type not in [ + "transferring_started", + "transferring_finished", + ]: + super(EventMailRegistration, rself)._compute_scheduled_date() + continue + + if rself.registration_id: + # date_open is not corresponded to its meaining, + # but keep because it's copy-pasted code + date_open_datetime = fields.datetime.now() + rself.scheduled_date = date_open_datetime + _INTERVALS[ + rself.scheduler_id.interval_unit + ](rself.scheduler_id.interval_nbr) + else: + rself.scheduled_date = False diff --git a/portal_event_tickets/models/event_registration.py b/portal_event_tickets/models/event_registration.py new file mode 100644 index 0000000..bd8a23f --- /dev/null +++ b/portal_event_tickets/models/event_registration.py @@ -0,0 +1,98 @@ +from odoo import api, fields, models + + +class EventRegistration(models.Model): + _inherit = "event.registration" + + # New fields + is_transferring = fields.Boolean( + "Ticket in transferring", + help="Ticket transferring is started, but not finished", + default=False, + ) + was_transferred = fields.Boolean( + "Ticket was transferred", + help="Ticket was transferred", + default=False, + ) + origin_registration = fields.Many2one( + "event.registration", + compute="_compute_origin_registration", + store=True, + string="Original Ticket", + tracking=True, + ) + was_updated = fields.Boolean( + "Ticket was updated", + compute="_compute_was_updated", + help="Ticket was transferred or updated", + default=False, + ) + + # Updated fields + email = fields.Char(tracking=True) + phone = fields.Char(tracking=True) + name = fields.Char(tracking=True) + + attendee_partner_id = fields.Many2one(tracking=True) + partner_id = fields.Many2one(tracking=True) + event_id = fields.Many2one(tracking=True) + event_ticket_id = fields.Many2one(tracking=True) + + @api.depends("sale_order_id", "sale_order_id.order_line") + def _compute_origin_registration(self): + for r in self: + order = False + if r.sale_order_id: + refunded_lines = r.sale_order_id.order_line.filtered( + lambda x: x.refund_source_line_id + ).mapped("refund_source_line_id") + if refunded_lines: + order = refunded_lines[0].order_id + r.origin_registration = order and self.search( + [("state", "=", "cancel"), ("sale_order_id", "=", order.id)], limit=1 + ) + + @api.depends("was_transferred", "origin_registration") + def _compute_was_updated(self): + for r in self: + r.was_updated = False + if r.was_transferred or r.origin_registration: + r.was_updated = True + + def transferring_started(self, receiver): + self.ensure_one() + self.write( + { + "attendee_partner_id": receiver.id, + "email": receiver.email, + "name": receiver.name, + "phone": receiver.phone, + "is_transferring": True, + } + ) + + # trigger email sending + onsubscribe_schedulers = self.event_id.event_mail_ids.filtered( + lambda s: s.interval_type == "transferring_started" + ) + onsubscribe_schedulers.execute(self) # self is a registration + + def transferring_finished(self): + self.ensure_one() + receiver = self.attendee_partner_id + # Update name and phone in registration, because those may be changed + # Mark that transferring is finished + self.write( + { + "name": receiver.name, + "phone": receiver.phone, + "is_transferring": False, + "was_transferred": True, + } + ) + # trigger email sending + onsubscribe_schedulers = self.event_id.event_mail_ids.filtered( + lambda s: s.interval_type == "transferring_finished" + ) + onsubscribe_schedulers.execute(self) # self is a registration diff --git a/portal_event_tickets/models/sale_order.py b/portal_event_tickets/models/sale_order.py new file mode 100644 index 0000000..5a1a2e3 --- /dev/null +++ b/portal_event_tickets/models/sale_order.py @@ -0,0 +1,31 @@ +from odoo import models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def _cancel_line(self, origin=None): + res = super(SaleOrderLine, self)._cancel_line(origin=origin) + + tickets = self.env["event.registration"].search( + [ + ("sale_order_line_id", "in", self.ids), + ("attendee_partner_id", "=", origin.partner_id.id), + ("event_id", "=", self.event_id.id), + ] + ) + tickets.action_cancel() + + for t in tickets: + # post a message why it was canceled + t.message_post_with_view( + "portal_event_tickets.message_origin_link", + values={"origin": origin}, + subtype_id=self.env.ref("mail.mt_note").id, + ) + + return res diff --git a/portal_event_tickets/models/website.py b/portal_event_tickets/models/website.py new file mode 100644 index 0000000..46599a6 --- /dev/null +++ b/portal_event_tickets/models/website.py @@ -0,0 +1,20 @@ +from odoo import fields, models + + +class Website(models.Model): + _inherit = "website" + + ticket_transfer_success = fields.Html( + "Message ticket transfer: success", + default="

Ticket is transfered successfully. We will instruct receiver on further actions.

", # noqa: E501 + ) + + ticket_transfer_receiver_not_found = fields.Html( + "Message ticket transfer: receiver not found", + default="""

User with the email is not found. The user has to signup first.

""", # noqa: E501 + ) + + ticket_transfer_receiver_has_ticket = fields.Html( + "Message ticket transfer: receiver already has ticket", + default="""

User already has the ticket.

""", + ) diff --git a/portal_event_tickets/pyproject.toml b/portal_event_tickets/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/portal_event_tickets/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/portal_event_tickets/readme/CONFIGURE.md b/portal_event_tickets/readme/CONFIGURE.md new file mode 100644 index 0000000..ce27f9b --- /dev/null +++ b/portal_event_tickets/readme/CONFIGURE.md @@ -0,0 +1,18 @@ +Ticket transferring configuration +--------------------------------- + +At event form: + +* Activate `[x] Enable Ticket transferring` +* At `Email Schedule` Tab add record: + + * **Email To Send**: *Event: Transferring started* + * **Unit**: *Immediately* + * **When to Run**: *Transferring started* + +Ticket changing configuration +----------------------------- + +At event form: + +* Activate `[x] Enable Ticket changing` diff --git a/portal_event_tickets/readme/CONTRIBUTORS.md b/portal_event_tickets/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..5ffabe4 --- /dev/null +++ b/portal_event_tickets/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- Ivan Yelizariev (https://github.com/yelizariev) +- Alexandr Kolushov (https://github.com/KolushovAlexandr) +- Eugene Molotov (https://github.com/em230418) +- Victor Bykov (https://github.com/BykovVik) +- Ilmir Karamov (https://github.com/ilmir-k) diff --git a/portal_event_tickets/readme/DESCRIPTION.md b/portal_event_tickets/readme/DESCRIPTION.md new file mode 100644 index 0000000..ccdbca5 --- /dev/null +++ b/portal_event_tickets/readme/DESCRIPTION.md @@ -0,0 +1,21 @@ +Allows to customers see tickets for events at Portal. + +* Only confirmed tickets with attendee_partner_id as current user are shown + +Additional features: + +* Ticket transferring feature + + - To decrease chance of transferring to a wrong email, partner with the email must exist before transferring. + - New *When to Run* values for Email Schedule: + + * transferring_started + * transferring_finished + + - New attendee receives email with a link to finish ticket transferring + +* Tracks changes in key registration fields (via `tracking=True`) + +* Tickets can be changed to other products (including other tickets) + + - When old ticket is canceled, a message with a reference to new Sale Order is posted diff --git a/portal_event_tickets/readme/USAGE.md b/portal_event_tickets/readme/USAGE.md new file mode 100644 index 0000000..4a21b57 --- /dev/null +++ b/portal_event_tickets/readme/USAGE.md @@ -0,0 +1,33 @@ +* Open link `/my` +* RESULT: there is sections Tickets + +Ticket transferring +------------------- + +Feature allows for attendees to transfer ticket ownership to another partner by email. + +* Login to portal as current ticket attendee +* Select a ticket +* Click button `[Transfer to another person]` +* Specify person's email. The partner must be already registered +* Click `[Confrim]` + +Now second person receives an email. If you use test deployment without mail servers, then you can find email at menu `[[ Settings ]] >> Technical >> Email >> Messages`. + +* Login to portal as new ticket attendee +* Open then link at email +* Fill the form +* Click `[Confrim]` +* RESULT: Ticket has new owner + +Ticket changing +--------------- + +Feature allows to change the ticket to new ticket or product + +* Login to portal +* Select a ticket +* Click button `[Upgrade / Change ticket]` +* You are redirected to original event page. You can select new ticket or navigate to `/shop` page and fill the cart +* Follow checkout process +* When the order is confirmed (e.g. after payment), old ticket is canceled and new one is confirmed diff --git a/portal_event_tickets/static/description/icon.png b/portal_event_tickets/static/description/icon.png new file mode 100644 index 0000000..5d32362 Binary files /dev/null and b/portal_event_tickets/static/description/icon.png differ diff --git a/portal_event_tickets/static/description/index.html b/portal_event_tickets/static/description/index.html new file mode 100644 index 0000000..4e019e2 --- /dev/null +++ b/portal_event_tickets/static/description/index.html @@ -0,0 +1,100 @@ +
+
+
+

Portal Event

+

Customer UI to see and manage tickets

+
+
+
+ +
+
+
+ +
+ Key features: +
    + +
  • + + Customer can see all his tickets +
  • + +
  • + + Possiblity to transfer ticket to another person (optional) +
  • + +
  • + + Possiblity to change / upgrade ticket (optional) +
  • + +
+
+ +
+
+
+ +
+
+

New section in Portal

+
+ +
+
+
+ +
+
+
+

Need our service?

+

Contact us by email or fill out request form

+ +
+
+
+
+ Tested on Odoo
11.0 community +
+ +
+
+
+
diff --git a/portal_event_tickets/static/description/ticket.png b/portal_event_tickets/static/description/ticket.png new file mode 100644 index 0000000..c1170db Binary files /dev/null and b/portal_event_tickets/static/description/ticket.png differ diff --git a/portal_event_tickets/static/src/js/portal.js b/portal_event_tickets/static/src/js/portal.js new file mode 100644 index 0000000..c8491cc --- /dev/null +++ b/portal_event_tickets/static/src/js/portal.js @@ -0,0 +1,37 @@ +odoo.define("portal_event_tickets.portal", function (require) { + "use strict"; + + var publicWidget = require("web.public.widget"); + + publicWidget.registry.transferTicketWidget = publicWidget.Widget.extend({ + selector: "#transfer_ticket", + + start: function () { + var def = this._super.apply(this, arguments); + + var event_name = this.$el.data("event-name"); + var $modal = $("#modal_attendees_registration"); + + /* Show form inline */ + $modal.find("form").attr("action", "/my/tickets/transfer/receive"); + $modal.removeClass("modal fade"); + + /* Remove Cancel button; update title */ + var $submit = $modal.find("[type=submit]"); + $submit.parent().empty().append($submit); + $submit.text("Confirm"); + + /* Remove Close button */ + $modal.find(".close").remove(); + + /* Make email non-editable */ + $modal.find("[name=1-email]").attr("disabled", "1"); + + /* Update title */ + $modal + .find("h4.modal-title") + .html("Receive the ticket for " + event_name + ""); + return def; + }, + }); +}); diff --git a/portal_event_tickets/static/src/js/ticket_transfer.tour.js b/portal_event_tickets/static/src/js/ticket_transfer.tour.js new file mode 100644 index 0000000..7e1194f --- /dev/null +++ b/portal_event_tickets/static/src/js/ticket_transfer.tour.js @@ -0,0 +1,40 @@ +odoo.define("portal_event_tickets.ticket_transfer_tour", function (require) { + "use strict"; + + var tour = require("web_tour.tour"); + + var options = { + test: true, + url: "/my/tickets/transfer/receive", + }; + + var tour_name = "ticket_transfer_receive"; + tour.register(tour_name, options, [ + { + content: "Fill attendees details", + extra_trigger: "input[name='1-function']", + trigger: "input[name='1-name']", + run: function () { + // Fill: + // * phone (optional) + // * country_id (mandatory) + // skip: + // * job position (optional) + $("input[name='1-phone']").val("111 111"); + $("select[name='1-country_id']").val("1"); + }, + }, + { + content: "Validate attendees details", + extra_trigger: "input[name='1-phone']", + trigger: 'button:contains("Confirm")', + }, + { + content: "We are redirected to /my/tickets page", + trigger: ".breadcrumb-item:contains(Tickets)", + run: function () { + // It's needed to don't make a click on the link + }, + }, + ]); +}); diff --git a/portal_event_tickets/tests/__init__.py b/portal_event_tickets/tests/__init__.py new file mode 100644 index 0000000..14ea249 --- /dev/null +++ b/portal_event_tickets/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_ticket_transfer +from . import test_ticket_pdf diff --git a/portal_event_tickets/tests/common.py b/portal_event_tickets/tests/common.py new file mode 100644 index 0000000..b28e955 --- /dev/null +++ b/portal_event_tickets/tests/common.py @@ -0,0 +1,128 @@ +from datetime import datetime, timedelta + +from odoo import fields +from odoo.tests.common import HttpCase + + +class TourCase(HttpCase): + def setUp(self): + super(TourCase, self).setUp() + + # create Event + self.event = self.env["event.event"].create( + { + "name": "TestEvent", + "create_partner": True, + "date_begin": fields.Datetime.to_string( + datetime.today() + timedelta(days=1) + ), + "date_end": fields.Datetime.to_string( + datetime.today() + timedelta(days=15) + ), + "website_published": True, + } + ) + self.ticket_type_1 = self.env.ref("event.event_0_ticket_1").copy( + {"event_id": self.event.id} + ) + self.ticket_type_2 = self.env.ref("event.event_0_ticket_2").copy( + {"event_id": self.event.id} + ) + + self.event.write( + { + "attendee_field_ids": [ + ( + 6, + 0, + [ + self.env.ref( + "website_event_attendee_fields.attendee_field_name" + ).id, + self.env.ref( + "website_event_attendee_fields.attendee_field_email" + ).id, + self.env.ref( + "website_event_attendee_fields.attendee_field_phone" + ).id, + self.env.ref( + "website_event_attendee_fields.attendee_field_country_id" + ).id, + self.env.ref( + "website_event_attendee_fields.attendee_field_function" + ).id, + ], + ) + ] + } + ) + + # create Portal User + self.user_portal1 = self.env.ref("portal_event_tickets.user_portal1") + + sale_order, self.ticket1 = self._create_ticket( + ticket_type=self.ticket_type_1, + partner=self.user_portal1.partner_id, + event=self.event, + ) + sale_order.action_confirm() + + self.user_portal2 = self.env.ref("portal_event_tickets.user_portal2") + + def _create_ticket(self, ticket_type, partner, event): + product = ticket_type.product_id + + # I create a sale order + sale_order = self.env["sale.order"].create( + { + "partner_id": partner.id, + "note": "Invoice after delivery", + } + ) + sale_order.onchange_partner_id() + + # In the sale order I add some sale order lines. i choose event product + sale_order_line = self.env["sale.order.line"].create( + { + "product_id": product.id, + # we set price_unit to 0 + # to confirm registration via registration.editor + "price_unit": 0, + "product_uom": self.env.ref("uom.product_uom_unit").id, + "product_uom_qty": 1.0, + "order_id": sale_order.id, + "name": "sale order line", + "event_id": event.id, + "event_ticket_id": ticket_type.id, + } + ) + + # In the event registration I add some attendee detail lines. + # I choose event product + register_person = self.env["registration.editor"].create( + { + "sale_order_id": sale_order.id, + "event_registration_ids": [ + ( + 0, + 0, + { + "event_id": event.id, + "name": partner.name, + "email": partner.email, + "sale_order_line_id": sale_order_line.id, + }, + ) + ], + } + ) + + # I click apply to create attendees + register_person.action_make_registration() + + return ( + sale_order, + self.env["event.registration"].search( + [("sale_order_id", "=", sale_order.id)] + ), + ) diff --git a/portal_event_tickets/tests/test_ticket_pdf.py b/portal_event_tickets/tests/test_ticket_pdf.py new file mode 100644 index 0000000..b7df33a --- /dev/null +++ b/portal_event_tickets/tests/test_ticket_pdf.py @@ -0,0 +1,19 @@ +from odoo.tests.common import tagged + +from .common import TourCase + + +@tagged("-at_install", "post_install") +class TicketPDF(TourCase): + def test_ticket_access_pdf(self): + self.assertEqual( + self.ticket1.attendee_partner_id, + self.user_portal1.partner_id, + "Wrong attendee_partner_id value before the test", + ) + + login = self.user_portal1.login + self.authenticate(login, login) + + r = self.url_open(f"/my/tickets/pdf/{self.ticket1.id}", allow_redirects=False) + self.assertEqual(r.status_code, 200) diff --git a/portal_event_tickets/tests/test_ticket_transfer.py b/portal_event_tickets/tests/test_ticket_transfer.py new file mode 100644 index 0000000..446c943 --- /dev/null +++ b/portal_event_tickets/tests/test_ticket_transfer.py @@ -0,0 +1,35 @@ +from odoo.tests.common import tagged + +from ..controllers.main import PortalEvent +from .common import TourCase + + +@tagged("-at_install", "post_install") +class TicketTransfer(TourCase): + def test_ticket_transfer_tour(self): + """user_portal1 transfers his ticket1 to user_portal2""" + + self.assertEqual( + self.ticket1.attendee_partner_id, + self.user_portal1.partner_id, + "Wrong attendee_partner_id value before the test", + ) + + # user_portal1 transfers ticket to user_portal2 + env = self.env["res.users"].with_user(self.user_portal1).env + PortalEvent()._ticket_transfer(env, self.user_portal2.email, self.ticket1.id) + + # user_portal2 click on the link in email + self.start_tour("/", "ticket_transfer_receive", login=self.user_portal2.login) + + self.assertEqual( + self.ticket1.state, + "open", + "Ticket doesn't have state Confirmed after transfering", + ) + + self.assertEqual( + self.ticket1.attendee_partner_id, + self.user_portal2.partner_id, + "Ticket Attendee was not changed", + ) diff --git a/portal_event_tickets/views/assets.xml b/portal_event_tickets/views/assets.xml new file mode 100644 index 0000000..ef6ecad --- /dev/null +++ b/portal_event_tickets/views/assets.xml @@ -0,0 +1,24 @@ + + +