Initialize fork and rebrand app to event_manager
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-11-01 11:36:52.321319",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"label",
|
||||
"fieldname",
|
||||
"column_break_nptw",
|
||||
"value",
|
||||
"fieldtype"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "label",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Label"
|
||||
},
|
||||
{
|
||||
"fieldname": "fieldname",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Fieldname",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "value",
|
||||
"fieldtype": "Code",
|
||||
"in_list_view": 1,
|
||||
"label": "Value"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_nptw",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "fieldtype",
|
||||
"fieldtype": "Data",
|
||||
"label": "Fieldtype"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-01 11:38:22.581796",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Additional Field",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class AdditionalField(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
fieldname: DF.Data
|
||||
fieldtype: DF.Data | None
|
||||
label: DF.Data | None
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
value: DF.Data
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2025, BWH Studios and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Attendee Ticket Add-on", {
|
||||
// });
|
||||
|
||||
frappe.ui.form.on("Ticket Add-on Value", {
|
||||
add_on(frm, cdt, cdn) {
|
||||
const doc = frappe.get_doc(cdt, cdn);
|
||||
frappe.db.get_value("Ticket Add-on", doc.add_on, "options").then(({ message }) => {
|
||||
if (message.options) {
|
||||
const options = message.options.trim().split("\n");
|
||||
frm.grids[0].grid.grid_rows_by_docname[cdn].on_grid_fields_dict.value.set_data(
|
||||
options
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-07-26 12:59:48.053719",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"attendee_name",
|
||||
"section_break_rejz",
|
||||
"add_ons"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "add_ons",
|
||||
"fieldtype": "Table",
|
||||
"label": "Add ons",
|
||||
"options": "Ticket Add-on Value"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_rejz",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fetch_from": "attendee.full_name",
|
||||
"fieldname": "attendee_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Attendee Name",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-10 14:17:52.755675",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Attendee Ticket Add-on",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Event Manager",
|
||||
"select": 1,
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"read_only": 1,
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "attendee_name"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class AttendeeTicketAddon(Document):
|
||||
pass
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) 2025, BWH Studios and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class IntegrationTestAttendeeTicketAddon(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for AttendeeTicketAddon.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2025, BWH Studios and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Pohodex Event Manager Coupon Code", {
|
||||
refresh(frm) {
|
||||
frm.set_query("ticket_type", () => {
|
||||
return {
|
||||
filters: {
|
||||
event: frm.doc.event,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("add_on", "free_add_ons", () => {
|
||||
return {
|
||||
filters: {
|
||||
event: frm.doc.event,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.trigger("coupon_type");
|
||||
frm.trigger("applies_to");
|
||||
},
|
||||
|
||||
coupon_type(frm) {
|
||||
if (frm.doc.coupon_type === "Free Tickets") {
|
||||
frm.set_value("applies_to", "Event");
|
||||
}
|
||||
},
|
||||
|
||||
applies_to(frm) {
|
||||
if (frm.doc.applies_to === "Event") {
|
||||
frm.set_value("event_category", null);
|
||||
} else if (frm.doc.applies_to === "Event Category") {
|
||||
frm.set_value("event", null);
|
||||
} else {
|
||||
frm.set_value("event", null);
|
||||
frm.set_value("event_category", null);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:code",
|
||||
"creation": "2025-12-30 19:40:20.803479",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"code",
|
||||
"coupon_type",
|
||||
"applies_to",
|
||||
"column_break_hhol",
|
||||
"is_active",
|
||||
"event",
|
||||
"event_category",
|
||||
"section_break_nvvh",
|
||||
"ticket_type",
|
||||
"number_of_free_tickets",
|
||||
"discount_type",
|
||||
"discount_value",
|
||||
"column_break_pbrj",
|
||||
"maximum_discount_amount",
|
||||
"minimum_order_value",
|
||||
"validity_limits_section",
|
||||
"valid_from",
|
||||
"valid_till",
|
||||
"column_break_elot",
|
||||
"max_usage_count",
|
||||
"max_usage_per_user",
|
||||
"section_break_ygde",
|
||||
"free_add_ons",
|
||||
"usage_statistics_section",
|
||||
"times_used",
|
||||
"column_break_zogd",
|
||||
"free_tickets_claimed"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"description": "Leave empty to auto-generate",
|
||||
"fieldname": "code",
|
||||
"fieldtype": "Data",
|
||||
"label": "Code",
|
||||
"set_only_once": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "coupon_type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Coupon Type",
|
||||
"options": "Free Tickets\nDiscount",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "is_active",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Active"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.coupon_type == 'Free Tickets' || doc.applies_to == 'Event'",
|
||||
"fieldname": "event",
|
||||
"fieldtype": "Link",
|
||||
"label": "Event",
|
||||
"mandatory_depends_on": "eval:doc.coupon_type == 'Free Tickets' || doc.applies_to == 'Event'",
|
||||
"options": "Pohodex Event Manager Event"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.applies_to == 'Event Category'",
|
||||
"fieldname": "event_category",
|
||||
"fieldtype": "Link",
|
||||
"label": "Event Category",
|
||||
"mandatory_depends_on": "eval:doc.applies_to == 'Event Category'",
|
||||
"options": "Event Category"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.coupon_type == 'Free Tickets'",
|
||||
"fieldname": "ticket_type",
|
||||
"fieldtype": "Link",
|
||||
"label": "Ticket Type",
|
||||
"mandatory_depends_on": "eval:doc.coupon_type == 'Free Tickets'",
|
||||
"options": "Event Ticket Type"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.coupon_type == 'Free Tickets'",
|
||||
"fieldname": "number_of_free_tickets",
|
||||
"fieldtype": "Int",
|
||||
"label": "Number of Free Tickets",
|
||||
"mandatory_depends_on": "eval:doc.coupon_type == 'Free Tickets'",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.coupon_type == 'Free Tickets'",
|
||||
"fieldname": "free_add_ons",
|
||||
"fieldtype": "Table",
|
||||
"label": "Free Add-ons",
|
||||
"options": "Coupon Free Add-on"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.coupon_type == 'Discount'",
|
||||
"fieldname": "discount_type",
|
||||
"fieldtype": "Select",
|
||||
"label": "Discount Type",
|
||||
"mandatory_depends_on": "eval:doc.coupon_type == 'Discount'",
|
||||
"options": "Percentage\nFlat Amount"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.coupon_type == 'Discount'",
|
||||
"fieldname": "discount_value",
|
||||
"fieldtype": "Float",
|
||||
"label": "Discount Value",
|
||||
"mandatory_depends_on": "eval:doc.coupon_type == 'Discount'",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.coupon_type == 'Discount'",
|
||||
"description": "0 is unlimited",
|
||||
"fieldname": "max_usage_count",
|
||||
"fieldtype": "Int",
|
||||
"label": "Max Usage Count",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.coupon_type == 'Discount'",
|
||||
"fieldname": "times_used",
|
||||
"fieldtype": "Int",
|
||||
"is_virtual": 1,
|
||||
"label": "Times Used",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.coupon_type == 'Free Tickets'",
|
||||
"fieldname": "free_tickets_claimed",
|
||||
"fieldtype": "Int",
|
||||
"is_virtual": 1,
|
||||
"label": "Free Tickets Claimed",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_hhol",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_nvvh",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_pbrj",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.coupon_type == 'Discount' && doc.discount_type == 'Percentage'",
|
||||
"fieldname": "maximum_discount_amount",
|
||||
"fieldtype": "Float",
|
||||
"label": "Maximum Discount Amount",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ygde",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.coupon_type == 'Discount'",
|
||||
"fieldname": "minimum_order_value",
|
||||
"fieldtype": "Float",
|
||||
"label": "Minimum Order Value",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "validity_limits_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Validity & Limits"
|
||||
},
|
||||
{
|
||||
"description": "Coupon active from this date (leave empty for immediate)",
|
||||
"fieldname": "valid_from",
|
||||
"fieldtype": "Date",
|
||||
"label": "Valid From"
|
||||
},
|
||||
{
|
||||
"description": "Coupon expires after this date (leave empty for no expiry)",
|
||||
"fieldname": "valid_till",
|
||||
"fieldtype": "Date",
|
||||
"label": "Valid Till"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_elot",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "0 = unlimited",
|
||||
"fieldname": "max_usage_per_user",
|
||||
"fieldtype": "Int",
|
||||
"label": "Max Usage Per User",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "usage_statistics_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Usage Statistics"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_zogd",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "applies_to",
|
||||
"fieldtype": "Select",
|
||||
"label": "Applies To",
|
||||
"options": "\nEvent\nEvent Category",
|
||||
"read_only_depends_on": "eval:doc.coupon_type == 'Free Tickets'"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-12 10:38:02.737227",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Pohodex Event Manager Coupon Code",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Event Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class BuzzCouponCode(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from event_manager.ticketing.doctype.coupon_free_add_on.coupon_free_add_on import CouponFreeAddon
|
||||
|
||||
applies_to: DF.Literal["", "Event", "Event Category"]
|
||||
code: DF.Data | None
|
||||
coupon_type: DF.Literal["Free Tickets", "Discount"]
|
||||
discount_type: DF.Literal["Percentage", "Flat Amount"]
|
||||
discount_value: DF.Float
|
||||
event: DF.Link | None
|
||||
event_category: DF.Link | None
|
||||
free_add_ons: DF.Table[CouponFreeAddon]
|
||||
is_active: DF.Check
|
||||
max_usage_count: DF.Int
|
||||
max_usage_per_user: DF.Int
|
||||
maximum_discount_amount: DF.Float
|
||||
minimum_order_value: DF.Float
|
||||
number_of_free_tickets: DF.Int
|
||||
ticket_type: DF.Link | None
|
||||
valid_from: DF.Date | None
|
||||
valid_till: DF.Date | None
|
||||
# end: auto-generated types
|
||||
|
||||
def autoname(self):
|
||||
if not self.code:
|
||||
self.code = frappe.generate_hash(length=8).upper()
|
||||
|
||||
def validate(self):
|
||||
self.validate_discount_value()
|
||||
self.validate_scope()
|
||||
self.validate_free_tickets_event()
|
||||
self.validate_validity_dates()
|
||||
|
||||
def validate_validity_dates(self):
|
||||
if self.valid_from and self.valid_till:
|
||||
if self.valid_from > self.valid_till:
|
||||
frappe.throw(_("Valid From cannot be after Valid Till"))
|
||||
|
||||
def validate_discount_value(self):
|
||||
if self.coupon_type == "Discount":
|
||||
if self.discount_value <= 0:
|
||||
frappe.throw(_("Discount value must be greater than 0"))
|
||||
if self.discount_type == "Percentage" and self.discount_value > 100:
|
||||
frappe.throw(_("Percentage discount cannot exceed 100%"))
|
||||
|
||||
def validate_scope(self):
|
||||
if self.applies_to == "Event":
|
||||
self.event_category = None
|
||||
elif self.applies_to == "Event Category":
|
||||
self.event = None
|
||||
else:
|
||||
self.event = None
|
||||
self.event_category = None
|
||||
|
||||
def validate_free_tickets_event(self):
|
||||
if self.coupon_type == "Free Tickets":
|
||||
if self.applies_to != "Event":
|
||||
frappe.throw(_("Free Tickets coupon must be restricted to an Event"))
|
||||
if not self.event:
|
||||
frappe.throw(_("Event is required for Free Tickets coupon"))
|
||||
if not self.ticket_type:
|
||||
frappe.throw(_("Ticket Type is required for Free Tickets coupon"))
|
||||
if self.number_of_free_tickets <= 0:
|
||||
frappe.throw(_("Number of free tickets must be greater than 0"))
|
||||
|
||||
def is_valid_for_event(self, event_name):
|
||||
if not self.is_active:
|
||||
return False, _("Coupon is not active")
|
||||
|
||||
is_valid, msg = self.is_within_validity_period()
|
||||
if not is_valid:
|
||||
return False, msg
|
||||
|
||||
if not self.applies_to:
|
||||
return True, ""
|
||||
|
||||
if self.applies_to == "Event":
|
||||
if str(self.event) != str(event_name):
|
||||
return False, _("Coupon is not valid for this event")
|
||||
return True, ""
|
||||
|
||||
if self.applies_to == "Event Category":
|
||||
event_category = frappe.get_cached_value("Pohodex Event Manager Event", event_name, "category")
|
||||
if not event_category or str(event_category) != str(self.event_category):
|
||||
return False, _("Coupon is not valid for this event category")
|
||||
return True, ""
|
||||
|
||||
def is_usage_available(self):
|
||||
if self.max_usage_count > 0:
|
||||
if self.times_used >= self.max_usage_count:
|
||||
return False, _("Coupon usage limit reached")
|
||||
return True, ""
|
||||
|
||||
def is_min_order_met(self, order_amount):
|
||||
if self.minimum_order_value > 0:
|
||||
if order_amount < self.minimum_order_value:
|
||||
gap = self.minimum_order_value - order_amount
|
||||
return False, _("Add {0} more to use this coupon (min order {1})").format(
|
||||
gap, self.minimum_order_value
|
||||
)
|
||||
return True, ""
|
||||
|
||||
def is_within_validity_period(self):
|
||||
today = frappe.utils.getdate()
|
||||
|
||||
if self.valid_from and today < frappe.utils.getdate(self.valid_from):
|
||||
return False, _("Coupon is not yet active (starts {0})").format(self.valid_from)
|
||||
|
||||
if self.valid_till and today > frappe.utils.getdate(self.valid_till):
|
||||
return False, _("Coupon expired on {0}").format(self.valid_till)
|
||||
|
||||
return True, ""
|
||||
|
||||
def is_user_limit_reached(self, user=None):
|
||||
if not self.max_usage_per_user:
|
||||
return False, ""
|
||||
|
||||
user = user or frappe.session.user
|
||||
user_usage = frappe.db.count(
|
||||
"Event Booking", {"coupon_code": self.name, "user": user, "docstatus": 1}
|
||||
)
|
||||
|
||||
if user_usage >= self.max_usage_per_user:
|
||||
return True, _("You have reached the maximum usage limit for this coupon")
|
||||
|
||||
return False, ""
|
||||
|
||||
@property
|
||||
def times_used(self):
|
||||
return frappe.db.count("Event Booking", {"coupon_code": self.name, "docstatus": 1})
|
||||
|
||||
@property
|
||||
def free_tickets_claimed(self):
|
||||
"""Calculate total attendees from all submitted bookings using this coupon"""
|
||||
from frappe.query_builder.functions import Count
|
||||
|
||||
EventBooking = frappe.qb.DocType("Event Booking")
|
||||
EventBookingAttendee = frappe.qb.DocType("Event Booking Attendee")
|
||||
|
||||
count = (
|
||||
frappe.qb.from_(EventBookingAttendee)
|
||||
.join(EventBooking)
|
||||
.on(EventBooking.name == EventBookingAttendee.parent)
|
||||
.where(EventBooking.coupon_code == self.name)
|
||||
.where(EventBooking.docstatus == 1)
|
||||
.where(EventBookingAttendee.ticket_type == self.ticket_type)
|
||||
.select(Count(EventBookingAttendee.name))
|
||||
).run()[0][0]
|
||||
|
||||
return count or 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-12-30 19:46:58.116937",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"add_on"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "add_on",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Add-on",
|
||||
"options": "Ticket Add-on",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-30 19:47:35.920567",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Coupon Free Add-on",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CouponFreeAddon(Document):
|
||||
pass
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2025, BWH Studios and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Event Booking", {
|
||||
refresh(frm) {
|
||||
frm.set_query("ticket_type", "attendees", (doc, cdt, cdn) => {
|
||||
return {
|
||||
filters: {
|
||||
event: doc.event,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Add Approve/Reject buttons for pending bookings
|
||||
if (frappe.user.has_role("Event Manager") && frm.doc.status === "Approval Pending") {
|
||||
frm.add_custom_button(__("Approve and Submit"), function () {
|
||||
frappe.confirm("Are you sure you want to approve this booking?", function () {
|
||||
frm.call("approve_booking").then(() => {
|
||||
frm.refresh();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
frm.add_custom_button(__("Reject"), function () {
|
||||
frappe.confirm("Are you sure you want to reject this booking?", function () {
|
||||
frm.call("reject_booking").then(() => {
|
||||
frm.refresh();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2025-07-22 19:39:32.418375",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"event",
|
||||
"column_break_cjxu",
|
||||
"user",
|
||||
"naming_series",
|
||||
"section_break_status",
|
||||
"payment_status",
|
||||
"column_break_status",
|
||||
"status",
|
||||
"payment_method_section",
|
||||
"payment_method",
|
||||
"column_break_payment_method",
|
||||
"offline_payment_method",
|
||||
"section_break_xvkp",
|
||||
"attendees",
|
||||
"section_break_suav",
|
||||
"additional_fields",
|
||||
"section_break_tsys",
|
||||
"net_amount",
|
||||
"tax_percentage",
|
||||
"tax_label",
|
||||
"tax_amount",
|
||||
"column_break_naeh",
|
||||
"total_amount",
|
||||
"currency",
|
||||
"coupon_code",
|
||||
"discount_amount",
|
||||
"billing_details_section",
|
||||
"invoice_requested",
|
||||
"tax_id",
|
||||
"column_break_billing",
|
||||
"billing_address",
|
||||
"section_break_sdfp",
|
||||
"amended_from",
|
||||
"marketing_tab",
|
||||
"utm_parameters"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "amended_from",
|
||||
"fieldtype": "Link",
|
||||
"label": "Amended From",
|
||||
"no_copy": 1,
|
||||
"options": "Event Booking",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_sdfp",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_xvkp",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "attendees",
|
||||
"fieldtype": "Table",
|
||||
"label": "Attendees",
|
||||
"options": "Event Booking Attendee",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "event",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Event",
|
||||
"options": "Pohodex Event Manager Event",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_cjxu",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"label": "User",
|
||||
"options": "User",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_tsys",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "total_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total Amount",
|
||||
"non_negative": 1,
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_naeh",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "INR",
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
"options": "Currency",
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "net_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "tax_percentage",
|
||||
"fieldtype": "Percent",
|
||||
"label": "Tax Percentage",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "tax_label",
|
||||
"fieldtype": "Data",
|
||||
"label": "Tax Label",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "tax_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Tax Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_suav",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "additional_fields",
|
||||
"fieldtype": "Table",
|
||||
"label": "Additional Fields",
|
||||
"options": "Additional Field"
|
||||
},
|
||||
{
|
||||
"fieldname": "naming_series",
|
||||
"fieldtype": "Select",
|
||||
"label": "Naming Series",
|
||||
"options": "B.###"
|
||||
},
|
||||
{
|
||||
"fieldname": "marketing_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Marketing"
|
||||
},
|
||||
{
|
||||
"fieldname": "utm_parameters",
|
||||
"fieldtype": "Table",
|
||||
"label": "UTM Parameters",
|
||||
"options": "UTM Parameter",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "coupon_code",
|
||||
"fieldtype": "Link",
|
||||
"label": "Coupon Code",
|
||||
"options": "Pohodex Event Manager Coupon Code"
|
||||
},
|
||||
{
|
||||
"fieldname": "discount_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Discount Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_details_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Billing Details"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.invoice_requested==1",
|
||||
"fieldname": "tax_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Tax ID"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_billing",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.invoice_requested==1",
|
||||
"fieldname": "billing_address",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Billing Address",
|
||||
"mandatory_depends_on": "eval:doc.invoice_requested==1"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_status",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Status"
|
||||
},
|
||||
{
|
||||
"default": "Unpaid",
|
||||
"fieldname": "payment_status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Payment Status",
|
||||
"options": "Unpaid\nPaid\nVerification Pending",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_status",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "Approval Pending",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"options": "Confirmed\nApproval Pending\nApproved\nRejected",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_method_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Payment Method"
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_method",
|
||||
"fieldtype": "Data",
|
||||
"label": "Payment Method",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_payment_method",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.payment_method === 'Offline'",
|
||||
"fieldname": "offline_payment_method",
|
||||
"fieldtype": "Data",
|
||||
"label": "Offline Payment Method",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "invoice_requested",
|
||||
"fieldtype": "Check",
|
||||
"label": "Invoice Requested"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [
|
||||
{
|
||||
"link_doctype": "Event Ticket",
|
||||
"link_fieldname": "booking"
|
||||
},
|
||||
{
|
||||
"link_doctype": "Event Payment",
|
||||
"link_fieldname": "reference_docname"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-03 07:18:23.131237",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Event Booking",
|
||||
"naming_rule": "By \"Naming Series\" field",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"cancel": 1,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"select": 1,
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"cancel": 1,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Event Manager",
|
||||
"select": 1,
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Pohodex Event Manager User",
|
||||
"select": 1,
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "event,total_amount,currency",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "user"
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
from event_manager.api import OFFLINE_PAYMENT_METHOD
|
||||
from event_manager.payments import mark_payment_as_received
|
||||
|
||||
|
||||
class EventBooking(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from event_manager.events.doctype.utm_parameter.utm_parameter import UTMParameter
|
||||
from event_manager.ticketing.doctype.additional_field.additional_field import AdditionalField
|
||||
from event_manager.ticketing.doctype.event_booking_attendee.event_booking_attendee import EventBookingAttendee
|
||||
|
||||
additional_fields: DF.Table[AdditionalField]
|
||||
amended_from: DF.Link | None
|
||||
attendees: DF.Table[EventBookingAttendee]
|
||||
billing_address: DF.SmallText | None
|
||||
coupon_code: DF.Link | None
|
||||
currency: DF.Link
|
||||
discount_amount: DF.Currency
|
||||
event: DF.Link
|
||||
invoice_requested: DF.Check
|
||||
naming_series: DF.Literal["B.###"]
|
||||
net_amount: DF.Currency
|
||||
offline_payment_method: DF.Data | None
|
||||
payment_method: DF.Data | None
|
||||
payment_status: DF.Literal["Unpaid", "Paid", "Verification Pending"]
|
||||
status: DF.Literal["Confirmed", "Approval Pending", "Approved", "Rejected"]
|
||||
tax_amount: DF.Currency
|
||||
tax_id: DF.Data | None
|
||||
tax_label: DF.Data | None
|
||||
tax_percentage: DF.Percent
|
||||
total_amount: DF.Currency
|
||||
user: DF.Link
|
||||
utm_parameters: DF.Table[UTMParameter]
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.validate_ticket_availability()
|
||||
self.fetch_amounts_from_ticket_types()
|
||||
self.set_currency()
|
||||
self.set_total()
|
||||
self.apply_coupon_if_applicable()
|
||||
self.apply_taxes_if_applicable()
|
||||
|
||||
def before_submit(self):
|
||||
"""Set status before submit based on payment method."""
|
||||
# Skip if already approved (submission triggered by approve_booking)
|
||||
if self.status == "Approved":
|
||||
return
|
||||
|
||||
if self.total_amount == 0:
|
||||
self.payment_status = "Paid"
|
||||
self.status = "Confirmed"
|
||||
return
|
||||
|
||||
if self.payment_method == OFFLINE_PAYMENT_METHOD:
|
||||
frappe.throw(
|
||||
_(
|
||||
"This booking requires offline payment verification. Please use the Approve or Reject button instead."
|
||||
)
|
||||
)
|
||||
elif self.payment_status != "Paid":
|
||||
self.payment_status = "Unpaid"
|
||||
self.status = "Approval Pending"
|
||||
|
||||
def set_currency(self):
|
||||
self.currency = self.attendees[0].currency
|
||||
|
||||
def set_total(self):
|
||||
self.net_amount = 0
|
||||
for attendee in self.attendees:
|
||||
self.net_amount += attendee.amount
|
||||
if attendee.add_ons:
|
||||
attendee.add_on_total = attendee.get_add_on_total()
|
||||
attendee.number_of_add_ons = attendee.get_number_of_add_ons()
|
||||
self.net_amount += attendee.add_on_total
|
||||
self.total_amount = self.net_amount
|
||||
|
||||
def apply_taxes_if_applicable(self):
|
||||
"""Apply tax based on event-level tax configuration."""
|
||||
self.tax_percentage = 0
|
||||
self.tax_amount = 0
|
||||
self.tax_label = None
|
||||
|
||||
event = frappe.get_cached_doc("Pohodex Event Manager Event", self.event)
|
||||
if not event.apply_tax:
|
||||
return
|
||||
|
||||
self.tax_label = event.tax_label or "Tax"
|
||||
self.tax_percentage = event.tax_percentage or 0
|
||||
|
||||
if self.tax_percentage > 0:
|
||||
if event.tax_inclusive:
|
||||
# Tax is included in the price — back-calculate the tax component
|
||||
self.tax_amount = round(
|
||||
self.total_amount * self.tax_percentage / (100 + self.tax_percentage), 2
|
||||
)
|
||||
else:
|
||||
# Tax is added on top of the price
|
||||
self.tax_amount = self.total_amount * (self.tax_percentage / 100)
|
||||
self.total_amount += self.tax_amount
|
||||
|
||||
def validate_ticket_availability(self):
|
||||
num_tickets_by_type = {}
|
||||
for attendee in self.attendees:
|
||||
if attendee.ticket_type not in num_tickets_by_type:
|
||||
num_tickets_by_type[attendee.ticket_type] = 0
|
||||
num_tickets_by_type[attendee.ticket_type] += 1
|
||||
|
||||
for ticket_type, num_tickets in num_tickets_by_type.items():
|
||||
ticket_type_doc = frappe.get_cached_doc("Event Ticket Type", ticket_type)
|
||||
if not ticket_type_doc.is_published:
|
||||
frappe.throw(frappe._(f"{ticket_type_doc.title} tickets no longer available!"))
|
||||
|
||||
if not ticket_type_doc.are_tickets_available(num_tickets):
|
||||
frappe.throw(
|
||||
frappe._(
|
||||
f"Only {ticket_type_doc.remaining_tickets} tickets available for {ticket_type_doc.title}, you are trying to book {num_tickets}!"
|
||||
)
|
||||
)
|
||||
|
||||
def fetch_amounts_from_ticket_types(self):
|
||||
for attendee in self.attendees:
|
||||
price, currency = frappe.get_cached_value(
|
||||
"Event Ticket Type", attendee.ticket_type, ["price", "currency"]
|
||||
)
|
||||
# Always set price from ticket type - coupon will discount later
|
||||
attendee.amount = price
|
||||
if not attendee.currency:
|
||||
attendee.currency = currency
|
||||
|
||||
def on_submit(self):
|
||||
self.validate_coupon_availability()
|
||||
self.generate_tickets()
|
||||
|
||||
def validate_coupon_availability(self):
|
||||
"""Re-validate coupon with lock to prevent race condition."""
|
||||
if not self.coupon_code:
|
||||
return
|
||||
|
||||
# Lock coupon row to prevent concurrent over-allocation
|
||||
coupon = frappe.get_doc("Pohodex Event Manager Coupon Code", self.coupon_code, for_update=True)
|
||||
|
||||
if coupon.coupon_type == "Free Tickets":
|
||||
# Count claimed tickets excluding current booking (since it's already docstatus=1 during on_submit)
|
||||
claimed = self.get_free_tickets_claimed_excluding_self(coupon)
|
||||
remaining = coupon.number_of_free_tickets - claimed
|
||||
|
||||
# Count only attendees that were actually discounted (amount == 0)
|
||||
# This supports partial allocation where user books more tickets than remaining free
|
||||
coupon_ticket_type = str(coupon.ticket_type) if coupon.ticket_type else ""
|
||||
tickets_discounted = len(
|
||||
[a for a in self.attendees if str(a.ticket_type) == coupon_ticket_type and a.amount == 0]
|
||||
)
|
||||
|
||||
if remaining < tickets_discounted:
|
||||
frappe.throw(_("Only {0} free tickets remaining").format(remaining))
|
||||
|
||||
def get_free_tickets_claimed_excluding_self(self, coupon):
|
||||
"""Get free tickets claimed excluding current booking."""
|
||||
from frappe.query_builder.functions import Count
|
||||
|
||||
EventBooking = frappe.qb.DocType("Event Booking")
|
||||
EventBookingAttendee = frappe.qb.DocType("Event Booking Attendee")
|
||||
|
||||
count = (
|
||||
frappe.qb.from_(EventBookingAttendee)
|
||||
.join(EventBooking)
|
||||
.on(EventBooking.name == EventBookingAttendee.parent)
|
||||
.where(EventBooking.coupon_code == coupon.name)
|
||||
.where(EventBooking.docstatus == 1)
|
||||
.where(EventBooking.name != self.name)
|
||||
.where(EventBookingAttendee.ticket_type == coupon.ticket_type)
|
||||
.select(Count(EventBookingAttendee.name))
|
||||
).run()[0][0]
|
||||
|
||||
return count or 0
|
||||
|
||||
def generate_tickets(self):
|
||||
for attendee in self.attendees:
|
||||
ticket = frappe.new_doc("Event Ticket")
|
||||
ticket.event = self.event
|
||||
ticket.booking = self.name
|
||||
ticket.ticket_type = attendee.ticket_type
|
||||
ticket.first_name = attendee.first_name
|
||||
ticket.last_name = attendee.last_name
|
||||
ticket.attendee_email = attendee.email
|
||||
|
||||
if attendee.add_ons:
|
||||
add_ons_list = frappe.get_cached_doc("Attendee Ticket Add-on", attendee.add_ons).add_ons
|
||||
ticket.add_ons = add_ons_list
|
||||
|
||||
# Add custom fields from attendee to ticket
|
||||
if attendee.custom_fields:
|
||||
custom_fields_data = attendee.custom_fields
|
||||
if isinstance(custom_fields_data, str):
|
||||
try:
|
||||
custom_fields_data = json.loads(custom_fields_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
custom_fields_data = {}
|
||||
|
||||
# Get custom field definitions for this event to get proper labels and types
|
||||
custom_field_defs = frappe.db.get_all(
|
||||
"Pohodex Event Manager Custom Field",
|
||||
filters={"event": self.event, "enabled": 1, "applied_to": "Ticket"},
|
||||
fields=["fieldname", "label", "fieldtype"],
|
||||
)
|
||||
custom_field_map = {cf["fieldname"]: cf for cf in custom_field_defs}
|
||||
|
||||
for field_name, field_value in custom_fields_data.items():
|
||||
if field_value and field_name in custom_field_map:
|
||||
field_def = custom_field_map[field_name]
|
||||
ticket.append(
|
||||
"additional_fields",
|
||||
{
|
||||
"fieldname": field_name,
|
||||
"value": str(field_value),
|
||||
"label": field_def["label"],
|
||||
"fieldtype": field_def["fieldtype"],
|
||||
},
|
||||
)
|
||||
|
||||
ticket.flags.ignore_permissions = 1
|
||||
ticket.insert().submit()
|
||||
|
||||
def on_payment_authorized(self, payment_status: str):
|
||||
if payment_status in ("Authorized", "Completed"):
|
||||
# payment success, submit the booking
|
||||
self.payment_status = "Paid"
|
||||
self.status = "Confirmed"
|
||||
self.update_payment_record()
|
||||
|
||||
def update_payment_record(self):
|
||||
try:
|
||||
mark_payment_as_received(self.doctype, self.name)
|
||||
self.flags.ignore_permissions = 1
|
||||
self.submit()
|
||||
except Exception:
|
||||
frappe.log_error(frappe.get_traceback(), _("Booking Failed"))
|
||||
frappe.throw(frappe._("Booking Failed! Please contact support."))
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = ["Ticket Cancellation Request"]
|
||||
self.cancel_all_tickets()
|
||||
|
||||
def cancel_all_tickets(self):
|
||||
tickets = frappe.db.get_all("Event Ticket", filters={"booking": self.name}, pluck="name")
|
||||
for ticket in tickets:
|
||||
frappe.get_cached_doc("Event Ticket", ticket).cancel()
|
||||
|
||||
@frappe.whitelist()
|
||||
def approve_booking(self):
|
||||
"""Approve the booking and submit it to generate tickets."""
|
||||
frappe.only_for("Event Manager")
|
||||
|
||||
self.status = "Approved"
|
||||
if self.payment_status == "Verification Pending":
|
||||
self.payment_status = "Paid"
|
||||
|
||||
self.flags.ignore_permissions = True
|
||||
self.submit()
|
||||
frappe.msgprint(_("Booking has been approved!"))
|
||||
|
||||
@frappe.whitelist()
|
||||
def reject_booking(self):
|
||||
"""Reject and discard the booking."""
|
||||
frappe.only_for("Event Manager")
|
||||
|
||||
self.flags.ignore_permissions = True
|
||||
self.discard()
|
||||
self.db_set("status", "Rejected")
|
||||
frappe.msgprint(_("Booking has been rejected!"))
|
||||
|
||||
def apply_coupon_if_applicable(self):
|
||||
self.discount_amount = 0
|
||||
|
||||
if not self.coupon_code:
|
||||
return
|
||||
|
||||
coupon = frappe.get_cached_doc("Pohodex Event Manager Coupon Code", self.coupon_code)
|
||||
|
||||
is_valid, error_msg = coupon.is_valid_for_event(self.event)
|
||||
if not is_valid:
|
||||
frappe.throw(error_msg)
|
||||
|
||||
is_available, error_msg = coupon.is_usage_available()
|
||||
if not is_available:
|
||||
frappe.throw(error_msg)
|
||||
|
||||
is_limited, error_msg = coupon.is_user_limit_reached(user=self.user)
|
||||
if is_limited:
|
||||
frappe.throw(error_msg)
|
||||
|
||||
if coupon.coupon_type == "Discount":
|
||||
is_met, error_msg = coupon.is_min_order_met(self.net_amount)
|
||||
if not is_met:
|
||||
frappe.throw(error_msg)
|
||||
if coupon.discount_type == "Percentage":
|
||||
calculated_discount = self.net_amount * (coupon.discount_value / 100)
|
||||
if coupon.maximum_discount_amount > 0:
|
||||
self.discount_amount = min(calculated_discount, coupon.maximum_discount_amount)
|
||||
else:
|
||||
self.discount_amount = calculated_discount
|
||||
else:
|
||||
self.discount_amount = min(coupon.discount_value, self.net_amount)
|
||||
|
||||
self.total_amount = self.net_amount - self.discount_amount
|
||||
|
||||
# Free Tickets - only discount attendees with matching ticket type
|
||||
elif coupon.coupon_type == "Free Tickets":
|
||||
remaining = coupon.number_of_free_tickets - coupon.free_tickets_claimed
|
||||
free_add_on_names = [row.add_on for row in coupon.free_add_ons]
|
||||
|
||||
# Only discount attendees with matching ticket type
|
||||
# Use str() to handle int/string type mismatch in document names
|
||||
coupon_ticket_type = str(coupon.ticket_type) if coupon.ticket_type else ""
|
||||
discounted = 0
|
||||
for attendee in self.attendees:
|
||||
if discounted >= remaining:
|
||||
break
|
||||
attendee_ticket_type = str(attendee.ticket_type) if attendee.ticket_type else ""
|
||||
if attendee_ticket_type != coupon_ticket_type:
|
||||
continue
|
||||
|
||||
self.discount_amount += attendee.amount
|
||||
attendee.amount = 0
|
||||
discounted += 1
|
||||
|
||||
# Discount free add-ons for this attendee
|
||||
if attendee.add_ons and free_add_on_names:
|
||||
add_on_doc = frappe.get_cached_doc("Attendee Ticket Add-on", attendee.add_ons)
|
||||
for add_on_row in add_on_doc.add_ons:
|
||||
if add_on_row.add_on in free_add_on_names:
|
||||
self.discount_amount += add_on_row.price
|
||||
|
||||
if discounted == 0:
|
||||
frappe.throw(_("No attendees with eligible ticket type for this coupon"))
|
||||
|
||||
self.total_amount = self.net_amount - self.discount_amount
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
# Copyright (c) 2025, BWH Studios and Contributors
|
||||
# See license.txt
|
||||
|
||||
import uuid
|
||||
from base64 import b32encode
|
||||
|
||||
import frappe
|
||||
import pyotp
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
from event_manager.api import process_booking
|
||||
|
||||
|
||||
class TestGuestBooking(IntegrationTestCase):
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
|
||||
self.event_name = str(self.test_event.name)
|
||||
self.original_allow_guest = self.test_event.allow_guest_booking
|
||||
self.original_verification = self.test_event.guest_verification_method
|
||||
self.test_event.allow_guest_booking = True
|
||||
self.test_event.guest_verification_method = "None"
|
||||
self.test_event.is_published = True
|
||||
self.test_event.save()
|
||||
self.ticket_type = str(self._get_or_create_free_ticket_type())
|
||||
|
||||
def tearDown(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.test_event.reload()
|
||||
self.test_event.allow_guest_booking = self.original_allow_guest
|
||||
self.test_event.guest_verification_method = self.original_verification
|
||||
self.test_event.is_published = False
|
||||
self.test_event.save()
|
||||
|
||||
# --- helpers ---
|
||||
|
||||
def _generate_test_email(self):
|
||||
return f"testguest-{uuid.uuid4().hex[:8]}@example.com"
|
||||
|
||||
def _cleanup_test_user(self, email):
|
||||
frappe.set_user("Administrator")
|
||||
if frappe.db.exists("User", email):
|
||||
frappe.delete_doc("User", email, force=True)
|
||||
|
||||
def _get_or_create_free_ticket_type(self):
|
||||
existing = frappe.db.get_value(
|
||||
"Event Ticket Type",
|
||||
{"event": self.test_event.name, "price": 0},
|
||||
"name",
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Ticket Type",
|
||||
"event": self.test_event.name,
|
||||
"title": "Free (Test)",
|
||||
"price": 0,
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
def _make_attendees(self, email):
|
||||
return [{"ticket_type": self.ticket_type, "first_name": "Test", "last_name": "Guest", "email": email}]
|
||||
|
||||
# --- tests ---
|
||||
|
||||
def test_guest_booking_without_otp(self):
|
||||
"""Full happy path: guest books with verification='None', booking + user created."""
|
||||
email = self._generate_test_email()
|
||||
try:
|
||||
frappe.set_user("Guest")
|
||||
result = process_booking(
|
||||
attendees=self._make_attendees(email),
|
||||
event=self.event_name,
|
||||
guest_email=email,
|
||||
guest_full_name="Test Guest",
|
||||
)
|
||||
self.assertIn("booking_name", result)
|
||||
self.assertTrue(frappe.db.exists("Event Booking", result["booking_name"]))
|
||||
self.assertTrue(frappe.db.exists("User", email))
|
||||
self.assertIn("Pohodex Event Manager User", frappe.get_roles(email))
|
||||
finally:
|
||||
self._cleanup_test_user(email)
|
||||
|
||||
def test_guest_booking_with_otp(self):
|
||||
"""OTP happy path: cache OTP, pass it in, booking succeeds, OTP cache cleared."""
|
||||
email = self._generate_test_email()
|
||||
self.test_event.guest_verification_method = "Email OTP"
|
||||
self.test_event.save()
|
||||
try:
|
||||
# Simulate OTP generation (same as send_guest_booking_otp)
|
||||
otp_secret = b32encode(b"TESTSECRET").decode("utf-8")
|
||||
otp_code = pyotp.HOTP(otp_secret).at(0)
|
||||
frappe.cache.set_value(
|
||||
f"guest_booking_otp:email:{email.lower().strip()}", otp_secret, expires_in_sec=600
|
||||
)
|
||||
|
||||
frappe.set_user("Guest")
|
||||
result = process_booking(
|
||||
attendees=self._make_attendees(email),
|
||||
event=self.event_name,
|
||||
guest_email=email,
|
||||
guest_full_name="Test Guest",
|
||||
otp=str(otp_code),
|
||||
)
|
||||
self.assertIn("booking_name", result)
|
||||
# OTP cache should be cleared after successful verification
|
||||
self.assertIsNone(frappe.cache.get_value(f"guest_booking_otp:email:{email.lower().strip()}"))
|
||||
finally:
|
||||
self._cleanup_test_user(email)
|
||||
|
||||
def test_guest_booking_rejected_when_disabled(self):
|
||||
"""Security gate: allow_guest_booking=False raises AuthenticationError."""
|
||||
self.test_event.allow_guest_booking = False
|
||||
self.test_event.save()
|
||||
|
||||
frappe.set_user("Guest")
|
||||
with self.assertRaises(frappe.AuthenticationError):
|
||||
process_booking(
|
||||
attendees=self._make_attendees("nobody@example.com"),
|
||||
event=self.event_name,
|
||||
guest_email="nobody@example.com",
|
||||
guest_full_name="Nobody",
|
||||
)
|
||||
|
||||
def test_invalid_otp_rejected(self):
|
||||
"""Wrong OTP code raises ValidationError."""
|
||||
email = self._generate_test_email()
|
||||
self.test_event.guest_verification_method = "Email OTP"
|
||||
self.test_event.save()
|
||||
|
||||
otp_secret = b32encode(b"TESTSECRET").decode("utf-8")
|
||||
frappe.cache.set_value(
|
||||
f"guest_booking_otp:email:{email.lower().strip()}", otp_secret, expires_in_sec=600
|
||||
)
|
||||
|
||||
frappe.set_user("Guest")
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
process_booking(
|
||||
attendees=self._make_attendees(email),
|
||||
event=self.event_name,
|
||||
guest_email=email,
|
||||
guest_full_name="Test Guest",
|
||||
otp="000000",
|
||||
)
|
||||
|
||||
def test_guest_booking_requires_email(self):
|
||||
"""Missing email raises ValidationError."""
|
||||
frappe.set_user("Guest")
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
process_booking(
|
||||
attendees=[
|
||||
{
|
||||
"ticket_type": self.ticket_type,
|
||||
"first_name": "Test",
|
||||
"last_name": "Guest",
|
||||
"email": "t@e.com",
|
||||
}
|
||||
],
|
||||
event=self.event_name,
|
||||
guest_email="",
|
||||
guest_full_name="Test Guest",
|
||||
)
|
||||
|
||||
def test_brute_force_lockout(self):
|
||||
"""Repeated wrong OTPs locks out subsequent attempts.
|
||||
|
||||
LoginAttemptTracker(max_consecutive_login_attempts=5) uses strict >
|
||||
comparison, so lockout triggers after count exceeds the threshold.
|
||||
"""
|
||||
email = self._generate_test_email()
|
||||
self.test_event.guest_verification_method = "Email OTP"
|
||||
self.test_event.save()
|
||||
|
||||
otp_secret = b32encode(b"TESTSECRET").decode("utf-8")
|
||||
cache_key = f"guest_booking_otp:email:{email.lower().strip()}"
|
||||
|
||||
frappe.set_user("Guest")
|
||||
|
||||
# Exhaust allowed attempts (Frappe's tracker uses > comparison,
|
||||
# so we need max_consecutive_login_attempts + 1 failures to trigger lockout)
|
||||
for _ in range(6):
|
||||
frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600)
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
process_booking(
|
||||
attendees=self._make_attendees(email),
|
||||
event=self.event_name,
|
||||
guest_email=email,
|
||||
guest_full_name="Test Guest",
|
||||
otp="000000",
|
||||
)
|
||||
|
||||
# Next attempt should hit "Too many failed attempts"
|
||||
frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600)
|
||||
with self.assertRaises(frappe.ValidationError) as ctx:
|
||||
process_booking(
|
||||
attendees=self._make_attendees(email),
|
||||
event=self.event_name,
|
||||
guest_email=email,
|
||||
guest_full_name="Test Guest",
|
||||
otp="000000",
|
||||
)
|
||||
self.assertIn("Too many failed attempts", str(ctx.exception))
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-07-22 19:41:54.837428",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"full_name",
|
||||
"ticket_type",
|
||||
"custom_fields",
|
||||
"column_break_xmfr",
|
||||
"email",
|
||||
"amount",
|
||||
"currency",
|
||||
"add_ons",
|
||||
"number_of_add_ons",
|
||||
"add_on_total"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "first_name",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "First Name",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "last_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Last Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "full_name",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Full Name",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_xmfr",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Email",
|
||||
"options": "Email",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "ticket_type",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Ticket Type",
|
||||
"options": "Event Ticket Type",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "INR",
|
||||
"fetch_from": "ticket_type.currency",
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
"options": "Currency",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "ticket_type.price",
|
||||
"fetch_if_empty": 1,
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Amount",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "add_ons",
|
||||
"fieldtype": "Link",
|
||||
"label": "Add Ons",
|
||||
"options": "Attendee Ticket Add-on"
|
||||
},
|
||||
{
|
||||
"fieldname": "add_on_total",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Add On Total",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "number_of_add_ons",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Number of Add Ons",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "custom_fields",
|
||||
"fieldtype": "JSON",
|
||||
"label": "Custom Fields"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-01 11:33:49.166876",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Event Booking Attendee",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class EventBookingAttendee(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
add_on_total: DF.Currency
|
||||
add_ons: DF.Link | None
|
||||
amount: DF.Currency
|
||||
currency: DF.Link
|
||||
custom_fields: DF.JSON | None
|
||||
email: DF.Data
|
||||
first_name: DF.Data
|
||||
full_name: DF.Data
|
||||
last_name: DF.Data | None
|
||||
number_of_add_ons: DF.Int
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
ticket_type: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
def before_validate(self):
|
||||
# Backward compat: split full_name into first/last if first_name not provided
|
||||
if not self.first_name and self.full_name:
|
||||
name_parts = self.full_name.strip().split(" ", 1)
|
||||
self.first_name = name_parts[0]
|
||||
if not self.last_name and len(name_parts) > 1:
|
||||
self.last_name = name_parts[1]
|
||||
|
||||
def before_save(self):
|
||||
self.full_name = f"{self.first_name or ''} {self.last_name or ''}".strip()
|
||||
|
||||
def get_add_on_total(self):
|
||||
if not self.add_ons:
|
||||
return 0
|
||||
doc = frappe.get_cached_doc("Attendee Ticket Add-on", self.add_ons)
|
||||
add_ons = doc.add_ons
|
||||
return sum(r.price for r in add_ons)
|
||||
|
||||
def get_number_of_add_ons(self):
|
||||
return len(frappe.get_cached_doc("Attendee Ticket Add-on", self.add_ons).add_ons)
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, BWH Studios and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Event Payment", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "autoincrement",
|
||||
"creation": "2025-07-31 19:51:13.433686",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"user",
|
||||
"reference_doctype",
|
||||
"reference_docname",
|
||||
"column_break_guir",
|
||||
"amount",
|
||||
"currency",
|
||||
"payment_received",
|
||||
"section_break_mcjj",
|
||||
"payment_gateway",
|
||||
"column_break_oauu",
|
||||
"payment_id",
|
||||
"order_id"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "payment_received",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Payment Received"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Reference DocType",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_guir",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_docname",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"label": "Reference Name",
|
||||
"options": "reference_doctype"
|
||||
},
|
||||
{
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Amount",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"default": "INR",
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
"options": "Currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "User",
|
||||
"options": "User",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_mcjj",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "order_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Order ID"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_oauu",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Payment ID"
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_gateway",
|
||||
"fieldtype": "Link",
|
||||
"label": "Payment Gateway",
|
||||
"options": "Payment Gateway"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-06 06:42:53.095763",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Event Payment",
|
||||
"naming_rule": "Autoincrement",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Event Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "user",
|
||||
"track_changes": 1,
|
||||
"track_seen": 1,
|
||||
"track_views": 1
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class EventPayment(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
amount: DF.Currency
|
||||
currency: DF.Link | None
|
||||
name: DF.Int | None
|
||||
order_id: DF.Data | None
|
||||
payment_gateway: DF.Link | None
|
||||
payment_id: DF.Data | None
|
||||
payment_received: DF.Check
|
||||
reference_docname: DF.DynamicLink | None
|
||||
reference_doctype: DF.Link | None
|
||||
user: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) 2025, BWH Studios and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class IntegrationTestEventPayment(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for EventPayment.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, BWH Studios and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Event Ticket", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "hash",
|
||||
"creation": "2025-07-22 19:49:30.385500",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"attendee_name",
|
||||
"event",
|
||||
"booking",
|
||||
"coupon_used",
|
||||
"column_break_sqmr",
|
||||
"attendee_email",
|
||||
"ticket_type",
|
||||
"qr_code",
|
||||
"section_break_ijdn",
|
||||
"additional_fields",
|
||||
"section_break_cgvb",
|
||||
"add_ons",
|
||||
"section_break_yzvi",
|
||||
"amended_from"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "first_name",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "First Name",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "last_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Last Name"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "attendee_name",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Attendee Name",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "ticket_type.event",
|
||||
"fieldname": "event",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Event",
|
||||
"options": "Pohodex Event Manager Event"
|
||||
},
|
||||
{
|
||||
"fieldname": "booking",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Booking",
|
||||
"options": "Event Booking"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_sqmr",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "ticket_type",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Ticket Type",
|
||||
"options": "Event Ticket Type",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "amended_from",
|
||||
"fieldtype": "Link",
|
||||
"label": "Amended From",
|
||||
"no_copy": 1,
|
||||
"options": "Event Ticket",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_yzvi",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "qr_code",
|
||||
"fieldtype": "Attach Image",
|
||||
"label": "QR Code",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "add_ons",
|
||||
"fieldtype": "Table",
|
||||
"label": "Add Ons",
|
||||
"options": "Ticket Add-on Value"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_cgvb",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "attendee_email",
|
||||
"fieldtype": "Data",
|
||||
"label": "Attendee Email",
|
||||
"options": "Email",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "coupon_used",
|
||||
"fieldtype": "Link",
|
||||
"label": "Coupon Used ",
|
||||
"options": "Bulk Ticket Coupon"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ijdn",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "additional_fields",
|
||||
"fieldtype": "Table",
|
||||
"label": "Additional Fields",
|
||||
"options": "Additional Field"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"image_field": "qr_code",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [
|
||||
{
|
||||
"link_doctype": "Event Check In",
|
||||
"link_fieldname": "ticket"
|
||||
}
|
||||
],
|
||||
"modified": "2025-12-30 13:25:40.498434",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Event Ticket",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"cancel": 1,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"cancel": 1,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Event Manager",
|
||||
"select": 1,
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Pohodex Event Manager User",
|
||||
"select": 1,
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "event,ticket_type",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "attendee_name"
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.core.api.user_invitation import invite_by_email
|
||||
from frappe.model.document import Document
|
||||
|
||||
from event_manager.utils import generate_ics_file, generate_qr_code_file, only_if_app_installed
|
||||
|
||||
|
||||
class EventTicket(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from event_manager.ticketing.doctype.additional_field.additional_field import AdditionalField
|
||||
from event_manager.ticketing.doctype.ticket_add_on_value.ticket_add_on_value import TicketAddonValue
|
||||
|
||||
add_ons: DF.Table[TicketAddonValue]
|
||||
additional_fields: DF.Table[AdditionalField]
|
||||
amended_from: DF.Link | None
|
||||
attendee_email: DF.Data
|
||||
attendee_name: DF.Data
|
||||
booking: DF.Link | None
|
||||
coupon_used: DF.Link | None
|
||||
event: DF.Link | None
|
||||
first_name: DF.Data
|
||||
last_name: DF.Data | None
|
||||
qr_code: DF.AttachImage | None
|
||||
ticket_type: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
def before_validate(self):
|
||||
# Backward compat: split attendee_name into first/last if first_name not provided
|
||||
if not self.first_name and self.attendee_name:
|
||||
name_parts = self.attendee_name.strip().split(" ", 1)
|
||||
self.first_name = name_parts[0]
|
||||
if not self.last_name and len(name_parts) > 1:
|
||||
self.last_name = name_parts[1]
|
||||
|
||||
def validate(self):
|
||||
self.attendee_name = f"{self.first_name or ''} {self.last_name or ''}".strip()
|
||||
|
||||
def before_submit(self):
|
||||
self.validate_coupon_usage()
|
||||
self.generate_qr_code()
|
||||
|
||||
def on_submit(self):
|
||||
try:
|
||||
self.send_ticket_email()
|
||||
except Exception as e:
|
||||
frappe.log_error("Error sending ticket email: " + str(e))
|
||||
|
||||
# TODO: bring back after we have templates
|
||||
# try:
|
||||
# self.send_user_invitation()
|
||||
# except Exception as e:
|
||||
# frappe.log_error("Error sending user invitation: " + str(e))
|
||||
self.create_zoom_registration_if_applicable()
|
||||
|
||||
@only_if_app_installed("zoom_integration")
|
||||
def create_zoom_registration_if_applicable(self):
|
||||
event_doc = frappe.get_cached_doc("Pohodex Event Manager Event", self.event)
|
||||
|
||||
if event_doc.zoom_webinar:
|
||||
doc = {
|
||||
"doctype": "Zoom Webinar Registration",
|
||||
"webinar": event_doc.zoom_webinar,
|
||||
"email": self.attendee_email,
|
||||
"first_name": self.first_name,
|
||||
"last_name": self.last_name or "-",
|
||||
}
|
||||
registration = frappe.get_doc(doc).insert(ignore_permissions=True)
|
||||
|
||||
try:
|
||||
registration.submit()
|
||||
# Store the registration reference on the ticket
|
||||
self.db_set("zoom_webinar_registration", registration.name)
|
||||
except Exception:
|
||||
frappe.log_error("Failed to create registration on Zoom")
|
||||
|
||||
def send_user_invitation(self):
|
||||
invite_by_email(
|
||||
emails=self.attendee_email,
|
||||
roles=["Pohodex Event Manager User"],
|
||||
redirect_to_path="/dashboard/account/tickets",
|
||||
app_name="event_manager",
|
||||
)
|
||||
|
||||
def send_ticket_email(self, now: bool = False):
|
||||
send_ticket_email = frappe.get_cached_value("Pohodex Event Manager Event", self.event, "send_ticket_email")
|
||||
|
||||
if not send_ticket_email:
|
||||
return
|
||||
|
||||
event_title, ticket_template, ticket_print_format, venue = frappe.get_cached_value(
|
||||
"Pohodex Event Manager Event", self.event, ["title", "ticket_email_template", "ticket_print_format", "venue"]
|
||||
)
|
||||
|
||||
# Fallback to global setting if event-level not set
|
||||
if not ticket_template:
|
||||
ticket_template = frappe.db.get_single_value("Pohodex Event Manager Settings", "default_ticket_email_template")
|
||||
|
||||
subject = frappe._("Your ticket to {0} 🎟️").format(event_title)
|
||||
event_doc = frappe.get_cached_doc("Pohodex Event Manager Event", self.event)
|
||||
args = {
|
||||
"doc": self,
|
||||
"event_doc": event_doc,
|
||||
"event_title": event_title,
|
||||
"venue": venue,
|
||||
}
|
||||
|
||||
if ticket_template:
|
||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||
|
||||
email_template = get_email_template(ticket_template, args)
|
||||
subject = email_template.get("subject")
|
||||
content = email_template.get("message")
|
||||
|
||||
attachments = []
|
||||
|
||||
if event_doc.attach_email_ticket:
|
||||
attachments.append(
|
||||
{
|
||||
"print_format_attachment": 1,
|
||||
"doctype": self.doctype,
|
||||
"name": self.name,
|
||||
"print_format": ticket_print_format or "Standard Ticket",
|
||||
}
|
||||
)
|
||||
|
||||
if event_doc.attach_calendar_invite:
|
||||
ics_content = generate_ics_file(event_doc, self.attendee_email)
|
||||
attachments.append(
|
||||
{
|
||||
"fname": f"{event_doc.title}.ics",
|
||||
"fcontent": ics_content,
|
||||
}
|
||||
)
|
||||
|
||||
frappe.sendmail(
|
||||
recipients=[self.attendee_email],
|
||||
subject=subject,
|
||||
content=content if ticket_template else None,
|
||||
template="ticket" if not ticket_template else None,
|
||||
args=args,
|
||||
reference_doctype=self.doctype,
|
||||
reference_name=self.name,
|
||||
now=now,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
def validate_coupon_usage(self):
|
||||
if not self.coupon_used:
|
||||
return
|
||||
|
||||
coupon = frappe.get_cached_doc("Bulk Ticket Coupon", self.coupon_used)
|
||||
if coupon.is_used_up():
|
||||
frappe.throw(frappe._("Coupon has been already used up maximum number of times!"))
|
||||
|
||||
def generate_qr_code(self):
|
||||
self.qr_code = generate_qr_code_file(
|
||||
doc=self,
|
||||
data=self.name,
|
||||
file_prefix="ticket-qr-code",
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = ["Event Booking", "Ticket Cancellation Request"]
|
||||
self.send_cancellation_email()
|
||||
|
||||
def send_cancellation_email(self):
|
||||
event_title = frappe.get_cached_value("Pohodex Event Manager Event", self.event, "title")
|
||||
frappe.sendmail(
|
||||
recipients=self.attendee_email,
|
||||
subject=f"Your ticket to {event_title} is cancelled.",
|
||||
message=f"Hi {self.attendee_name}, your ticket has been cancelled successfully. Sad to see you go.",
|
||||
header=[("Ticket Cancelled"), "red"],
|
||||
delayed=False,
|
||||
retry=2,
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
# Copyright (c) 2025, BWH Studios and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
from event_manager.utils import generate_qr_code_file, make_qr_image
|
||||
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = []
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = ["Bulk Ticket Coupon"]
|
||||
|
||||
|
||||
class TestEventTicketEmail(IntegrationTestCase):
|
||||
"""Tests for Event Ticket email sending with template fallback logic."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
|
||||
cls.test_event.ticket_email_template = None
|
||||
cls.test_event.save()
|
||||
|
||||
# Clear global settings
|
||||
settings = frappe.get_doc("Pohodex Event Manager Settings")
|
||||
settings.default_ticket_email_template = None
|
||||
settings.save()
|
||||
|
||||
def setUp(self):
|
||||
self.test_ticket_type = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Ticket Type",
|
||||
"event": self.test_event.name,
|
||||
"title": "Email Test Ticket",
|
||||
"price": 100,
|
||||
}
|
||||
).insert()
|
||||
|
||||
self.test_ticket = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Ticket",
|
||||
"event": self.test_event.name,
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
"attendee_name": "Test Attendee",
|
||||
"attendee_email": "test@example.com",
|
||||
}
|
||||
).insert()
|
||||
|
||||
def tearDown(self):
|
||||
frappe.delete_doc("Event Ticket", self.test_ticket.name, force=True)
|
||||
frappe.delete_doc("Event Ticket Type", self.test_ticket_type.name, force=True)
|
||||
|
||||
def _create_template(self, name, subject_prefix):
|
||||
if frappe.db.exists("Email Template", name):
|
||||
frappe.delete_doc("Email Template", name, force=True)
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Email Template",
|
||||
"name": name,
|
||||
"subject": f"{subject_prefix} - {{{{ event_title }}}}",
|
||||
"response": f"<p>{subject_prefix} content</p>",
|
||||
}
|
||||
).insert()
|
||||
|
||||
@patch("frappe.sendmail")
|
||||
def test_uses_event_template_when_set(self, mock_sendmail):
|
||||
template = self._create_template("Event Ticket Template", "EVENT")
|
||||
try:
|
||||
self.test_event.ticket_email_template = template.name
|
||||
self.test_event.save()
|
||||
|
||||
self.test_ticket.send_ticket_email(now=True)
|
||||
|
||||
mock_sendmail.assert_called_once()
|
||||
self.assertIn("EVENT", mock_sendmail.call_args[1]["subject"])
|
||||
finally:
|
||||
self.test_event.ticket_email_template = None
|
||||
self.test_event.save()
|
||||
frappe.delete_doc("Email Template", template.name, force=True)
|
||||
|
||||
@patch("frappe.sendmail")
|
||||
def test_falls_back_to_global_template(self, mock_sendmail):
|
||||
template = self._create_template("Global Ticket Template", "GLOBAL")
|
||||
try:
|
||||
self.test_event.ticket_email_template = None
|
||||
self.test_event.save()
|
||||
|
||||
settings = frappe.get_doc("Pohodex Event Manager Settings")
|
||||
settings.default_ticket_email_template = template.name
|
||||
settings.save()
|
||||
|
||||
self.test_ticket.send_ticket_email(now=True)
|
||||
|
||||
mock_sendmail.assert_called_once()
|
||||
self.assertIn("GLOBAL", mock_sendmail.call_args[1]["subject"])
|
||||
finally:
|
||||
settings.default_ticket_email_template = None
|
||||
settings.save()
|
||||
frappe.delete_doc("Email Template", template.name, force=True)
|
||||
|
||||
@patch("frappe.sendmail")
|
||||
def test_event_template_takes_precedence(self, mock_sendmail):
|
||||
event_template = self._create_template("Event Template", "EVENT")
|
||||
global_template = self._create_template("Global Template", "GLOBAL")
|
||||
try:
|
||||
self.test_event.ticket_email_template = event_template.name
|
||||
self.test_event.save()
|
||||
|
||||
settings = frappe.get_doc("Pohodex Event Manager Settings")
|
||||
settings.default_ticket_email_template = global_template.name
|
||||
settings.save()
|
||||
|
||||
self.test_ticket.send_ticket_email(now=True)
|
||||
|
||||
mock_sendmail.assert_called_once()
|
||||
self.assertIn("EVENT", mock_sendmail.call_args[1]["subject"])
|
||||
self.assertNotIn("GLOBAL", mock_sendmail.call_args[1]["subject"])
|
||||
finally:
|
||||
self.test_event.ticket_email_template = None
|
||||
self.test_event.save()
|
||||
settings.default_ticket_email_template = None
|
||||
settings.save()
|
||||
frappe.delete_doc("Email Template", event_template.name, force=True)
|
||||
frappe.delete_doc("Email Template", global_template.name, force=True)
|
||||
|
||||
@patch("frappe.sendmail")
|
||||
def test_uses_inline_template_when_none_configured(self, mock_sendmail):
|
||||
self.test_event.ticket_email_template = None
|
||||
self.test_event.save()
|
||||
|
||||
settings = frappe.get_doc("Pohodex Event Manager Settings")
|
||||
settings.default_ticket_email_template = None
|
||||
settings.save()
|
||||
|
||||
self.test_ticket.send_ticket_email(now=True)
|
||||
|
||||
mock_sendmail.assert_called_once()
|
||||
self.assertEqual(mock_sendmail.call_args[1]["template"], "ticket")
|
||||
|
||||
|
||||
class TestQRCodeGeneration(IntegrationTestCase):
|
||||
"""Tests for QR code generation utility."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
|
||||
|
||||
def test_make_qr_image_returns_png_bytes(self):
|
||||
"""QR image generation should return valid PNG bytes."""
|
||||
result = make_qr_image("test-data-123")
|
||||
|
||||
self.assertIsInstance(result, bytes)
|
||||
# PNG magic bytes
|
||||
self.assertTrue(result.startswith(b"\x89PNG"))
|
||||
|
||||
def test_generate_qr_code_file_creates_attachment(self):
|
||||
"""QR code file should be created and attached to document."""
|
||||
file_url = generate_qr_code_file(
|
||||
doc=self.test_event,
|
||||
data="test-qr-data",
|
||||
field_name="qr_code",
|
||||
file_prefix="test-qr",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(file_url)
|
||||
self.assertTrue(file_url.endswith(".png"))
|
||||
|
||||
# Verify file exists in File doctype
|
||||
file_doc = frappe.get_doc("File", {"file_url": file_url})
|
||||
self.assertEqual(file_doc.attached_to_doctype, "Pohodex Event Manager Event")
|
||||
self.assertEqual(str(file_doc.attached_to_name), str(self.test_event.name))
|
||||
|
||||
# Cleanup
|
||||
file_doc.delete()
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, BWH Studios and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Event Ticket Type", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "autoincrement",
|
||||
"creation": "2025-07-22 19:49:59.242064",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title",
|
||||
"price",
|
||||
"currency",
|
||||
"column_break_lrao",
|
||||
"event",
|
||||
"is_published",
|
||||
"auto_unpublish_after",
|
||||
"max_tickets_available",
|
||||
"stats_section",
|
||||
"tickets_sold",
|
||||
"column_break_ygut",
|
||||
"remaining_tickets"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "event",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Event",
|
||||
"options": "Pohodex Event Manager Event",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"description": "VIP, Early Bird, etc.",
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"label": "Title",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_lrao",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"allow_in_quick_entry": 1,
|
||||
"default": "0",
|
||||
"fieldname": "price",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Price",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"default": "INR",
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
"options": "Currency",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "is_published",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Published?"
|
||||
},
|
||||
{
|
||||
"description": "Leave it 0 for no limit",
|
||||
"fieldname": "max_tickets_available",
|
||||
"fieldtype": "Int",
|
||||
"label": "Max Tickets Available",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_published",
|
||||
"description": "For Early Bird, etc.",
|
||||
"fieldname": "auto_unpublish_after",
|
||||
"fieldtype": "Date",
|
||||
"label": "Auto Unpublish After"
|
||||
},
|
||||
{
|
||||
"fieldname": "stats_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Stats"
|
||||
},
|
||||
{
|
||||
"description": "-1 if no limit defined above",
|
||||
"fieldname": "remaining_tickets",
|
||||
"fieldtype": "Int",
|
||||
"is_virtual": 1,
|
||||
"label": "Remaining Tickets"
|
||||
},
|
||||
{
|
||||
"fieldname": "tickets_sold",
|
||||
"fieldtype": "Int",
|
||||
"is_virtual": 1,
|
||||
"label": "Tickets Sold"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ygut",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [
|
||||
{
|
||||
"link_doctype": "Event Ticket",
|
||||
"link_fieldname": "ticket_type"
|
||||
}
|
||||
],
|
||||
"modified": "2025-10-28 16:17:49.999214",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Event Ticket Type",
|
||||
"naming_rule": "Autoincrement",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Event Manager",
|
||||
"select": 1,
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Pohodex Event Manager User",
|
||||
"select": 1,
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "event,price,currency",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class EventTicketType(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
auto_unpublish_after: DF.Date | None
|
||||
currency: DF.Link
|
||||
event: DF.Link
|
||||
is_published: DF.Check
|
||||
max_tickets_available: DF.Int
|
||||
name: DF.Int | None
|
||||
price: DF.Currency
|
||||
title: DF.Data
|
||||
# end: auto-generated types
|
||||
|
||||
def are_tickets_available(self, num_tickets: int) -> bool:
|
||||
if self.remaining_tickets != -1 and self.remaining_tickets < num_tickets:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def tickets_sold(self) -> int:
|
||||
"""Returns the number of tickets sold for this ticket type."""
|
||||
return frappe.db.count("Event Ticket", {"ticket_type": self.name, "docstatus": 1})
|
||||
|
||||
@property
|
||||
def remaining_tickets(self) -> int:
|
||||
"""Returns -1 if no limit, otherwise the number of remaining tickets."""
|
||||
if not self.max_tickets_available:
|
||||
return -1
|
||||
return self.max_tickets_available - self.tickets_sold
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) 2025, BWH Studios and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class IntegrationTestEventTicketType(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for EventTicketType.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) 2025, BWH Studios and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class IntegrationTestTicketAddon(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for TicketAddon.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, BWH Studios and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Ticket Add-on", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-07-26 12:49:04.542973",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"enabled",
|
||||
"title",
|
||||
"event",
|
||||
"price",
|
||||
"description",
|
||||
"column_break_lbak",
|
||||
"currency",
|
||||
"user_selects_option",
|
||||
"options"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "price",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Price",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_lbak",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "INR",
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
"options": "Currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "event",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Event",
|
||||
"options": "Pohodex Event Manager Event",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"label": "Title",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "options",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Options",
|
||||
"mandatory_depends_on": "eval:doc.user_selects_option==true"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "user_selects_option",
|
||||
"fieldtype": "Check",
|
||||
"label": "User Selects Option?"
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Description"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enabled?"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-08 15:25:00.872093",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Ticket Add-on",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Event Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Pohodex Event Manager User",
|
||||
"select": 1,
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title",
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class TicketAddon(Document):
|
||||
def validate(self):
|
||||
self.validate_duplicate_title()
|
||||
|
||||
def validate_duplicate_title(self):
|
||||
if frappe.db.exists(
|
||||
self.doctype,
|
||||
{
|
||||
"event": self.event,
|
||||
"title": self.title,
|
||||
"name": ["!=", self.name],
|
||||
},
|
||||
):
|
||||
frappe.throw(_("Add-on <b>{0}</b> already exists for this event").format(self.title))
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-07-26 13:01:07.476312",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"add_on",
|
||||
"value",
|
||||
"column_break_jbbk",
|
||||
"price",
|
||||
"currency"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "add_on",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Add on",
|
||||
"options": "Ticket Add-on",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "value",
|
||||
"fieldtype": "Autocomplete",
|
||||
"in_list_view": 1,
|
||||
"label": "Value",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_jbbk",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fetch_from": "add_on.price",
|
||||
"fetch_if_empty": 1,
|
||||
"fieldname": "price",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Price",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"default": "INR",
|
||||
"fetch_from": "add_on.currency",
|
||||
"fetch_if_empty": 1,
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Currency",
|
||||
"options": "Currency"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-02 11:36:25.853051",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Ticket Add-on Value",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class TicketAddonValue(Document):
|
||||
pass
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-08-20 15:21:33.899513",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"ticket"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "ticket",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Ticket",
|
||||
"options": "Event Ticket",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-20 15:21:50.432135",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Ticket Cancellation Item",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class TicketCancellationItem(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
ticket: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) 2025, BWH Studios and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class IntegrationTestTicketCancellationRequest(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for TicketCancellationRequest.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, BWH Studios and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Ticket Cancellation Request", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-08-20 15:20:06.899417",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"booking",
|
||||
"cancel_full_booking",
|
||||
"tickets",
|
||||
"column_break_fiii",
|
||||
"status",
|
||||
"section_break_cykg",
|
||||
"amended_from"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "booking",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Booking",
|
||||
"options": "Event Booking",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "cancel_full_booking",
|
||||
"fieldtype": "Check",
|
||||
"label": "Cancel Full Booking?"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.cancel_full_booking==0",
|
||||
"fieldname": "tickets",
|
||||
"fieldtype": "Table",
|
||||
"label": "Tickets",
|
||||
"mandatory_depends_on": "eval:doc.cancel_full_booking==0",
|
||||
"options": "Ticket Cancellation Item"
|
||||
},
|
||||
{
|
||||
"fieldname": "amended_from",
|
||||
"fieldtype": "Link",
|
||||
"label": "Amended From",
|
||||
"no_copy": 1,
|
||||
"options": "Ticket Cancellation Request",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_fiii",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "In Review",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"options": "In Review\nAccepted\nRejected"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_cykg",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-11 14:24:24.302960",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Ticket Cancellation Request",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Event Manager",
|
||||
"select": 1,
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class TicketCancellationRequest(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from event_manager.ticketing.doctype.ticket_cancellation_item.ticket_cancellation_item import (
|
||||
TicketCancellationItem,
|
||||
)
|
||||
|
||||
amended_from: DF.Link | None
|
||||
booking: DF.Link
|
||||
cancel_full_booking: DF.Check
|
||||
status: DF.Literal["In Review", "Accepted", "Rejected"]
|
||||
tickets: DF.Table[TicketCancellationItem]
|
||||
# end: auto-generated types
|
||||
|
||||
def on_submit(self):
|
||||
if self.status != "Accepted":
|
||||
frappe.throw(frappe._("You must accept the request in order to submit it!"))
|
||||
|
||||
if self.cancel_full_booking:
|
||||
frappe.get_cached_doc("Event Booking", self.booking).cancel()
|
||||
else:
|
||||
# cancel individual tickets
|
||||
for ticket_item in self.tickets:
|
||||
frappe.get_cached_doc("Event Ticket", ticket_item.ticket).cancel()
|
||||
+1
@@ -0,0 +1 @@
|
||||
# Ticket Email Notification
|
||||
File diff suppressed because one or more lines are too long
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2025, BWH Studios and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.query_reports["Detailed Event Registrations"] = {
|
||||
filters: [
|
||||
{
|
||||
fieldname: "event",
|
||||
label: __("Event"),
|
||||
fieldtype: "Link",
|
||||
options: "Pohodex Event Manager Event",
|
||||
reqd: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2025-12-30 13:50:53.801487",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 0,
|
||||
"is_standard": "Yes",
|
||||
"letter_head": null,
|
||||
"modified": "2025-12-30 13:50:58.181897",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Detailed Event Registrations",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Event Ticket",
|
||||
"report_name": "Detailed Event Registrations",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Event Manager"
|
||||
}
|
||||
],
|
||||
"timeout": 0
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
if not filters.get("event"):
|
||||
return [], []
|
||||
|
||||
columns = get_columns(filters)
|
||||
data = get_data(filters, columns)
|
||||
return columns, data
|
||||
|
||||
|
||||
def get_columns(filters):
|
||||
event = filters.get("event")
|
||||
|
||||
# Fixed columns
|
||||
columns = [
|
||||
{
|
||||
"fieldname": "ticket_id",
|
||||
"label": _("Ticket ID"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Event Ticket",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"fieldname": "attendee_name",
|
||||
"label": _("Attendee Name"),
|
||||
"fieldtype": "Data",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"fieldname": "attendee_email",
|
||||
"label": _("Attendee Email"),
|
||||
"fieldtype": "Data",
|
||||
"width": 180,
|
||||
},
|
||||
{
|
||||
"fieldname": "booking_id",
|
||||
"label": _("Booking ID"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Event Booking",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"fieldname": "ticket_type",
|
||||
"label": _("Ticket Type"),
|
||||
"fieldtype": "Data",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"fieldname": "booking_user",
|
||||
"label": _("Booking User"),
|
||||
"fieldtype": "Link",
|
||||
"options": "User",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"fieldname": "booked_at",
|
||||
"label": _("Booked At"),
|
||||
"fieldtype": "Datetime",
|
||||
"width": 160,
|
||||
},
|
||||
]
|
||||
|
||||
# Dynamic custom field columns
|
||||
custom_fields = get_custom_fields_for_event(event)
|
||||
for cf in custom_fields:
|
||||
columns.append(
|
||||
{
|
||||
"fieldname": f"cf_{cf.fieldname}",
|
||||
"label": _(cf.label),
|
||||
"fieldtype": "Data",
|
||||
"width": 180,
|
||||
}
|
||||
)
|
||||
|
||||
# Dynamic add-on columns
|
||||
add_ons = get_add_ons_for_event(event)
|
||||
for addon in add_ons:
|
||||
columns.append(
|
||||
{
|
||||
"fieldname": f"addon_{addon.name}",
|
||||
"label": _(addon.title),
|
||||
"fieldtype": "Data",
|
||||
"width": 150,
|
||||
}
|
||||
)
|
||||
|
||||
# Dynamic UTM parameter columns
|
||||
utm_params = get_utm_params_for_event(event)
|
||||
for utm in utm_params:
|
||||
columns.append(
|
||||
{
|
||||
"fieldname": f"utm_{utm}",
|
||||
"label": _(utm.replace("_", " ").title()),
|
||||
"fieldtype": "Data",
|
||||
"width": 150,
|
||||
}
|
||||
)
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def get_data(filters, columns):
|
||||
event = filters.get("event")
|
||||
|
||||
# Get all submitted tickets for the event
|
||||
tickets = frappe.get_all(
|
||||
"Event Ticket",
|
||||
filters={"event": event, "docstatus": 1},
|
||||
fields=["name", "attendee_name", "attendee_email", "booking", "ticket_type", "creation"],
|
||||
)
|
||||
|
||||
if not tickets:
|
||||
return []
|
||||
|
||||
# Get ticket type titles
|
||||
ticket_type_map = get_ticket_type_map(event)
|
||||
|
||||
# Get booking details
|
||||
booking_ids = list(set([t.booking for t in tickets if t.booking]))
|
||||
booking_map = get_booking_map(booking_ids)
|
||||
|
||||
# Get custom fields configuration
|
||||
custom_fields = get_custom_fields_for_event(event)
|
||||
custom_field_names = [cf.fieldname for cf in custom_fields]
|
||||
|
||||
# Get add-ons configuration
|
||||
add_ons = get_add_ons_for_event(event)
|
||||
add_on_names = [addon.name for addon in add_ons]
|
||||
|
||||
# Get UTM params
|
||||
utm_params = get_utm_params_for_event(event)
|
||||
|
||||
# Get ticket additional fields
|
||||
ticket_ids = [t.name for t in tickets]
|
||||
ticket_additional_fields = get_ticket_additional_fields(ticket_ids)
|
||||
|
||||
# Get booking additional fields
|
||||
booking_additional_fields = get_booking_additional_fields(booking_ids)
|
||||
|
||||
# Get ticket add-ons
|
||||
ticket_add_ons = get_ticket_add_ons(ticket_ids)
|
||||
|
||||
# Get booking UTM parameters
|
||||
booking_utm_params = get_booking_utm_params(booking_ids)
|
||||
|
||||
# Build data rows
|
||||
data = []
|
||||
for ticket in tickets:
|
||||
row = {
|
||||
"ticket_id": ticket.name,
|
||||
"attendee_name": ticket.attendee_name,
|
||||
"attendee_email": ticket.attendee_email,
|
||||
"booking_id": ticket.booking,
|
||||
"ticket_type": ticket_type_map.get(str(ticket.ticket_type), ticket.ticket_type),
|
||||
"booking_user": booking_map.get(ticket.booking, {}).get("user", ""),
|
||||
"booked_at": ticket.creation,
|
||||
}
|
||||
|
||||
# Add custom field values (ticket takes priority over booking)
|
||||
for cf_name in custom_field_names:
|
||||
ticket_cf_value = ticket_additional_fields.get(ticket.name, {}).get(cf_name)
|
||||
booking_cf_value = booking_additional_fields.get(ticket.booking, {}).get(cf_name)
|
||||
row[f"cf_{cf_name}"] = ticket_cf_value or booking_cf_value or ""
|
||||
|
||||
# Add add-on values
|
||||
for addon_name in add_on_names:
|
||||
addon_value = ticket_add_ons.get(ticket.name, {}).get(addon_name)
|
||||
row[f"addon_{addon_name}"] = addon_value or ""
|
||||
|
||||
# Add UTM parameter values
|
||||
for utm in utm_params:
|
||||
utm_value = booking_utm_params.get(ticket.booking, {}).get(utm)
|
||||
row[f"utm_{utm}"] = utm_value or ""
|
||||
|
||||
data.append(row)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_custom_fields_for_event(event):
|
||||
return frappe.get_all(
|
||||
"Pohodex Event Manager Custom Field",
|
||||
filters={"event": event, "enabled": 1},
|
||||
fields=["fieldname", "label", "applied_to"],
|
||||
order_by="order asc",
|
||||
)
|
||||
|
||||
|
||||
def get_add_ons_for_event(event):
|
||||
return frappe.get_all(
|
||||
"Ticket Add-on",
|
||||
filters={"event": event, "enabled": 1},
|
||||
fields=["name", "title"],
|
||||
order_by="creation asc",
|
||||
)
|
||||
|
||||
|
||||
def get_utm_params_for_event(event):
|
||||
# Get distinct UTM parameter names from bookings for this event
|
||||
utm_data = frappe.db.sql(
|
||||
"""
|
||||
SELECT DISTINCT up.utm_name
|
||||
FROM `tabUTM Parameter` up
|
||||
INNER JOIN `tabEvent Booking` eb ON up.parent = eb.name
|
||||
WHERE eb.event = %s AND eb.docstatus = 1
|
||||
ORDER BY up.utm_name
|
||||
""",
|
||||
(event,),
|
||||
as_dict=True,
|
||||
)
|
||||
return [u.utm_name for u in utm_data]
|
||||
|
||||
|
||||
def get_ticket_type_map(event):
|
||||
ticket_types = frappe.get_all(
|
||||
"Event Ticket Type",
|
||||
filters={"event": event},
|
||||
fields=["name", "title"],
|
||||
)
|
||||
# Use string keys to handle type mismatches (autoincrement IDs can be int or str)
|
||||
return {str(tt.name): tt.title for tt in ticket_types}
|
||||
|
||||
|
||||
def get_booking_map(booking_ids):
|
||||
if not booking_ids:
|
||||
return {}
|
||||
|
||||
bookings = frappe.get_all(
|
||||
"Event Booking",
|
||||
filters={"name": ["in", booking_ids]},
|
||||
fields=["name", "user"],
|
||||
)
|
||||
return {b.name: b for b in bookings}
|
||||
|
||||
|
||||
def get_ticket_additional_fields(ticket_ids):
|
||||
if not ticket_ids:
|
||||
return {}
|
||||
|
||||
additional_fields = frappe.get_all(
|
||||
"Additional Field",
|
||||
filters={"parent": ["in", ticket_ids], "parenttype": "Event Ticket"},
|
||||
fields=["parent", "fieldname", "value"],
|
||||
)
|
||||
|
||||
result = {}
|
||||
for af in additional_fields:
|
||||
if af.parent not in result:
|
||||
result[af.parent] = {}
|
||||
result[af.parent][af.fieldname] = af.value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_booking_additional_fields(booking_ids):
|
||||
if not booking_ids:
|
||||
return {}
|
||||
|
||||
additional_fields = frappe.get_all(
|
||||
"Additional Field",
|
||||
filters={"parent": ["in", booking_ids], "parenttype": "Event Booking"},
|
||||
fields=["parent", "fieldname", "value"],
|
||||
)
|
||||
|
||||
result = {}
|
||||
for af in additional_fields:
|
||||
if af.parent not in result:
|
||||
result[af.parent] = {}
|
||||
result[af.parent][af.fieldname] = af.value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_ticket_add_ons(ticket_ids):
|
||||
if not ticket_ids:
|
||||
return {}
|
||||
|
||||
add_ons = frappe.get_all(
|
||||
"Ticket Add-on Value",
|
||||
filters={"parent": ["in", ticket_ids], "parenttype": "Event Ticket"},
|
||||
fields=["parent", "add_on", "value"],
|
||||
)
|
||||
|
||||
result = {}
|
||||
for ao in add_ons:
|
||||
if ao.parent not in result:
|
||||
result[ao.parent] = {}
|
||||
result[ao.parent][ao.add_on] = ao.value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_booking_utm_params(booking_ids):
|
||||
if not booking_ids:
|
||||
return {}
|
||||
|
||||
utm_params = frappe.get_all(
|
||||
"UTM Parameter",
|
||||
filters={"parent": ["in", booking_ids], "parenttype": "Event Booking"},
|
||||
fields=["parent", "utm_name", "value"],
|
||||
)
|
||||
|
||||
result = {}
|
||||
for up in utm_params:
|
||||
if up.parent not in result:
|
||||
result[up.parent] = {}
|
||||
result[up.parent][up.utm_name] = up.value
|
||||
|
||||
return result
|
||||
+828
@@ -0,0 +1,828 @@
|
||||
# Copyright (c) 2025, BWH Studios and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
from event_manager.ticketing.report.detailed_event_registrations.detailed_event_registrations import (
|
||||
execute,
|
||||
get_add_ons_for_event,
|
||||
get_booking_additional_fields,
|
||||
get_booking_map,
|
||||
get_booking_utm_params,
|
||||
get_columns,
|
||||
get_custom_fields_for_event,
|
||||
get_data,
|
||||
get_ticket_add_ons,
|
||||
get_ticket_additional_fields,
|
||||
get_ticket_type_map,
|
||||
get_utm_params_for_event,
|
||||
)
|
||||
|
||||
|
||||
class TestDetailedEventRegistrationsReport(IntegrationTestCase):
|
||||
"""Integration tests for the Detailed Event Registrations report."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.test_event = cls._create_test_event()
|
||||
cls.test_ticket_type = cls._create_test_ticket_type(cls.test_event)
|
||||
cls.test_ticket_type_vip = cls._create_test_ticket_type(cls.test_event, title="VIP", price=500)
|
||||
|
||||
@classmethod
|
||||
def _create_test_event(cls):
|
||||
"""Create a test event for the report tests."""
|
||||
# Check if test event already exists
|
||||
if frappe.db.exists("Pohodex Event Manager Event", {"route": "test-report-event"}):
|
||||
return frappe.get_doc("Pohodex Event Manager Event", {"route": "test-report-event"})
|
||||
|
||||
# Create required dependencies
|
||||
if not frappe.db.exists("Event Category", "Test Category"):
|
||||
frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert()
|
||||
|
||||
if not frappe.db.exists("Event Host", "Test Host"):
|
||||
frappe.get_doc({"doctype": "Event Host", "host_name": "Test Host"}).insert()
|
||||
|
||||
event = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Pohodex Event Manager Event",
|
||||
"title": "Test Report Event",
|
||||
"route": "test-report-event",
|
||||
"category": "Test Category",
|
||||
"host": "Test Host",
|
||||
"start_date": frappe.utils.today(),
|
||||
"start_time": "10:00:00",
|
||||
"end_time": "18:00:00",
|
||||
"medium": "Online",
|
||||
"apply_tax": False,
|
||||
}
|
||||
).insert()
|
||||
return event
|
||||
|
||||
@classmethod
|
||||
def _create_test_ticket_type(cls, event, title="Standard", price=100):
|
||||
"""Create a test ticket type."""
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Ticket Type",
|
||||
"event": event.name,
|
||||
"title": title,
|
||||
"price": price,
|
||||
"is_published": True,
|
||||
}
|
||||
).insert()
|
||||
|
||||
def _create_booking_with_tickets(
|
||||
self,
|
||||
attendees_data=None,
|
||||
utm_parameters=None,
|
||||
additional_fields=None,
|
||||
submit=True,
|
||||
):
|
||||
"""Helper to create a booking with tickets."""
|
||||
if attendees_data is None:
|
||||
attendees_data = [
|
||||
{
|
||||
"first_name": "John Doe",
|
||||
"email": "john@test.com",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
}
|
||||
]
|
||||
|
||||
booking_data = {
|
||||
"doctype": "Event Booking",
|
||||
"event": self.test_event.name,
|
||||
"user": "Administrator",
|
||||
"attendees": attendees_data,
|
||||
}
|
||||
|
||||
if utm_parameters:
|
||||
booking_data["utm_parameters"] = utm_parameters
|
||||
|
||||
if additional_fields:
|
||||
booking_data["additional_fields"] = additional_fields
|
||||
|
||||
booking = frappe.get_doc(booking_data).insert()
|
||||
|
||||
if submit:
|
||||
booking.submit()
|
||||
|
||||
return booking
|
||||
|
||||
def _create_custom_field(self, label, fieldname, applied_to="Ticket", fieldtype="Data"):
|
||||
"""Helper to create a custom field for the event."""
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Pohodex Event Manager Custom Field",
|
||||
"event": self.test_event.name,
|
||||
"label": label,
|
||||
"fieldname": fieldname,
|
||||
"fieldtype": fieldtype,
|
||||
"applied_to": applied_to,
|
||||
"enabled": 1,
|
||||
"order": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
def _create_ticket_add_on(self, title, price=50, user_selects_option=True, options="S\nM\nL\nXL"):
|
||||
"""Helper to create a ticket add-on for the event."""
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Ticket Add-on",
|
||||
"event": self.test_event.name,
|
||||
"title": title,
|
||||
"price": price,
|
||||
"enabled": 1,
|
||||
"user_selects_option": user_selects_option,
|
||||
"options": options if user_selects_option else None,
|
||||
}
|
||||
).insert()
|
||||
|
||||
def _get_ticket_for_booking(self, booking_name, attendee_email=None):
|
||||
"""Helper to get a ticket from a booking."""
|
||||
filters = {"booking": booking_name}
|
||||
if attendee_email:
|
||||
filters["attendee_email"] = attendee_email
|
||||
ticket_names = frappe.get_all("Event Ticket", filters=filters, pluck="name")
|
||||
if ticket_names:
|
||||
return frappe.get_doc("Event Ticket", ticket_names[0])
|
||||
return None
|
||||
|
||||
# ==================== Test execute function ====================
|
||||
|
||||
def test_execute_returns_empty_without_filters(self):
|
||||
"""Test that execute returns empty results without filters."""
|
||||
columns, data = execute(None)
|
||||
self.assertEqual(columns, [])
|
||||
self.assertEqual(data, [])
|
||||
|
||||
def test_execute_returns_empty_without_event_filter(self):
|
||||
"""Test that execute returns empty results without event filter."""
|
||||
columns, data = execute({})
|
||||
self.assertEqual(columns, [])
|
||||
self.assertEqual(data, [])
|
||||
|
||||
def test_execute_returns_columns_and_data_with_event_filter(self):
|
||||
"""Test that execute returns proper columns and data with event filter."""
|
||||
# Create a submitted booking
|
||||
self._create_booking_with_tickets()
|
||||
|
||||
columns, data = execute({"event": self.test_event.name})
|
||||
|
||||
self.assertIsInstance(columns, list)
|
||||
self.assertIsInstance(data, list)
|
||||
self.assertGreater(len(columns), 0)
|
||||
self.assertGreater(len(data), 0)
|
||||
|
||||
# ==================== Test get_columns function ====================
|
||||
|
||||
def test_get_columns_returns_fixed_columns(self):
|
||||
"""Test that get_columns returns the required fixed columns."""
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
|
||||
fieldnames = [col["fieldname"] for col in columns]
|
||||
expected_fieldnames = [
|
||||
"ticket_id",
|
||||
"attendee_name",
|
||||
"attendee_email",
|
||||
"booking_id",
|
||||
"ticket_type",
|
||||
"booking_user",
|
||||
]
|
||||
|
||||
for expected in expected_fieldnames:
|
||||
self.assertIn(expected, fieldnames)
|
||||
|
||||
def test_get_columns_includes_custom_field_columns(self):
|
||||
"""Test that custom field columns are included."""
|
||||
# Create a custom field
|
||||
custom_field = self._create_custom_field("Company Name", "company_name")
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
fieldnames = [col["fieldname"] for col in columns]
|
||||
|
||||
self.assertIn("cf_company_name", fieldnames)
|
||||
|
||||
# Clean up
|
||||
custom_field.delete()
|
||||
|
||||
def test_get_columns_includes_add_on_columns(self):
|
||||
"""Test that add-on columns are included."""
|
||||
# Create a ticket add-on
|
||||
add_on = self._create_ticket_add_on("T-Shirt Size")
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
fieldnames = [col["fieldname"] for col in columns]
|
||||
|
||||
self.assertIn(f"addon_{add_on.name}", fieldnames)
|
||||
|
||||
# Clean up
|
||||
add_on.delete()
|
||||
|
||||
def test_get_columns_includes_utm_columns(self):
|
||||
"""Test that UTM parameter columns are included."""
|
||||
# Create a booking with UTM parameters
|
||||
self._create_booking_with_tickets(
|
||||
utm_parameters=[
|
||||
{"utm_name": "utm_source", "value": "google"},
|
||||
{"utm_name": "utm_medium", "value": "cpc"},
|
||||
]
|
||||
)
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
fieldnames = [col["fieldname"] for col in columns]
|
||||
|
||||
self.assertIn("utm_utm_source", fieldnames)
|
||||
self.assertIn("utm_utm_medium", fieldnames)
|
||||
|
||||
# ==================== Test get_data function ====================
|
||||
|
||||
def test_get_data_returns_only_submitted_tickets(self):
|
||||
"""Test that only submitted tickets are returned."""
|
||||
# Create a draft booking (not submitted)
|
||||
self._create_booking_with_tickets(
|
||||
attendees_data=[
|
||||
{
|
||||
"first_name": "Draft User",
|
||||
"email": "draft@test.com",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
}
|
||||
],
|
||||
submit=False,
|
||||
)
|
||||
|
||||
# Create a submitted booking
|
||||
self._create_booking_with_tickets(
|
||||
attendees_data=[
|
||||
{
|
||||
"first_name": "Submitted User",
|
||||
"email": "submitted@test.com",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
}
|
||||
],
|
||||
submit=True,
|
||||
)
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
data = get_data({"event": self.test_event.name}, columns)
|
||||
|
||||
# Check that draft user is not in data
|
||||
attendee_names = [row["attendee_name"] for row in data]
|
||||
self.assertNotIn("Draft User", attendee_names)
|
||||
self.assertIn("Submitted User", attendee_names)
|
||||
|
||||
def test_get_data_includes_correct_ticket_info(self):
|
||||
"""Test that ticket information is correctly included."""
|
||||
booking = self._create_booking_with_tickets(
|
||||
attendees_data=[
|
||||
{
|
||||
"first_name": "Test Attendee",
|
||||
"email": "testattendee@test.com",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
data = get_data({"event": self.test_event.name}, columns)
|
||||
|
||||
# Find the row for our test attendee
|
||||
test_row = next((row for row in data if row["attendee_name"] == "Test Attendee"), None)
|
||||
|
||||
self.assertIsNotNone(test_row)
|
||||
self.assertEqual(test_row["attendee_email"], "testattendee@test.com")
|
||||
self.assertEqual(test_row["booking_id"], booking.name)
|
||||
# Check ticket type is the title from the ticket type we created
|
||||
self.assertEqual(test_row["ticket_type"], self.test_ticket_type.title)
|
||||
self.assertEqual(test_row["booking_user"], "Administrator")
|
||||
|
||||
def test_get_data_includes_custom_field_values_from_ticket(self):
|
||||
"""Test that custom field values from tickets are included."""
|
||||
# Create a custom field
|
||||
custom_field = self._create_custom_field(
|
||||
"Dietary Preference", "dietary_preference", applied_to="Ticket"
|
||||
)
|
||||
|
||||
# Create a standalone ticket with additional fields (not via booking)
|
||||
ticket = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Ticket",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
"attendee_name": "Dietary Test User",
|
||||
"attendee_email": "dietary@test.com",
|
||||
"additional_fields": [
|
||||
{
|
||||
"fieldname": "dietary_preference",
|
||||
"label": "Dietary Preference",
|
||||
"value": "Vegetarian",
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
ticket.submit()
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
data = get_data({"event": self.test_event.name}, columns)
|
||||
|
||||
# Find the row for our ticket
|
||||
test_row = next((row for row in data if row["ticket_id"] == ticket.name), None)
|
||||
|
||||
self.assertIsNotNone(test_row)
|
||||
self.assertEqual(test_row["cf_dietary_preference"], "Vegetarian")
|
||||
|
||||
# Clean up
|
||||
custom_field.delete()
|
||||
|
||||
def test_get_data_custom_field_ticket_priority_over_booking(self):
|
||||
"""Test that ticket-level custom field values take priority over booking-level."""
|
||||
# Create a custom field
|
||||
custom_field = self._create_custom_field("Organization", "organization", applied_to="Ticket")
|
||||
|
||||
# Create a standalone ticket with both booking and ticket level custom fields
|
||||
# Since we can't easily add ticket-level fields after submission, we use direct insertion
|
||||
# First create the ticket with ticket-level field
|
||||
ticket = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Ticket",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
"attendee_name": "Priority Test User",
|
||||
"attendee_email": "priority@test.com",
|
||||
"additional_fields": [
|
||||
{
|
||||
"fieldname": "organization",
|
||||
"label": "Organization",
|
||||
"value": "Ticket Org",
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
# Create a booking with organization field and link the ticket
|
||||
booking = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Booking",
|
||||
"event": self.test_event.name,
|
||||
"user": "Administrator",
|
||||
"attendees": [
|
||||
{
|
||||
"first_name": "Priority Test User",
|
||||
"email": "priority@test.com",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
}
|
||||
],
|
||||
"additional_fields": [
|
||||
{
|
||||
"fieldname": "organization",
|
||||
"label": "Organization",
|
||||
"value": "Booking Org",
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
# Update the ticket to link to this booking
|
||||
ticket.booking = booking.name
|
||||
ticket.save()
|
||||
|
||||
# Submit the ticket
|
||||
ticket.submit()
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
data = get_data({"event": self.test_event.name}, columns)
|
||||
|
||||
# Find the row for our ticket
|
||||
test_row = next((row for row in data if row["ticket_id"] == ticket.name), None)
|
||||
|
||||
self.assertIsNotNone(test_row)
|
||||
# Ticket value should take priority
|
||||
self.assertEqual(test_row["cf_organization"], "Ticket Org")
|
||||
|
||||
# Clean up
|
||||
custom_field.delete()
|
||||
|
||||
def test_get_data_falls_back_to_booking_custom_field(self):
|
||||
"""Test that booking-level custom field is used when ticket doesn't have it."""
|
||||
# Create a custom field
|
||||
custom_field = self._create_custom_field("Company", "company", applied_to="Booking")
|
||||
|
||||
# Create a booking with additional fields (no ticket-level fields)
|
||||
booking = self._create_booking_with_tickets(
|
||||
additional_fields=[
|
||||
{
|
||||
"fieldname": "company",
|
||||
"label": "Company",
|
||||
"value": "Acme Inc",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
data = get_data({"event": self.test_event.name}, columns)
|
||||
|
||||
# Find the row for our ticket
|
||||
ticket = self._get_ticket_for_booking(booking.name)
|
||||
self.assertIsNotNone(ticket, "Ticket should be created with booking")
|
||||
test_row = next((row for row in data if row["ticket_id"] == ticket.name), None) # type: ignore
|
||||
|
||||
self.assertIsNotNone(test_row)
|
||||
self.assertEqual(test_row["cf_company"], "Acme Inc")
|
||||
|
||||
# Clean up
|
||||
custom_field.delete()
|
||||
|
||||
def test_get_data_includes_add_on_values(self):
|
||||
"""Test that add-on values are correctly included."""
|
||||
# Create a ticket add-on
|
||||
add_on = self._create_ticket_add_on("T-Shirt Size")
|
||||
|
||||
# Create attendee add-on doc
|
||||
attendee_add_on = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Attendee Ticket Add-on",
|
||||
"add_ons": [{"add_on": add_on.name, "value": "XL"}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
# Create a booking with the add-on
|
||||
booking = self._create_booking_with_tickets(
|
||||
attendees_data=[
|
||||
{
|
||||
"first_name": "AddOn User",
|
||||
"email": "addon@test.com",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
"add_ons": attendee_add_on.name,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
data = get_data({"event": self.test_event.name}, columns)
|
||||
|
||||
# Find the row for our ticket
|
||||
ticket = self._get_ticket_for_booking(booking.name)
|
||||
self.assertIsNotNone(ticket, "Ticket should be created with booking")
|
||||
test_row = next((row for row in data if row["ticket_id"] == ticket.name), None) # type: ignore
|
||||
|
||||
self.assertIsNotNone(test_row)
|
||||
self.assertEqual(test_row[f"addon_{add_on.name}"], "XL")
|
||||
|
||||
# Clean up
|
||||
add_on.delete()
|
||||
|
||||
def test_get_data_includes_utm_values(self):
|
||||
"""Test that UTM parameter values are correctly included."""
|
||||
booking = self._create_booking_with_tickets(
|
||||
utm_parameters=[
|
||||
{"utm_name": "utm_source", "value": "facebook"},
|
||||
{"utm_name": "utm_campaign", "value": "summer_promo"},
|
||||
]
|
||||
)
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
data = get_data({"event": self.test_event.name}, columns)
|
||||
|
||||
# Find the row for our ticket
|
||||
ticket = self._get_ticket_for_booking(booking.name)
|
||||
self.assertIsNotNone(ticket, "Ticket should be created with booking")
|
||||
test_row = next((row for row in data if row["ticket_id"] == ticket.name), None) # type: ignore
|
||||
|
||||
self.assertIsNotNone(test_row)
|
||||
self.assertEqual(test_row["utm_utm_source"], "facebook")
|
||||
self.assertEqual(test_row["utm_utm_campaign"], "summer_promo")
|
||||
|
||||
def test_get_data_handles_multiple_tickets_per_booking(self):
|
||||
"""Test that multiple tickets per booking are handled correctly."""
|
||||
booking = self._create_booking_with_tickets(
|
||||
attendees_data=[
|
||||
{
|
||||
"first_name": "Attendee One",
|
||||
"email": "one@test.com",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
},
|
||||
{
|
||||
"first_name": "Attendee Two",
|
||||
"email": "two@test.com",
|
||||
"ticket_type": self.test_ticket_type_vip.name,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
data = get_data({"event": self.test_event.name}, columns)
|
||||
|
||||
# Find rows for both attendees
|
||||
attendee_names = [row["attendee_name"] for row in data]
|
||||
self.assertIn("Attendee One", attendee_names)
|
||||
self.assertIn("Attendee Two", attendee_names)
|
||||
|
||||
# Check ticket types
|
||||
attendee_one_row = next((row for row in data if row["attendee_name"] == "Attendee One"), None)
|
||||
attendee_two_row = next((row for row in data if row["attendee_name"] == "Attendee Two"), None)
|
||||
|
||||
self.assertIsNotNone(attendee_one_row)
|
||||
self.assertIsNotNone(attendee_two_row)
|
||||
self.assertEqual(attendee_one_row["ticket_type"], self.test_ticket_type.title)
|
||||
self.assertEqual(attendee_two_row["ticket_type"], self.test_ticket_type_vip.title)
|
||||
self.assertEqual(attendee_one_row["booking_id"], booking.name)
|
||||
self.assertEqual(attendee_two_row["booking_id"], booking.name)
|
||||
|
||||
# ==================== Test helper functions ====================
|
||||
|
||||
def test_get_custom_fields_for_event(self):
|
||||
"""Test get_custom_fields_for_event returns correct fields."""
|
||||
cf1 = self._create_custom_field("Field One", "field_one")
|
||||
cf2 = self._create_custom_field("Field Two", "field_two")
|
||||
|
||||
# Create a disabled field that should not be returned
|
||||
cf3 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Pohodex Event Manager Custom Field",
|
||||
"event": self.test_event.name,
|
||||
"label": "Disabled Field",
|
||||
"fieldname": "disabled_field",
|
||||
"fieldtype": "Data",
|
||||
"applied_to": "Ticket",
|
||||
"enabled": 0,
|
||||
"order": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
result = get_custom_fields_for_event(self.test_event.name)
|
||||
|
||||
fieldnames = [r.fieldname for r in result]
|
||||
self.assertIn("field_one", fieldnames)
|
||||
self.assertIn("field_two", fieldnames)
|
||||
self.assertNotIn("disabled_field", fieldnames)
|
||||
|
||||
# Clean up
|
||||
cf1.delete()
|
||||
cf2.delete()
|
||||
cf3.delete()
|
||||
|
||||
def test_get_add_ons_for_event(self):
|
||||
"""Test get_add_ons_for_event returns correct add-ons."""
|
||||
ao1 = self._create_ticket_add_on("Add-on One")
|
||||
ao2 = self._create_ticket_add_on("Add-on Two")
|
||||
|
||||
# Create a disabled add-on
|
||||
ao3 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Ticket Add-on",
|
||||
"event": self.test_event.name,
|
||||
"title": "Disabled Add-on",
|
||||
"price": 50,
|
||||
"enabled": 0,
|
||||
}
|
||||
).insert()
|
||||
|
||||
result = get_add_ons_for_event(self.test_event.name)
|
||||
|
||||
titles = [r.title for r in result]
|
||||
self.assertIn("Add-on One", titles)
|
||||
self.assertIn("Add-on Two", titles)
|
||||
self.assertNotIn("Disabled Add-on", titles)
|
||||
|
||||
# Clean up
|
||||
ao1.delete()
|
||||
ao2.delete()
|
||||
ao3.delete()
|
||||
|
||||
def test_get_utm_params_for_event(self):
|
||||
"""Test get_utm_params_for_event returns distinct UTM names."""
|
||||
# Create bookings with different UTM params
|
||||
self._create_booking_with_tickets(
|
||||
utm_parameters=[
|
||||
{"utm_name": "utm_source", "value": "google"},
|
||||
{"utm_name": "utm_medium", "value": "cpc"},
|
||||
]
|
||||
)
|
||||
|
||||
self._create_booking_with_tickets(
|
||||
attendees_data=[
|
||||
{
|
||||
"first_name": "Another User",
|
||||
"email": "another@test.com",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
}
|
||||
],
|
||||
utm_parameters=[
|
||||
{"utm_name": "utm_source", "value": "facebook"}, # Duplicate name, different value
|
||||
{"utm_name": "utm_campaign", "value": "winter"},
|
||||
],
|
||||
)
|
||||
|
||||
result = get_utm_params_for_event(self.test_event.name)
|
||||
|
||||
# Should have 3 distinct UTM names
|
||||
self.assertIn("utm_source", result)
|
||||
self.assertIn("utm_medium", result)
|
||||
self.assertIn("utm_campaign", result)
|
||||
self.assertEqual(len(set(result)), len(result)) # All unique
|
||||
|
||||
def test_get_ticket_type_map(self):
|
||||
"""Test get_ticket_type_map returns correct mapping."""
|
||||
result = get_ticket_type_map(self.test_event.name)
|
||||
|
||||
# Keys are strings due to autoincrement ID handling
|
||||
self.assertIn(str(self.test_ticket_type.name), result)
|
||||
self.assertEqual(result[str(self.test_ticket_type.name)], "Standard")
|
||||
self.assertIn(str(self.test_ticket_type_vip.name), result)
|
||||
self.assertEqual(result[str(self.test_ticket_type_vip.name)], "VIP")
|
||||
|
||||
def test_get_booking_map(self):
|
||||
"""Test get_booking_map returns correct mapping."""
|
||||
booking = self._create_booking_with_tickets()
|
||||
|
||||
result = get_booking_map([booking.name])
|
||||
|
||||
self.assertIn(booking.name, result)
|
||||
self.assertEqual(result[booking.name]["user"], "Administrator")
|
||||
|
||||
def test_get_booking_map_empty_list(self):
|
||||
"""Test get_booking_map handles empty list."""
|
||||
result = get_booking_map([])
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_get_ticket_additional_fields(self):
|
||||
"""Test get_ticket_additional_fields returns correct data."""
|
||||
# Create a standalone ticket with additional fields
|
||||
ticket = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Ticket",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
"attendee_name": "Additional Fields Test",
|
||||
"attendee_email": "addfields@test.com",
|
||||
"additional_fields": [
|
||||
{
|
||||
"fieldname": "test_field",
|
||||
"label": "Test Field",
|
||||
"value": "Test Value",
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
result = get_ticket_additional_fields([ticket.name])
|
||||
|
||||
self.assertIn(ticket.name, result)
|
||||
self.assertEqual(result[ticket.name]["test_field"], "Test Value")
|
||||
|
||||
def test_get_ticket_additional_fields_empty_list(self):
|
||||
"""Test get_ticket_additional_fields handles empty list."""
|
||||
result = get_ticket_additional_fields([])
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_get_booking_additional_fields(self):
|
||||
"""Test get_booking_additional_fields returns correct data."""
|
||||
booking = self._create_booking_with_tickets(
|
||||
additional_fields=[
|
||||
{
|
||||
"fieldname": "booking_field",
|
||||
"label": "Booking Field",
|
||||
"value": "Booking Value",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
result = get_booking_additional_fields([booking.name])
|
||||
|
||||
self.assertIn(booking.name, result)
|
||||
self.assertEqual(result[booking.name]["booking_field"], "Booking Value")
|
||||
|
||||
def test_get_booking_additional_fields_empty_list(self):
|
||||
"""Test get_booking_additional_fields handles empty list."""
|
||||
result = get_booking_additional_fields([])
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_get_ticket_add_ons(self):
|
||||
"""Test get_ticket_add_ons returns correct data."""
|
||||
add_on = self._create_ticket_add_on("Test Add-on")
|
||||
|
||||
attendee_add_on = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Attendee Ticket Add-on",
|
||||
"add_ons": [{"add_on": add_on.name, "value": "Medium"}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
booking = self._create_booking_with_tickets(
|
||||
attendees_data=[
|
||||
{
|
||||
"first_name": "Add-on Test",
|
||||
"email": "addontest@test.com",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
"add_ons": attendee_add_on.name,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
ticket = self._get_ticket_for_booking(booking.name)
|
||||
self.assertIsNotNone(ticket, "Ticket should be created with booking")
|
||||
result = get_ticket_add_ons([ticket.name]) # type: ignore
|
||||
|
||||
self.assertIn(ticket.name, result) # type: ignore
|
||||
self.assertEqual(result[ticket.name][add_on.name], "Medium") # type: ignore
|
||||
|
||||
# Clean up
|
||||
add_on.delete()
|
||||
|
||||
def test_get_ticket_add_ons_empty_list(self):
|
||||
"""Test get_ticket_add_ons handles empty list."""
|
||||
result = get_ticket_add_ons([])
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_get_booking_utm_params(self):
|
||||
"""Test get_booking_utm_params returns correct data."""
|
||||
booking = self._create_booking_with_tickets(
|
||||
utm_parameters=[
|
||||
{"utm_name": "utm_source", "value": "twitter"},
|
||||
{"utm_name": "utm_medium", "value": "social"},
|
||||
]
|
||||
)
|
||||
|
||||
result = get_booking_utm_params([booking.name])
|
||||
|
||||
self.assertIn(booking.name, result)
|
||||
self.assertEqual(result[booking.name]["utm_source"], "twitter")
|
||||
self.assertEqual(result[booking.name]["utm_medium"], "social")
|
||||
|
||||
def test_get_booking_utm_params_empty_list(self):
|
||||
"""Test get_booking_utm_params handles empty list."""
|
||||
result = get_booking_utm_params([])
|
||||
self.assertEqual(result, {})
|
||||
|
||||
# ==================== Edge cases ====================
|
||||
|
||||
def test_report_with_no_tickets(self):
|
||||
"""Test report handles events with no tickets."""
|
||||
# Create a new event with no tickets
|
||||
event = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Pohodex Event Manager Event",
|
||||
"title": "Empty Event",
|
||||
"route": "empty-event-" + frappe.generate_hash(length=6),
|
||||
"category": "Test Category",
|
||||
"host": "Test Host",
|
||||
"start_date": frappe.utils.today(),
|
||||
"start_time": "10:00:00",
|
||||
"end_time": "18:00:00",
|
||||
"medium": "Online",
|
||||
}
|
||||
).insert()
|
||||
|
||||
columns, data = execute({"event": event.name})
|
||||
|
||||
self.assertIsInstance(columns, list)
|
||||
self.assertEqual(data, [])
|
||||
# Note: Not cleaning up the event as it may have linked dependencies in test DB
|
||||
|
||||
def test_report_with_missing_booking(self):
|
||||
"""Test report handles tickets without booking reference gracefully."""
|
||||
# Create a standalone ticket without booking
|
||||
ticket = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Ticket",
|
||||
"ticket_type": self.test_ticket_type.name,
|
||||
"attendee_name": "Standalone User",
|
||||
"attendee_email": "standalone@test.com",
|
||||
}
|
||||
).insert()
|
||||
ticket.submit()
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
data = get_data({"event": self.test_event.name}, columns)
|
||||
|
||||
# Should include the ticket even without booking
|
||||
test_row = next((row for row in data if row["attendee_name"] == "Standalone User"), None)
|
||||
self.assertIsNotNone(test_row)
|
||||
self.assertEqual(test_row["booking_user"], "")
|
||||
|
||||
def test_report_column_ordering(self):
|
||||
"""Test that columns are in the expected order."""
|
||||
# Create all types of dynamic columns
|
||||
custom_field = self._create_custom_field("Custom Col", "custom_col")
|
||||
add_on = self._create_ticket_add_on("Add-on Col")
|
||||
self._create_booking_with_tickets(utm_parameters=[{"utm_name": "utm_test", "value": "test"}])
|
||||
|
||||
columns = get_columns({"event": self.test_event.name})
|
||||
fieldnames = [col["fieldname"] for col in columns]
|
||||
|
||||
# Fixed columns should come first
|
||||
fixed_end_index = fieldnames.index("booking_user")
|
||||
|
||||
# Custom fields should come after fixed columns
|
||||
cf_index = fieldnames.index("cf_custom_col")
|
||||
self.assertGreater(cf_index, fixed_end_index)
|
||||
|
||||
# Add-ons should come after custom fields
|
||||
addon_index = fieldnames.index(f"addon_{add_on.name}")
|
||||
self.assertGreater(addon_index, cf_index)
|
||||
|
||||
# UTM params should come last
|
||||
utm_index = fieldnames.index("utm_utm_test")
|
||||
self.assertGreater(utm_index, addon_index)
|
||||
|
||||
# Clean up
|
||||
custom_field.delete()
|
||||
add_on.delete()
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2025, BWH Studios and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.query_reports["Event Add-Ons Overview"] = {
|
||||
filters: [
|
||||
{
|
||||
fieldname: "event",
|
||||
label: __("Event"),
|
||||
fieldtype: "Link",
|
||||
options: "Pohodex Event Manager Event",
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "add_on_type",
|
||||
label: __("Add-On Type"),
|
||||
fieldtype: "Link",
|
||||
options: "Ticket Add-on",
|
||||
},
|
||||
{
|
||||
fieldname: "add_on_value",
|
||||
label: __("Add-On Value"),
|
||||
fieldtype: "Data",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2025-11-06 10:58:02.520355",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 0,
|
||||
"is_standard": "Yes",
|
||||
"letterhead": null,
|
||||
"modified": "2025-11-06 10:58:02.520355",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Ticketing",
|
||||
"name": "Event Add-Ons Overview",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Event Ticket",
|
||||
"report_name": "Event Add-Ons Overview",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "System Manager"
|
||||
},
|
||||
{
|
||||
"role": "Event Manager"
|
||||
},
|
||||
{
|
||||
"role": "Pohodex Event Manager User"
|
||||
}
|
||||
],
|
||||
"timeout": 0
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# Copyright (c) 2025, BWH Studios and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
|
||||
def execute(filters: dict | None = None):
|
||||
"""Return columns and data for the report.
|
||||
|
||||
This is the main entry point for the report. It accepts the filters as a
|
||||
dictionary and should return columns and data. It is called by the framework
|
||||
every time the report is refreshed or a filter is updated.
|
||||
"""
|
||||
columns = get_columns()
|
||||
data = get_data(filters)
|
||||
|
||||
return columns, data
|
||||
|
||||
|
||||
def get_columns() -> list[dict]:
|
||||
"""Return columns for the report.
|
||||
|
||||
One field definition per column, just like a DocType field definition.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"label": _("Attendee Name"),
|
||||
"fieldname": "attendee_name",
|
||||
"fieldtype": "Data",
|
||||
},
|
||||
{"label": _("Attendee Email"), "fieldname": "attendee_email", "fieldtype": "Data", "width": 200},
|
||||
{"label": _("Add-On"), "fieldname": "add_on", "fieldtype": "Data", "width": 150},
|
||||
{"label": _("Value"), "fieldname": "value", "fieldtype": "Data", "width": 150},
|
||||
{
|
||||
"label": _("Ticket"),
|
||||
"fieldname": "ticket",
|
||||
"fieldtype": "Link",
|
||||
"options": "Event Ticket",
|
||||
"width": 150,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_data(filters=None) -> list[dict]:
|
||||
"""Return data for the report.
|
||||
|
||||
The report data is a list of rows, with each row being a list of cell values.
|
||||
"""
|
||||
tav = frappe.qb.DocType("Ticket Add-on Value")
|
||||
ticket = frappe.qb.DocType("Event Ticket")
|
||||
ticket_add_on = frappe.qb.DocType("Ticket Add-on")
|
||||
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(tav)
|
||||
.join(ticket)
|
||||
.on(tav.parent == ticket.name)
|
||||
.join(ticket_add_on)
|
||||
.on(tav.add_on == ticket_add_on.name)
|
||||
.select(
|
||||
ticket.attendee_name,
|
||||
ticket.attendee_email,
|
||||
ticket.name.as_("ticket"),
|
||||
ticket_add_on.title.as_("add_on"),
|
||||
tav.value,
|
||||
)
|
||||
.where(ticket.event == filters.get("event"))
|
||||
.where(ticket.docstatus == 1)
|
||||
)
|
||||
|
||||
if filters.get("add_on_type"):
|
||||
query = query.where(ticket_add_on.name == filters.get("add_on_type"))
|
||||
|
||||
if filters.get("add_on_value"):
|
||||
# like operator for partial matching
|
||||
query = query.where(tav.value.like(f"%{filters.get('add_on_value')}%"))
|
||||
|
||||
return query.run(as_dict=True)
|
||||
Reference in New Issue
Block a user