Initialize fork and rebrand app to event_manager
CI / Server (push) Has been cancelled
Linters / Frappe Linter (push) Has been cancelled
Linters / Vulnerable Dependency Check (push) Has been cancelled
UI Tests / Playwright E2E Tests (push) Has been cancelled

This commit is contained in:
2026-05-11 09:56:57 +02:00
parent f82bb803ac
commit 786cbc724f
500 changed files with 41152 additions and 2 deletions
View File
@@ -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
@@ -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
@@ -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
@@ -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) {
// },
// });
@@ -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": []
}
@@ -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()
@@ -0,0 +1 @@
# Ticket Email Notification
File diff suppressed because one or more lines are too long
@@ -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,
},
],
};
@@ -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
}
@@ -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
@@ -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)