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
@@ -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)