diff --git a/.eslintrc b/.eslintrc index c5e7d68..2012ee7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -25,6 +25,7 @@ }, "root": true, "globals": { + "buzz": true, "frappe": true, "Vue": true, "SetVueGlobals": true, diff --git a/PLAN_EVENT_TEMPLATE.md b/PLAN_EVENT_TEMPLATE.md new file mode 100644 index 0000000..18d0497 --- /dev/null +++ b/PLAN_EVENT_TEMPLATE.md @@ -0,0 +1,1261 @@ +# Event Template Feature - Implementation Plan + +## Overview + +Create an **Event Template** doctype that allows users to save reusable event configurations. Users can then create new Buzz Events from these templates via a "Create from Template" button in the Buzz Event list view. + +--- + +## Part 1: Event Template DocType + +### 1.1 Create New DocType: `Event Template` + +**Location:** `buzz/events/doctype/event_template/` + +**Fields to include (mirroring Buzz Event):** + +| Fieldname | Fieldtype | Options/Notes | +|-----------|-----------|---------------| +| `template_name` | Data | **Required** - Name of the template (e.g., "Frappe Webinar Template") | +| `category` | Link | Event Category | +| `host` | Link | Event Host | +| `banner_image` | Attach Image | | +| `short_description` | Small Text | | +| `about` | Text Editor | | +| `medium` | Select | "In Person", "Online" | +| `venue` | Link | Event Venue (conditional on medium) | +| `time_zone` | Autocomplete | | +| `ticket_email_template` | Link | Email Template | +| `ticket_print_format` | Link | Print Format | +| `apply_tax` | Check | | +| `tax_label` | Data | | +| `tax_percentage` | Percent | | +| `auto_send_pitch_deck` | Check | | +| `sponsor_deck_email_template` | Link | Email Template | +| `sponsor_deck_reply_to` | Data | | +| `sponsor_deck_cc` | Small Text | | + +**Child Tables:** +| Fieldname | Fieldtype | Options | +|-----------|-----------|---------| +| `payment_gateways` | Table | Event Payment Gateway | +| `sponsor_deck_attachments` | Table | Sponsorship Deck Item | + +> **Note:** Schedule items and Featured Speakers are NOT included in templates since every event has unique talks, speakers, and schedule. + +**Additional Child Tables for Template-Specific Data:** +| Fieldname | Fieldtype | Options | +|-----------|-----------|---------| +| `template_ticket_types` | Table | Event Template Ticket Type (new) | +| `template_add_ons` | Table | Event Template Add-on (new) | +| `template_custom_fields` | Table | Event Template Custom Field (new) | + +### 1.2 Create Child DocTypes for Template + +Since Ticket Types, Add-ons, and Custom Fields are linked to events (not child tables), we need template-specific child tables: + +#### Event Template Ticket Type +| Fieldname | Fieldtype | Options | +|-----------|-----------|---------| +| `title` | Data | Required | +| `price` | Currency | | +| `currency` | Link | Currency | +| `is_published` | Check | | +| `max_tickets_available` | Int | | +| `auto_unpublish_after` | Date | | + +#### Event Template Add-on +| Fieldname | Fieldtype | Options | +|-----------|-----------|---------| +| `title` | Data | Required | +| `price` | Currency | | +| `currency` | Link | Currency | +| `description` | Small Text | | +| `user_selects_option` | Check | | +| `options` | Small Text | | +| `enabled` | Check | | + +#### Event Template Custom Field +| Fieldname | Fieldtype | Options | +|-----------|-----------|---------| +| `label` | Data | Required | +| `fieldname` | Data | | +| `fieldtype` | Select | Data, Phone, Email, Select, Date, Number | +| `options` | Small Text | | +| `applied_to` | Select | Booking, Ticket | +| `enabled` | Check | | +| `mandatory` | Check | | +| `placeholder` | Data | | +| `default_value` | Data | | +| `order` | Int | | + +--- + +## Part 2: List View Button & Dialog + +### 2.1 Create List View Settings + +**File:** `buzz/events/doctype/buzz_event/buzz_event_list.js` + +```javascript +frappe.listview_settings["Buzz Event"] = { + onload: function(listview) { + // Add "Create from Template" button to page actions + listview.page.add_inner_button(__("Create from Template"), function() { + buzz.events.show_create_from_template_dialog(); + }); + } +}; +``` + +### 2.2 Template Selection Dialog + +**File:** `buzz/public/js/events/create_from_template.js` + +The dialog will have two stages: + +**Stage 1: Select Template** +- Link field to select Event Template +- On selection, fetch template data and show Stage 2 + +**Stage 2: Select What to Copy** +- Dynamic checkboxes based on template content +- Grouped sections: + - **Event Details** (always shown) + - [ ] Category + - [ ] Host + - [ ] Banner Image + - [ ] Short Description + - [ ] About + - [ ] Medium + - [ ] Venue + - [ ] Time Zone + - **Ticketing Settings** + - [ ] Tax Settings + - [ ] Ticket Email Template + - [ ] Ticket Print Format + - **Sponsorship Settings** + - [ ] Auto Send Pitch Deck + - [ ] Sponsor Deck Email Template + - [ ] Sponsor Deck Attachments + - **Payment Gateways** (if template has payment gateways) + - [ ] Copy Payment Gateways + - **Ticket Types** (if template has ticket types) + - [ ] Copy Ticket Types + - **Add-ons** (if template has add-ons) + - [ ] Copy Add-ons + - **Custom Fields** (if template has custom fields) + - [ ] Copy Custom Fields + +### 2.3 Dialog Implementation Pattern + +Following the ERPNext/Frappe patterns discovered: + +```javascript +frappe.provide("buzz.events"); + +buzz.events.show_create_from_template_dialog = function() { + let d = new frappe.ui.Dialog({ + title: __("Create Event from Template"), + fields: [ + { + fieldtype: "Link", + fieldname: "template", + label: __("Select Template"), + options: "Event Template", + reqd: 1, + change: function() { + // Fetch template and update checkboxes + buzz.events.update_template_options(d); + } + }, + { + fieldtype: "Section Break", + fieldname: "options_section", + label: __("Select What to Copy"), + depends_on: "eval:doc.template" + }, + { + fieldtype: "HTML", + fieldname: "field_options", + depends_on: "eval:doc.template" + } + ], + primary_action_label: __("Create Event"), + primary_action: function(values) { + buzz.events.create_event_from_template(d, values); + } + }); + + d.show(); +}; +``` + +--- + +## Part 3: Backend API + +### 3.1 Python API Method + +**File:** `buzz/events/doctype/buzz_event/buzz_event.py` + +```python +@frappe.whitelist() +def create_from_template(template_name, options): + """ + Create a new Buzz Event from a template. + + Args: + template_name: Name of the Event Template + options: Dict of what to copy (e.g., {"category": 1, "ticket_types": 1, ...}) + + Returns: + New Buzz Event document name + """ + template = frappe.get_doc("Event Template", template_name) + options = frappe.parse_json(options) + + # Create new event + event = frappe.new_doc("Buzz Event") + event.title = f"New Event from {template.template_name}" + + # Copy selected fields + field_map = { + "category": "category", + "host": "host", + "banner_image": "banner_image", + "short_description": "short_description", + "about": "about", + "medium": "medium", + "venue": "venue", + "time_zone": "time_zone", + "ticket_email_template": "ticket_email_template", + "ticket_print_format": "ticket_print_format", + "apply_tax": "apply_tax", + "tax_label": "tax_label", + "tax_percentage": "tax_percentage", + "auto_send_pitch_deck": "auto_send_pitch_deck", + "sponsor_deck_email_template": "sponsor_deck_email_template", + "sponsor_deck_reply_to": "sponsor_deck_reply_to", + "sponsor_deck_cc": "sponsor_deck_cc", + } + + for option_key, field_name in field_map.items(): + if options.get(option_key): + event.set(field_name, template.get(field_name)) + + # Copy child tables + if options.get("payment_gateways"): + for pg in template.payment_gateways: + event.append("payment_gateways", {"payment_gateway": pg.payment_gateway}) + + + if options.get("sponsor_deck_attachments"): + for attachment in template.sponsor_deck_attachments: + event.append("sponsor_deck_attachments", {"file": attachment.file}) + + event.insert() + + # Create linked documents (Ticket Types, Add-ons, Custom Fields) + if options.get("ticket_types"): + for tt in template.template_ticket_types: + ticket_type = frappe.new_doc("Event Ticket Type") + ticket_type.event = event.name + ticket_type.title = tt.title + ticket_type.price = tt.price + ticket_type.currency = tt.currency + ticket_type.is_published = tt.is_published + ticket_type.max_tickets_available = tt.max_tickets_available + ticket_type.auto_unpublish_after = tt.auto_unpublish_after + ticket_type.insert() + + if options.get("add_ons"): + for addon in template.template_add_ons: + add_on = frappe.new_doc("Ticket Add-on") + add_on.event = event.name + add_on.title = addon.title + add_on.price = addon.price + add_on.currency = addon.currency + add_on.description = addon.description + add_on.user_selects_option = addon.user_selects_option + add_on.options = addon.options + add_on.enabled = addon.enabled + add_on.insert() + + if options.get("custom_fields"): + for cf in template.template_custom_fields: + custom_field = frappe.new_doc("Buzz Custom Field") + custom_field.event = event.name + custom_field.label = cf.label + custom_field.fieldname = cf.fieldname + custom_field.fieldtype = cf.fieldtype + custom_field.options = cf.options + custom_field.applied_to = cf.applied_to + custom_field.enabled = cf.enabled + custom_field.mandatory = cf.mandatory + custom_field.placeholder = cf.placeholder + custom_field.default_value = cf.default_value + custom_field.order = cf.order + custom_field.insert() + + return event.name +``` + +--- + +## Part 4: "Save as Template" Feature (Reverse Flow) + +### 4.1 Custom Button on Buzz Event Form + +**File:** `buzz/events/doctype/buzz_event/buzz_event.js` + +Add a custom button to save the current event as a template: + +```javascript +frappe.ui.form.on("Buzz Event", { + refresh: function(frm) { + if (!frm.is_new() && frappe.perm.has_perm("Event Template", 0, "create")) { + frm.add_custom_button(__("Save as Template"), function() { + buzz.events.show_save_as_template_dialog(frm); + }, __("Actions")); + } + } +}); +``` + +### 4.2 Save as Template Dialog + +Similar to "Create from Template" but in reverse - user selects what to include: + +```javascript +buzz.events.show_save_as_template_dialog = function(frm) { + let d = new frappe.ui.Dialog({ + title: __("Save Event as Template"), + fields: [ + { + fieldtype: "Data", + fieldname: "template_name", + label: __("Template Name"), + reqd: 1, + default: frm.doc.title + " Template" + }, + { + fieldtype: "Section Break", + label: __("Select What to Include") + }, + // ... checkbox fields similar to create dialog + ], + primary_action_label: __("Save Template"), + primary_action: function(values) { + frappe.call({ + method: "buzz.events.doctype.event_template.event_template.create_template_from_event", + args: { + event_name: frm.doc.name, + template_name: values.template_name, + options: values + }, + callback: function(r) { + if (r.message) { + d.hide(); + frappe.show_alert({ + message: __("Template {0} created successfully", [r.message]), + indicator: "green" + }); + frappe.set_route("Form", "Event Template", r.message); + } + } + }); + } + }); + d.show(); +}; +``` + +### 4.3 Backend API for Save as Template + +**File:** `buzz/events/doctype/event_template/event_template.py` + +```python +@frappe.whitelist() +def create_template_from_event(event_name, template_name, options): + """ + Create an Event Template from an existing Buzz Event. + + Args: + event_name: Name of the source Buzz Event + template_name: Name for the new template + options: Dict of what to include + + Returns: + New Event Template document name + """ + event = frappe.get_doc("Buzz Event", event_name) + options = frappe.parse_json(options) + + template = frappe.new_doc("Event Template") + template.template_name = template_name + + # Copy selected fields from event to template + # ... similar logic to create_from_template but reversed + + # Copy linked documents (Ticket Types, Add-ons, Custom Fields) + if options.get("ticket_types"): + ticket_types = frappe.get_all("Event Ticket Type", + filters={"event": event_name}, + fields=["*"] + ) + for tt in ticket_types: + template.append("template_ticket_types", { + "title": tt.title, + "price": tt.price, + "currency": tt.currency, + # ... other fields + }) + + # Similar for add-ons and custom fields + + template.insert() + return template.name +``` + +--- + +## Part 5: Permissions + +### 5.1 Event Template DocType Permissions + +The Event Template doctype will have permissions for the **Event Manager** role: + +| Role | Read | Write | Create | Delete | Submit | Cancel | +|------|------|-------|--------|--------|--------|--------| +| Event Manager | ✓ | ✓ | ✓ | ✓ | - | - | +| System Manager | ✓ | ✓ | ✓ | ✓ | - | - | + +### 5.2 Permission Checks in Code + +```python +# In create_from_template +if not frappe.has_permission("Event Template", "read"): + frappe.throw(_("You don't have permission to use templates")) + +if not frappe.has_permission("Buzz Event", "create"): + frappe.throw(_("You don't have permission to create events")) + +# In create_template_from_event +if not frappe.has_permission("Event Template", "create"): + frappe.throw(_("You don't have permission to create templates")) +``` + +--- + +## Part 6: Implementation Steps + +### Step 1: Create DocTypes +1. Create `Event Template` doctype with all fields and Event Manager permissions +2. Create `Event Template Ticket Type` child doctype +3. Create `Event Template Add-on` child doctype +4. Create `Event Template Custom Field` child doctype + +### Step 2: Create JavaScript Files +1. Create `buzz_event_list.js` with "Create from Template" button +2. Create `create_from_template.js` with dialog logic +3. Update `buzz_event.js` with "Save as Template" button +4. Add JS files to `hooks.py` + +### Step 3: Create Python API +1. Add `create_from_template` whitelisted method in `buzz_event.py` +2. Add `create_template_from_event` whitelisted method in `event_template.py` +3. Add permission checks + +### Step 4: Testing +1. Create test templates with various configurations +2. Test "Create from Template" dialog functionality +3. Test "Save as Template" from existing events +4. Test event creation with different option combinations +5. Verify all linked documents are created correctly +6. Test permissions for Event Manager role + +--- + +## Part 5: UI/UX Details + +### Dialog Layout (Following Frappe Patterns) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Create Event from Template X │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Select Template │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Link Field - Event Template] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────── │ +│ Select What to Copy │ +│ │ +│ [Select All] [Unselect All] │ +│ │ +│ ▼ Event Details │ +│ ☑ Category ☑ Host │ +│ ☑ Banner Image ☑ Short Description │ +│ ☑ About ☑ Medium │ +│ ☑ Venue ☑ Time Zone │ +│ │ +│ ▼ Ticketing Settings │ +│ ☑ Tax Settings ☑ Ticket Email Template │ +│ ☑ Ticket Print Format │ +│ │ +│ ▼ Sponsorship Settings │ +│ ☑ Auto Send Pitch Deck ☑ Sponsor Deck Email │ +│ ☑ Sponsor Deck Attachments │ +│ │ +│ ▼ Related Documents │ +│ ☑ Payment Gateways (3) ☑ Ticket Types (4) │ +│ ☑ Add-ons (2) ☑ Custom Fields (6) │ +│ │ +├─────────────────────────────────────────────────────────────┤ +│ [Cancel] [Create Event] │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Checkbox Implementation + +Using Frappe's MultiCheck fieldtype pattern: + +```javascript +{ + fieldtype: "MultiCheck", + fieldname: "event_details", + label: __("Event Details"), + columns: 2, + options: [ + { label: __("Category"), value: "category", checked: true }, + { label: __("Host"), value: "host", checked: true }, + { label: __("Banner Image"), value: "banner_image", checked: true }, + // ... more options + ] +} +``` + +Or using HTML field with custom rendering (more flexible): + +```javascript +{ + fieldtype: "HTML", + fieldname: "options_html" +} + +// Then render custom HTML with checkboxes grouped by section +``` + +--- + +## Part 6: File Structure + +``` +buzz/ +├── events/ +│ └── doctype/ +│ ├── buzz_event/ +│ │ ├── buzz_event.py # Add create_from_template method +│ │ ├── buzz_event.js # UPDATE: Add "Save as Template" button +│ │ └── buzz_event_list.js # NEW: List view "Create from Template" button +│ ├── event_template/ # NEW +│ │ ├── event_template.json +│ │ ├── event_template.py # create_template_from_event method +│ │ └── event_template.js +│ ├── event_template_ticket_type/ # NEW +│ │ └── event_template_ticket_type.json +│ ├── event_template_add_on/ # NEW +│ │ └── event_template_add_on.json +│ └── event_template_custom_field/ # NEW +│ └── event_template_custom_field.json +└── public/ + └── js/ + └── events/ + └── create_from_template.js # NEW: Dialog logic for both directions +``` + +--- + +## Part 7: Unit Tests + +### 7.1 Test File Location + +**File:** `buzz/events/doctype/event_template/test_event_template.py` + +### 7.2 Test Cases + +```python +import frappe +from frappe.tests.utils import FrappeTestCase +from buzz.events.doctype.buzz_event.buzz_event import create_from_template +from buzz.events.doctype.event_template.event_template import create_template_from_event + + +class TestEventTemplate(FrappeTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create test fixtures + cls.create_test_fixtures() + + @classmethod + def create_test_fixtures(cls): + """Create required test data: Event Category, Host, etc.""" + # Create Event Category if not exists + if not frappe.db.exists("Event Category", "Test Category"): + frappe.get_doc({ + "doctype": "Event Category", + "category_name": "Test Category" + }).insert() + + # Create Event Host if not exists + if not frappe.db.exists("Event Host", "Test Host"): + frappe.get_doc({ + "doctype": "Event Host", + "host_name": "Test Host" + }).insert() + + def tearDown(self): + """Clean up test data after each test""" + frappe.db.rollback() + + # ==================== Template Creation Tests ==================== + + def test_create_template_basic(self): + """Test creating a basic Event Template""" + template = frappe.get_doc({ + "doctype": "Event Template", + "template_name": "Test Webinar Template", + "category": "Test Category", + "host": "Test Host", + "medium": "Online", + "about": "Test description" + }) + template.insert() + + self.assertEqual(template.template_name, "Test Webinar Template") + self.assertEqual(template.category, "Test Category") + self.assertEqual(template.medium, "Online") + + def test_create_template_with_ticket_types(self): + """Test creating a template with ticket types""" + template = frappe.get_doc({ + "doctype": "Event Template", + "template_name": "Template with Tickets", + "category": "Test Category", + "host": "Test Host", + "template_ticket_types": [ + { + "title": "Early Bird", + "price": 100, + "currency": "INR", + "is_published": 1, + "max_tickets_available": 50 + }, + { + "title": "Regular", + "price": 200, + "currency": "INR", + "is_published": 1 + } + ] + }) + template.insert() + + self.assertEqual(len(template.template_ticket_types), 2) + self.assertEqual(template.template_ticket_types[0].title, "Early Bird") + self.assertEqual(template.template_ticket_types[0].price, 100) + + def test_create_template_with_add_ons(self): + """Test creating a template with add-ons""" + template = frappe.get_doc({ + "doctype": "Event Template", + "template_name": "Template with Add-ons", + "category": "Test Category", + "host": "Test Host", + "template_add_ons": [ + { + "title": "T-Shirt", + "price": 500, + "currency": "INR", + "enabled": 1 + }, + { + "title": "Lunch", + "price": 300, + "currency": "INR", + "user_selects_option": 1, + "options": "Veg\nNon-Veg", + "enabled": 1 + } + ] + }) + template.insert() + + self.assertEqual(len(template.template_add_ons), 2) + self.assertEqual(template.template_add_ons[1].user_selects_option, 1) + + def test_create_template_with_custom_fields(self): + """Test creating a template with custom fields""" + template = frappe.get_doc({ + "doctype": "Event Template", + "template_name": "Template with Custom Fields", + "category": "Test Category", + "host": "Test Host", + "template_custom_fields": [ + { + "label": "Company Name", + "fieldname": "company_name", + "fieldtype": "Data", + "applied_to": "Booking", + "mandatory": 1, + "enabled": 1 + }, + { + "label": "Dietary Preference", + "fieldname": "dietary_preference", + "fieldtype": "Select", + "options": "Veg\nNon-Veg\nVegan", + "applied_to": "Ticket", + "enabled": 1 + } + ] + }) + template.insert() + + self.assertEqual(len(template.template_custom_fields), 2) + self.assertEqual(template.template_custom_fields[0].mandatory, 1) + + # ==================== Create Event from Template Tests ==================== + + def test_create_event_from_template_all_options(self): + """Test creating an event from template with all options selected""" + # Create template + template = frappe.get_doc({ + "doctype": "Event Template", + "template_name": "Full Template", + "category": "Test Category", + "host": "Test Host", + "medium": "Online", + "about": "Template about text", + "apply_tax": 1, + "tax_label": "GST", + "tax_percentage": 18, + "template_ticket_types": [ + {"title": "Standard", "price": 500, "currency": "INR", "is_published": 1} + ], + "template_add_ons": [ + {"title": "Workshop", "price": 1000, "currency": "INR", "enabled": 1} + ], + "template_custom_fields": [ + {"label": "Phone", "fieldname": "phone", "fieldtype": "Phone", "applied_to": "Booking", "enabled": 1} + ] + }) + template.insert() + + # Create event from template with all options + options = { + "category": 1, + "host": 1, + "medium": 1, + "about": 1, + "apply_tax": 1, + "tax_label": 1, + "tax_percentage": 1, + "ticket_types": 1, + "add_ons": 1, + "custom_fields": 1 + } + + event_name = create_from_template(template.name, frappe.as_json(options)) + event = frappe.get_doc("Buzz Event", event_name) + + # Verify event fields + self.assertEqual(event.category, "Test Category") + self.assertEqual(event.host, "Test Host") + self.assertEqual(event.medium, "Online") + self.assertEqual(event.about, "Template about text") + self.assertEqual(event.apply_tax, 1) + self.assertEqual(event.tax_percentage, 18) + + # Verify ticket types created + ticket_types = frappe.get_all("Event Ticket Type", + filters={"event": event_name}, + fields=["title", "price"] + ) + self.assertEqual(len(ticket_types), 1) + self.assertEqual(ticket_types[0].title, "Standard") + + # Verify add-ons created + add_ons = frappe.get_all("Ticket Add-on", + filters={"event": event_name}, + fields=["title", "price"] + ) + self.assertEqual(len(add_ons), 1) + self.assertEqual(add_ons[0].title, "Workshop") + + # Verify custom fields created + custom_fields = frappe.get_all("Buzz Custom Field", + filters={"event": event_name}, + fields=["label", "fieldtype"] + ) + self.assertEqual(len(custom_fields), 1) + self.assertEqual(custom_fields[0].fieldtype, "Phone") + + def test_create_event_from_template_partial_options(self): + """Test creating an event with only some options selected""" + template = frappe.get_doc({ + "doctype": "Event Template", + "template_name": "Partial Template", + "category": "Test Category", + "host": "Test Host", + "medium": "In Person", + "about": "Should not be copied", + "template_ticket_types": [ + {"title": "VIP", "price": 2000, "currency": "INR", "is_published": 1} + ] + }) + template.insert() + + # Only copy category and ticket types + options = { + "category": 1, + "host": 0, + "medium": 0, + "about": 0, + "ticket_types": 1 + } + + event_name = create_from_template(template.name, frappe.as_json(options)) + event = frappe.get_doc("Buzz Event", event_name) + + # Category should be copied + self.assertEqual(event.category, "Test Category") + + # Host should NOT be copied + self.assertFalse(event.host) + + # About should NOT be copied + self.assertFalse(event.about) + + # Ticket types should be copied + ticket_types = frappe.get_all("Event Ticket Type", filters={"event": event_name}) + self.assertEqual(len(ticket_types), 1) + + def test_create_event_from_template_no_linked_docs(self): + """Test creating an event without copying linked documents""" + template = frappe.get_doc({ + "doctype": "Event Template", + "template_name": "No Linked Docs Template", + "category": "Test Category", + "host": "Test Host", + "template_ticket_types": [ + {"title": "General", "price": 100, "currency": "INR", "is_published": 1} + ] + }) + template.insert() + + # Copy fields but not linked docs + options = { + "category": 1, + "host": 1, + "ticket_types": 0, + "add_ons": 0, + "custom_fields": 0 + } + + event_name = create_from_template(template.name, frappe.as_json(options)) + + # Event fields should be copied + event = frappe.get_doc("Buzz Event", event_name) + self.assertEqual(event.category, "Test Category") + + # No ticket types should be created (except default) + ticket_types = frappe.get_all("Event Ticket Type", + filters={"event": event_name, "title": "General"} + ) + self.assertEqual(len(ticket_types), 0) + + # ==================== Save as Template Tests ==================== + + def test_save_event_as_template(self): + """Test saving an existing event as a template""" + # Create an event with ticket types and add-ons + event = frappe.get_doc({ + "doctype": "Buzz Event", + "title": "Source Event", + "category": "Test Category", + "host": "Test Host", + "start_date": frappe.utils.today(), + "medium": "Online", + "about": "Event description" + }) + event.insert() + + # Create ticket type for the event + ticket_type = frappe.get_doc({ + "doctype": "Event Ticket Type", + "event": event.name, + "title": "Premium", + "price": 1500, + "currency": "INR", + "is_published": 1 + }) + ticket_type.insert() + + # Create add-on for the event + add_on = frappe.get_doc({ + "doctype": "Ticket Add-on", + "event": event.name, + "title": "Swag Kit", + "price": 500, + "currency": "INR", + "enabled": 1 + }) + add_on.insert() + + # Save as template + options = { + "category": 1, + "host": 1, + "medium": 1, + "about": 1, + "ticket_types": 1, + "add_ons": 1 + } + + template_name = create_template_from_event( + event.name, + "My Event Template", + frappe.as_json(options) + ) + template = frappe.get_doc("Event Template", template_name) + + # Verify template fields + self.assertEqual(template.template_name, "My Event Template") + self.assertEqual(template.category, "Test Category") + self.assertEqual(template.medium, "Online") + + # Verify ticket types in template + self.assertEqual(len(template.template_ticket_types), 1) + self.assertEqual(template.template_ticket_types[0].title, "Premium") + self.assertEqual(template.template_ticket_types[0].price, 1500) + + # Verify add-ons in template + self.assertEqual(len(template.template_add_ons), 1) + self.assertEqual(template.template_add_ons[0].title, "Swag Kit") + + def test_save_event_as_template_partial(self): + """Test saving event as template with only some options""" + event = frappe.get_doc({ + "doctype": "Buzz Event", + "title": "Partial Source Event", + "category": "Test Category", + "host": "Test Host", + "start_date": frappe.utils.today(), + "medium": "In Person", + "about": "Should be copied", + "apply_tax": 1, + "tax_percentage": 18 + }) + event.insert() + + # Only save category and about + options = { + "category": 1, + "host": 0, + "medium": 0, + "about": 1, + "apply_tax": 0 + } + + template_name = create_template_from_event( + event.name, + "Partial Template", + frappe.as_json(options) + ) + template = frappe.get_doc("Event Template", template_name) + + self.assertEqual(template.category, "Test Category") + self.assertEqual(template.about, "Should be copied") + self.assertFalse(template.host) + self.assertFalse(template.medium) + self.assertFalse(template.apply_tax) + + # ==================== Round Trip Tests ==================== + + def test_round_trip_event_to_template_to_event(self): + """Test full round trip: Event -> Template -> New Event""" + # Step 1: Create original event + original_event = frappe.get_doc({ + "doctype": "Buzz Event", + "title": "Original Conference", + "category": "Test Category", + "host": "Test Host", + "start_date": frappe.utils.today(), + "medium": "In Person", + "about": "Annual conference description", + "apply_tax": 1, + "tax_label": "GST", + "tax_percentage": 18 + }) + original_event.insert() + + # Add ticket types + for ticket_data in [ + {"title": "Early Bird", "price": 1000}, + {"title": "Regular", "price": 1500}, + {"title": "VIP", "price": 3000} + ]: + frappe.get_doc({ + "doctype": "Event Ticket Type", + "event": original_event.name, + "title": ticket_data["title"], + "price": ticket_data["price"], + "currency": "INR", + "is_published": 1 + }).insert() + + # Step 2: Save as template + template_options = { + "category": 1, + "host": 1, + "medium": 1, + "about": 1, + "apply_tax": 1, + "tax_label": 1, + "tax_percentage": 1, + "ticket_types": 1 + } + template_name = create_template_from_event( + original_event.name, + "Conference Template", + frappe.as_json(template_options) + ) + + # Step 3: Create new event from template + event_options = { + "category": 1, + "host": 1, + "medium": 1, + "about": 1, + "apply_tax": 1, + "tax_label": 1, + "tax_percentage": 1, + "ticket_types": 1 + } + new_event_name = create_from_template(template_name, frappe.as_json(event_options)) + new_event = frappe.get_doc("Buzz Event", new_event_name) + + # Verify new event matches original + self.assertEqual(new_event.category, original_event.category) + self.assertEqual(new_event.host, original_event.host) + self.assertEqual(new_event.medium, original_event.medium) + self.assertEqual(new_event.about, original_event.about) + self.assertEqual(new_event.tax_percentage, original_event.tax_percentage) + + # Verify ticket types match + new_ticket_types = frappe.get_all("Event Ticket Type", + filters={"event": new_event_name}, + fields=["title", "price"], + order_by="price" + ) + self.assertEqual(len(new_ticket_types), 3) + self.assertEqual(new_ticket_types[0].title, "Early Bird") + self.assertEqual(new_ticket_types[0].price, 1000) + + # ==================== Edge Case Tests ==================== + + def test_create_event_empty_template(self): + """Test creating event from template with no optional data""" + template = frappe.get_doc({ + "doctype": "Event Template", + "template_name": "Empty Template" + }) + template.insert() + + options = {"category": 1, "host": 1} + event_name = create_from_template(template.name, frappe.as_json(options)) + + # Should create event without errors + self.assertTrue(frappe.db.exists("Buzz Event", event_name)) + + def test_template_name_required(self): + """Test that template_name is required""" + template = frappe.get_doc({ + "doctype": "Event Template", + "category": "Test Category" + }) + + with self.assertRaises(frappe.exceptions.MandatoryError): + template.insert() + + def test_duplicate_template_name(self): + """Test handling of duplicate template names""" + frappe.get_doc({ + "doctype": "Event Template", + "template_name": "Duplicate Name" + }).insert() + + duplicate = frappe.get_doc({ + "doctype": "Event Template", + "template_name": "Duplicate Name" + }) + + with self.assertRaises(frappe.exceptions.DuplicateEntryError): + duplicate.insert() + + # ==================== Permission Tests ==================== + + def test_event_manager_can_create_template(self): + """Test that Event Manager role can create templates""" + # Create test user with Event Manager role + if not frappe.db.exists("User", "event_manager@test.com"): + user = frappe.get_doc({ + "doctype": "User", + "email": "event_manager@test.com", + "first_name": "Event", + "last_name": "Manager", + "roles": [{"role": "Event Manager"}] + }) + user.insert() + + frappe.set_user("event_manager@test.com") + + try: + template = frappe.get_doc({ + "doctype": "Event Template", + "template_name": "Manager Template" + }) + template.insert() + self.assertTrue(frappe.db.exists("Event Template", template.name)) + finally: + frappe.set_user("Administrator") + + def test_guest_cannot_create_template(self): + """Test that Guest cannot create templates""" + frappe.set_user("Guest") + + try: + template = frappe.get_doc({ + "doctype": "Event Template", + "template_name": "Guest Template" + }) + + with self.assertRaises(frappe.exceptions.PermissionError): + template.insert() + finally: + frappe.set_user("Administrator") +``` + +### 7.3 Test Fixtures + +Create test fixtures file for reusable test data: + +**File:** `buzz/events/doctype/event_template/test_records.json` + +```json +[ + { + "doctype": "Event Template", + "template_name": "Webinar Template", + "category": "Webinars", + "host": "Frappe", + "medium": "Online", + "about": "Standard webinar template", + "apply_tax": 1, + "tax_label": "GST", + "tax_percentage": 18, + "template_ticket_types": [ + { + "title": "Free", + "price": 0, + "currency": "INR", + "is_published": 1 + } + ] + }, + { + "doctype": "Event Template", + "template_name": "Conference Template", + "category": "Conferences", + "host": "Frappe", + "medium": "In Person", + "about": "Annual conference template", + "template_ticket_types": [ + { + "title": "Early Bird", + "price": 1000, + "currency": "INR", + "is_published": 1 + }, + { + "title": "Regular", + "price": 1500, + "currency": "INR", + "is_published": 1 + } + ], + "template_add_ons": [ + { + "title": "Workshop Access", + "price": 500, + "currency": "INR", + "enabled": 1 + } + ] + } +] +``` + +### 7.4 Running Tests + +```bash +# Run all event template tests +bench --site your-site run-tests --module buzz.events.doctype.event_template + +# Run specific test +bench --site your-site run-tests --module buzz.events.doctype.event_template.test_event_template --test test_create_event_from_template_all_options + +# Run with verbose output +bench --site your-site run-tests --module buzz.events.doctype.event_template -v +``` + +--- + +## Questions for Clarification + +1. **Default Ticket Type**: The Buzz Event has a `default_ticket_type` field. Should templates store this preference, and if multiple ticket types are copied, which becomes the default? + +--- + +## Summary + +This feature involves: +- **4 new DocTypes** (1 main + 3 child tables) +- **2 new JavaScript files** + 1 update to existing buzz_event.js +- **2 Python API methods** (create_from_template, create_template_from_event) +- **Permissions** for Event Manager role +- **Unit tests** with comprehensive test coverage +- Updates to hooks.py for JS inclusion + +### Two User Flows: +1. **Create from Template** (List View → Dialog → New Event) +2. **Save as Template** (Event Form → Dialog → New Template) + +### Test Coverage: +- Template creation tests (basic, with ticket types, add-ons, custom fields) +- Create event from template tests (all options, partial options, no linked docs) +- Save as template tests (full, partial) +- Round trip tests (Event → Template → New Event) +- Edge case tests (empty template, required fields, duplicates) +- Permission tests (Event Manager, Guest) + +The implementation follows established Frappe/ERPNext patterns for: +- List view buttons (`listview.page.add_inner_button`) +- Dynamic dialogs with conditional fields +- MultiCheck or custom HTML for checkbox groups +- Whitelisted API methods for document creation diff --git a/buzz/events/doctype/buzz_event/buzz_event.js b/buzz/events/doctype/buzz_event/buzz_event.js index 1ef8db4..8ae8384 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.js +++ b/buzz/events/doctype/buzz_event/buzz_event.js @@ -38,6 +38,17 @@ frappe.ui.form.on("Buzz Event", { }; }); + // Save as Template button + if (!frm.is_new()) { + frm.add_custom_button( + __("Save as Template"), + function () { + buzz.events.show_save_as_template_dialog(frm); + }, + __("Actions") + ); + } + frm.trigger("add_zoom_custom_actions"); }, diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index 5243e08..68f431d 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -2,6 +2,7 @@ # For license information, please see license.txt import frappe +from frappe import _ from frappe.model.document import Document from frappe.utils.data import time_diff_in_seconds @@ -127,3 +128,117 @@ def update_zoom_webinar(self): } ) webinar.save() + + +@frappe.whitelist() +def create_from_template(template_name: str, options: str, additional_fields: str = "{}") -> str: + """ + Create a new Buzz Event from a template. + + Args: + template_name: Name of the Event Template + options: JSON string of what to copy (e.g., {"category": 1, "ticket_types": 1, ...}) + additional_fields: JSON string of additional field values for mandatory fields not in template + + Returns: + New Buzz Event document name + """ + if not frappe.has_permission("Event Template", "read"): + frappe.throw(_("You don't have permission to use templates")) + + if not frappe.has_permission("Buzz Event", "create"): + frappe.throw(_("You don't have permission to create events")) + + template = frappe.get_doc("Event Template", template_name) + options = frappe.parse_json(options) + additional_fields = frappe.parse_json(additional_fields) + + # Create new event with required fields + event = frappe.new_doc("Buzz Event") + event.title = f"New Event from {template.template_name}" + event.start_date = frappe.utils.today() + + # Apply additional fields first (these are mandatory fields provided by user) + for field, value in additional_fields.items(): + if value: + event.set(field, value) + + # Field mapping for direct copy + field_map = { + "category": "category", + "host": "host", + "banner_image": "banner_image", + "short_description": "short_description", + "about": "about", + "medium": "medium", + "venue": "venue", + "time_zone": "time_zone", + "ticket_email_template": "ticket_email_template", + "ticket_print_format": "ticket_print_format", + "apply_tax": "apply_tax", + "tax_label": "tax_label", + "tax_percentage": "tax_percentage", + "auto_send_pitch_deck": "auto_send_pitch_deck", + "sponsor_deck_email_template": "sponsor_deck_email_template", + "sponsor_deck_reply_to": "sponsor_deck_reply_to", + "sponsor_deck_cc": "sponsor_deck_cc", + } + + for option_key, field_name in field_map.items(): + if options.get(option_key): + event.set(field_name, template.get(field_name)) + + # Copy child tables + if options.get("payment_gateways"): + for pg in template.payment_gateways: + event.append("payment_gateways", {"payment_gateway": pg.payment_gateway}) + + if options.get("sponsor_deck_attachments"): + for attachment in template.sponsor_deck_attachments: + event.append("sponsor_deck_attachments", {"file": attachment.file}) + + event.insert() + + # Create linked documents (Ticket Types, Add-ons, Custom Fields) + if options.get("ticket_types"): + for tt in template.template_ticket_types: + ticket_type = frappe.new_doc("Event Ticket Type") + ticket_type.event = event.name + ticket_type.title = tt.title + ticket_type.price = tt.price + ticket_type.currency = tt.currency + ticket_type.is_published = tt.is_published + ticket_type.max_tickets_available = tt.max_tickets_available + ticket_type.auto_unpublish_after = tt.auto_unpublish_after + ticket_type.insert() + + if options.get("add_ons"): + for addon in template.template_add_ons: + add_on = frappe.new_doc("Ticket Add-on") + add_on.event = event.name + add_on.title = addon.title + add_on.price = addon.price + add_on.currency = addon.currency + add_on.description = addon.description + add_on.user_selects_option = addon.user_selects_option + add_on.options = addon.options + add_on.enabled = addon.enabled + add_on.insert() + + if options.get("custom_fields"): + for cf in template.template_custom_fields: + custom_field = frappe.new_doc("Buzz Custom Field") + custom_field.event = event.name + custom_field.label = cf.label + custom_field.fieldname = cf.fieldname + custom_field.fieldtype = cf.fieldtype + custom_field.options = cf.options + custom_field.applied_to = cf.applied_to + custom_field.enabled = cf.enabled + custom_field.mandatory = cf.mandatory + custom_field.placeholder = cf.placeholder + custom_field.default_value = cf.default_value + custom_field.order = cf.order + custom_field.insert() + + return event.name diff --git a/buzz/events/doctype/buzz_event/buzz_event_list.js b/buzz/events/doctype/buzz_event/buzz_event_list.js new file mode 100644 index 0000000..9f51477 --- /dev/null +++ b/buzz/events/doctype/buzz_event/buzz_event_list.js @@ -0,0 +1,9 @@ +frappe.listview_settings["Buzz Event"] = { + onload: function (listview) { + if (frappe.perm.has_perm("Event Template", 0, "read")) { + listview.page.add_inner_button(__("Create from Template"), function () { + buzz.events.show_create_from_template_dialog(); + }); + } + }, +}; diff --git a/buzz/events/doctype/event_template/__init__.py b/buzz/events/doctype/event_template/__init__.py new file mode 100644 index 0000000..f66d03d --- /dev/null +++ b/buzz/events/doctype/event_template/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt diff --git a/buzz/events/doctype/event_template/event_template.json b/buzz/events/doctype/event_template/event_template.json new file mode 100644 index 0000000..c168e43 --- /dev/null +++ b/buzz/events/doctype/event_template/event_template.json @@ -0,0 +1,303 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:template_name", + "creation": "2025-12-31 12:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "template_name", + "category", + "medium", + "column_break_main", + "banner_image", + "host", + "venue", + "section_break_details", + "time_zone", + "short_description", + "about", + "payments_tab", + "section_break_payments", + "payment_gateways", + "tax_settings_section", + "apply_tax", + "tax_label", + "tax_percentage", + "sponsorships_tab", + "automations_section", + "auto_send_pitch_deck", + "section_break_sponsor", + "sponsor_deck_reply_to", + "sponsor_deck_email_template", + "column_break_sponsor", + "sponsor_deck_cc", + "section_break_attachments", + "sponsor_deck_attachments", + "customisations_tab", + "ticket_email_template", + "column_break_custom", + "ticket_print_format", + "ticket_types_tab", + "template_ticket_types", + "add_ons_tab", + "template_add_ons", + "custom_fields_tab", + "template_custom_fields" + ], + "fields": [ + { + "fieldname": "template_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Template Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "category", + "fieldtype": "Link", + "label": "Category", + "options": "Event Category" + }, + { + "default": "In Person", + "fieldname": "medium", + "fieldtype": "Select", + "label": "Medium", + "options": "In Person\nOnline" + }, + { + "fieldname": "column_break_main", + "fieldtype": "Column Break" + }, + { + "fieldname": "banner_image", + "fieldtype": "Attach Image", + "label": "Banner Image" + }, + { + "fieldname": "host", + "fieldtype": "Link", + "label": "Host", + "options": "Event Host" + }, + { + "depends_on": "eval:doc.medium!=\"Online\"", + "fieldname": "venue", + "fieldtype": "Link", + "label": "Venue", + "options": "Event Venue" + }, + { + "fieldname": "section_break_details", + "fieldtype": "Section Break" + }, + { + "fieldname": "time_zone", + "fieldtype": "Autocomplete", + "label": "Time Zone" + }, + { + "fieldname": "short_description", + "fieldtype": "Small Text", + "label": "Short Description" + }, + { + "fieldname": "about", + "fieldtype": "Text Editor", + "label": "About" + }, + { + "fieldname": "payments_tab", + "fieldtype": "Tab Break", + "label": "Payments" + }, + { + "fieldname": "section_break_payments", + "fieldtype": "Section Break" + }, + { + "fieldname": "payment_gateways", + "fieldtype": "Table", + "label": "Payment Gateways", + "options": "Event Payment Gateway" + }, + { + "fieldname": "tax_settings_section", + "fieldtype": "Section Break", + "label": "Tax Settings" + }, + { + "default": "0", + "fieldname": "apply_tax", + "fieldtype": "Check", + "label": "Apply Tax on Bookings?" + }, + { + "default": "GST", + "depends_on": "eval:doc.apply_tax==1", + "fieldname": "tax_label", + "fieldtype": "Data", + "label": "Tax Label" + }, + { + "default": "18", + "depends_on": "eval:doc.apply_tax==1", + "fieldname": "tax_percentage", + "fieldtype": "Percent", + "label": "Tax Percentage" + }, + { + "fieldname": "sponsorships_tab", + "fieldtype": "Tab Break", + "label": "Sponsorships" + }, + { + "fieldname": "automations_section", + "fieldtype": "Section Break", + "label": "Automations" + }, + { + "default": "0", + "fieldname": "auto_send_pitch_deck", + "fieldtype": "Check", + "label": "Auto Send Pitch Deck?" + }, + { + "fieldname": "section_break_sponsor", + "fieldtype": "Section Break" + }, + { + "depends_on": "eval:doc.auto_send_pitch_deck==true", + "fieldname": "sponsor_deck_reply_to", + "fieldtype": "Data", + "label": "Reply To" + }, + { + "depends_on": "eval:doc.auto_send_pitch_deck==true", + "fieldname": "sponsor_deck_email_template", + "fieldtype": "Link", + "label": "Email Template", + "options": "Email Template" + }, + { + "fieldname": "column_break_sponsor", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.auto_send_pitch_deck==true", + "fieldname": "sponsor_deck_cc", + "fieldtype": "Small Text", + "label": "CC" + }, + { + "fieldname": "section_break_attachments", + "fieldtype": "Section Break" + }, + { + "depends_on": "eval:doc.auto_send_pitch_deck==true", + "fieldname": "sponsor_deck_attachments", + "fieldtype": "Table", + "label": "Attachments", + "options": "Sponsorship Deck Item" + }, + { + "fieldname": "customisations_tab", + "fieldtype": "Tab Break", + "label": "Customisations" + }, + { + "fieldname": "ticket_email_template", + "fieldtype": "Link", + "label": "Ticket Email Template", + "options": "Email Template" + }, + { + "fieldname": "column_break_custom", + "fieldtype": "Column Break" + }, + { + "fieldname": "ticket_print_format", + "fieldtype": "Link", + "label": "Ticket Print Format", + "link_filters": "[[\"Print Format\",\"doc_type\",\"=\",\"Event Ticket\"]]", + "options": "Print Format" + }, + { + "fieldname": "ticket_types_tab", + "fieldtype": "Tab Break", + "label": "Ticket Types" + }, + { + "fieldname": "template_ticket_types", + "fieldtype": "Table", + "label": "Ticket Types", + "options": "Event Template Ticket Type" + }, + { + "fieldname": "add_ons_tab", + "fieldtype": "Tab Break", + "label": "Add-ons" + }, + { + "fieldname": "template_add_ons", + "fieldtype": "Table", + "label": "Add-ons", + "options": "Event Template Add-on" + }, + { + "fieldname": "custom_fields_tab", + "fieldtype": "Tab Break", + "label": "Custom Fields" + }, + { + "fieldname": "template_custom_fields", + "fieldtype": "Table", + "label": "Custom Fields", + "options": "Event Template Custom Field" + } + ], + "image_field": "banner_image", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-12-31 12:00:00.000000", + "modified_by": "Administrator", + "module": "Events", + "name": "Event Template", + "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", + "select": 1, + "share": 1, + "write": 1 + } + ], + "show_title_field_in_link": 1, + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "template_name", + "track_changes": 1 +} diff --git a/buzz/events/doctype/event_template/event_template.py b/buzz/events/doctype/event_template/event_template.py new file mode 100644 index 0000000..457b19f --- /dev/null +++ b/buzz/events/doctype/event_template/event_template.py @@ -0,0 +1,194 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class EventTemplate(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 buzz.events.doctype.event_payment_gateway.event_payment_gateway import EventPaymentGateway + from buzz.events.doctype.event_template_add_on.event_template_add_on import EventTemplateAddOn + from buzz.events.doctype.event_template_custom_field.event_template_custom_field import ( + EventTemplateCustomField, + ) + from buzz.events.doctype.event_template_ticket_type.event_template_ticket_type import ( + EventTemplateTicketType, + ) + from buzz.proposals.doctype.sponsorship_deck_item.sponsorship_deck_item import SponsorshipDeckItem + + about: DF.TextEditor | None + apply_tax: DF.Check + auto_send_pitch_deck: DF.Check + banner_image: DF.AttachImage | None + category: DF.Link | None + host: DF.Link | None + medium: DF.Literal["In Person", "Online"] + payment_gateways: DF.Table[EventPaymentGateway] + short_description: DF.SmallText | None + sponsor_deck_attachments: DF.Table[SponsorshipDeckItem] + sponsor_deck_cc: DF.SmallText | None + sponsor_deck_email_template: DF.Link | None + sponsor_deck_reply_to: DF.Data | None + tax_label: DF.Data | None + tax_percentage: DF.Percent + template_add_ons: DF.Table[EventTemplateAddOn] + template_custom_fields: DF.Table[EventTemplateCustomField] + template_name: DF.Data + template_ticket_types: DF.Table[EventTemplateTicketType] + ticket_email_template: DF.Link | None + ticket_print_format: DF.Link | None + time_zone: DF.Autocomplete | None + venue: DF.Link | None + # end: auto-generated types + + pass + + +@frappe.whitelist() +def create_template_from_event(event_name: str, template_name: str, options: str) -> str: + """ + Create an Event Template from an existing Buzz Event. + + Args: + event_name: Name of the source Buzz Event + template_name: Name for the new template + options: JSON string of what to include + + Returns: + New Event Template document name + """ + if not frappe.has_permission("Event Template", "create"): + frappe.throw(_("You don't have permission to create templates")) + + event = frappe.get_doc("Buzz Event", event_name) + options = frappe.parse_json(options) + + template = frappe.new_doc("Event Template") + template.template_name = template_name + + # Field mapping for direct copy + field_map = { + "category": "category", + "host": "host", + "banner_image": "banner_image", + "short_description": "short_description", + "about": "about", + "medium": "medium", + "venue": "venue", + "time_zone": "time_zone", + "ticket_email_template": "ticket_email_template", + "ticket_print_format": "ticket_print_format", + "apply_tax": "apply_tax", + "tax_label": "tax_label", + "tax_percentage": "tax_percentage", + "auto_send_pitch_deck": "auto_send_pitch_deck", + "sponsor_deck_email_template": "sponsor_deck_email_template", + "sponsor_deck_reply_to": "sponsor_deck_reply_to", + "sponsor_deck_cc": "sponsor_deck_cc", + } + + for option_key, field_name in field_map.items(): + if options.get(option_key): + template.set(field_name, event.get(field_name)) + + # Copy child tables from event + if options.get("payment_gateways"): + for pg in event.payment_gateways: + template.append("payment_gateways", {"payment_gateway": pg.payment_gateway}) + + if options.get("sponsor_deck_attachments"): + for attachment in event.sponsor_deck_attachments: + template.append("sponsor_deck_attachments", {"file": attachment.file}) + + # Copy linked documents (Ticket Types, Add-ons, Custom Fields) + if options.get("ticket_types"): + ticket_types = frappe.get_all( + "Event Ticket Type", + filters={"event": event_name}, + fields=[ + "title", + "price", + "currency", + "is_published", + "max_tickets_available", + "auto_unpublish_after", + ], + ) + for tt in ticket_types: + template.append( + "template_ticket_types", + { + "title": tt.title, + "price": tt.price, + "currency": tt.currency, + "is_published": tt.is_published, + "max_tickets_available": tt.max_tickets_available, + "auto_unpublish_after": tt.auto_unpublish_after, + }, + ) + + if options.get("add_ons"): + add_ons = frappe.get_all( + "Ticket Add-on", + filters={"event": event_name}, + fields=["title", "price", "currency", "description", "user_selects_option", "options", "enabled"], + ) + for addon in add_ons: + template.append( + "template_add_ons", + { + "title": addon.title, + "price": addon.price, + "currency": addon.currency, + "description": addon.description, + "user_selects_option": addon.user_selects_option, + "options": addon.options, + "enabled": addon.enabled, + }, + ) + + if options.get("custom_fields"): + custom_fields = frappe.get_all( + "Buzz Custom Field", + filters={"event": event_name}, + fields=[ + "label", + "fieldname", + "fieldtype", + "options", + "applied_to", + "enabled", + "mandatory", + "placeholder", + "default_value", + "order", + ], + ) + for cf in custom_fields: + template.append( + "template_custom_fields", + { + "label": cf.label, + "fieldname": cf.fieldname, + "fieldtype": cf.fieldtype, + "options": cf.options, + "applied_to": cf.applied_to, + "enabled": cf.enabled, + "mandatory": cf.mandatory, + "placeholder": cf.placeholder, + "default_value": cf.default_value, + "order": cf.order, + }, + ) + + template.insert() + return template.name diff --git a/buzz/events/doctype/event_template/test_event_template.py b/buzz/events/doctype/event_template/test_event_template.py new file mode 100644 index 0000000..81ec51c --- /dev/null +++ b/buzz/events/doctype/event_template/test_event_template.py @@ -0,0 +1,499 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase + +from buzz.events.doctype.buzz_event.buzz_event import create_from_template +from buzz.events.doctype.event_template.event_template import create_template_from_event + + +class TestEventTemplate(FrappeTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.create_test_fixtures() + + @classmethod + def create_test_fixtures(cls): + """Create required test data: Event Category, Host, etc.""" + # Create Event Category if not exists + if not frappe.db.exists("Event Category", "Test Category"): + frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert( + ignore_permissions=True + ) + + # Create Event Host if not exists + if not frappe.db.exists("Event Host", "Test Host"): + frappe.get_doc({"doctype": "Event Host", "host_name": "Test Host"}).insert( + ignore_permissions=True + ) + + def tearDown(self): + """Clean up test data after each test""" + frappe.db.rollback() + + # ==================== Template Creation Tests ==================== + + def test_create_template_basic(self): + """Test creating a basic Event Template""" + template = frappe.get_doc( + { + "doctype": "Event Template", + "template_name": "Test Webinar Template", + "category": "Test Category", + "host": "Test Host", + "medium": "Online", + "about": "Test description", + } + ) + template.insert() + + self.assertEqual(template.template_name, "Test Webinar Template") + self.assertEqual(template.category, "Test Category") + self.assertEqual(template.medium, "Online") + + def test_create_template_with_ticket_types(self): + """Test creating a template with ticket types""" + template = frappe.get_doc( + { + "doctype": "Event Template", + "template_name": "Template with Tickets", + "category": "Test Category", + "host": "Test Host", + "template_ticket_types": [ + { + "title": "Early Bird", + "price": 100, + "currency": "INR", + "is_published": 1, + "max_tickets_available": 50, + }, + {"title": "Regular", "price": 200, "currency": "INR", "is_published": 1}, + ], + } + ) + template.insert() + + self.assertEqual(len(template.template_ticket_types), 2) + self.assertEqual(template.template_ticket_types[0].title, "Early Bird") + self.assertEqual(template.template_ticket_types[0].price, 100) + + def test_create_template_with_add_ons(self): + """Test creating a template with add-ons""" + template = frappe.get_doc( + { + "doctype": "Event Template", + "template_name": "Template with Add-ons", + "category": "Test Category", + "host": "Test Host", + "template_add_ons": [ + {"title": "T-Shirt", "price": 500, "currency": "INR", "enabled": 1}, + { + "title": "Lunch", + "price": 300, + "currency": "INR", + "user_selects_option": 1, + "options": "Veg\nNon-Veg", + "enabled": 1, + }, + ], + } + ) + template.insert() + + self.assertEqual(len(template.template_add_ons), 2) + self.assertEqual(template.template_add_ons[1].user_selects_option, 1) + + def test_create_template_with_custom_fields(self): + """Test creating a template with custom fields""" + template = frappe.get_doc( + { + "doctype": "Event Template", + "template_name": "Template with Custom Fields", + "category": "Test Category", + "host": "Test Host", + "template_custom_fields": [ + { + "label": "Company Name", + "fieldname": "company_name", + "fieldtype": "Data", + "applied_to": "Booking", + "mandatory": 1, + "enabled": 1, + }, + { + "label": "Dietary Preference", + "fieldname": "dietary_preference", + "fieldtype": "Select", + "options": "Veg\nNon-Veg\nVegan", + "applied_to": "Ticket", + "enabled": 1, + }, + ], + } + ) + template.insert() + + self.assertEqual(len(template.template_custom_fields), 2) + self.assertEqual(template.template_custom_fields[0].mandatory, 1) + + # ==================== Create Event from Template Tests ==================== + + def test_create_event_from_template_all_options(self): + """Test creating an event from template with all options selected""" + # Create template + template = frappe.get_doc( + { + "doctype": "Event Template", + "template_name": "Full Template", + "category": "Test Category", + "host": "Test Host", + "medium": "Online", + "about": "Template about text", + "apply_tax": 1, + "tax_label": "GST", + "tax_percentage": 18, + "template_ticket_types": [ + {"title": "Standard", "price": 500, "currency": "INR", "is_published": 1} + ], + "template_add_ons": [{"title": "Workshop", "price": 1000, "currency": "INR", "enabled": 1}], + "template_custom_fields": [ + { + "label": "Phone", + "fieldname": "phone", + "fieldtype": "Phone", + "applied_to": "Booking", + "enabled": 1, + } + ], + } + ) + template.insert() + + # Create event from template with all options + options = { + "category": 1, + "host": 1, + "medium": 1, + "about": 1, + "apply_tax": 1, + "tax_label": 1, + "tax_percentage": 1, + "ticket_types": 1, + "add_ons": 1, + "custom_fields": 1, + } + + event_name = create_from_template(template.name, frappe.as_json(options)) + event = frappe.get_doc("Buzz Event", event_name) + + # Verify event fields + self.assertEqual(event.category, "Test Category") + self.assertEqual(event.host, "Test Host") + self.assertEqual(event.medium, "Online") + self.assertEqual(event.about, "Template about text") + self.assertEqual(event.apply_tax, 1) + self.assertEqual(event.tax_percentage, 18) + + # Verify ticket types created (excluding default "Normal" ticket type) + ticket_types = frappe.get_all( + "Event Ticket Type", filters={"event": event_name, "title": "Standard"}, fields=["title", "price"] + ) + self.assertEqual(len(ticket_types), 1) + self.assertEqual(ticket_types[0].title, "Standard") + + # Verify add-ons created + add_ons = frappe.get_all("Ticket Add-on", filters={"event": event_name}, fields=["title", "price"]) + self.assertEqual(len(add_ons), 1) + self.assertEqual(add_ons[0].title, "Workshop") + + # Verify custom fields created + custom_fields = frappe.get_all( + "Buzz Custom Field", filters={"event": event_name}, fields=["label", "fieldtype"] + ) + self.assertEqual(len(custom_fields), 1) + self.assertEqual(custom_fields[0].fieldtype, "Phone") + + def test_create_event_from_template_partial_options(self): + """Test creating an event with only some options selected""" + template = frappe.get_doc( + { + "doctype": "Event Template", + "template_name": "Partial Template", + "category": "Test Category", + "host": "Test Host", + "medium": "In Person", + "about": "Should not be copied", + "template_ticket_types": [ + {"title": "VIP", "price": 2000, "currency": "INR", "is_published": 1} + ], + } + ) + template.insert() + + # Copy category, host (required) and ticket types, but not medium/about + options = {"category": 1, "host": 1, "medium": 0, "about": 0, "ticket_types": 1} + + event_name = create_from_template(template.name, frappe.as_json(options)) + event = frappe.get_doc("Buzz Event", event_name) + + # Category should be copied + self.assertEqual(event.category, "Test Category") + + # Host should be copied (it's mandatory) + self.assertEqual(event.host, "Test Host") + + # About should NOT be copied + self.assertFalse(event.about) + + # Ticket types should be copied + ticket_types = frappe.get_all("Event Ticket Type", filters={"event": event_name, "title": "VIP"}) + self.assertEqual(len(ticket_types), 1) + + def test_create_event_from_template_no_linked_docs(self): + """Test creating an event without copying linked documents""" + template = frappe.get_doc( + { + "doctype": "Event Template", + "template_name": "No Linked Docs Template", + "category": "Test Category", + "host": "Test Host", + "template_ticket_types": [ + {"title": "General", "price": 100, "currency": "INR", "is_published": 1} + ], + } + ) + template.insert() + + # Copy fields but not linked docs + options = {"category": 1, "host": 1, "ticket_types": 0, "add_ons": 0, "custom_fields": 0} + + event_name = create_from_template(template.name, frappe.as_json(options)) + + # Event fields should be copied + event = frappe.get_doc("Buzz Event", event_name) + self.assertEqual(event.category, "Test Category") + + # No "General" ticket type should be created (only default "Normal") + ticket_types = frappe.get_all("Event Ticket Type", filters={"event": event_name, "title": "General"}) + self.assertEqual(len(ticket_types), 0) + + # ==================== Save as Template Tests ==================== + + def test_save_event_as_template(self): + """Test saving an existing event as a template""" + # Create an event with ticket types and add-ons + event = frappe.get_doc( + { + "doctype": "Buzz Event", + "title": "Source Event", + "category": "Test Category", + "host": "Test Host", + "start_date": frappe.utils.today(), + "medium": "Online", + "about": "Event description", + } + ) + event.insert() + + # Create ticket type for the event + ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": event.name, + "title": "Premium", + "price": 1500, + "currency": "INR", + "is_published": 1, + } + ) + ticket_type.insert() + + # Create add-on for the event + add_on = frappe.get_doc( + { + "doctype": "Ticket Add-on", + "event": event.name, + "title": "Swag Kit", + "price": 500, + "currency": "INR", + "enabled": 1, + } + ) + add_on.insert() + + # Save as template (convert event.name to string as it's an int autoname) + options = {"category": 1, "host": 1, "medium": 1, "about": 1, "ticket_types": 1, "add_ons": 1} + + template_name = create_template_from_event( + str(event.name), "My Event Template", frappe.as_json(options) + ) + template = frappe.get_doc("Event Template", template_name) + + # Verify template fields + self.assertEqual(template.template_name, "My Event Template") + self.assertEqual(template.category, "Test Category") + self.assertEqual(template.medium, "Online") + + # Verify ticket types in template (excluding default "Normal") + premium_tickets = [t for t in template.template_ticket_types if t.title == "Premium"] + self.assertEqual(len(premium_tickets), 1) + self.assertEqual(premium_tickets[0].price, 1500) + + # Verify add-ons in template + self.assertEqual(len(template.template_add_ons), 1) + self.assertEqual(template.template_add_ons[0].title, "Swag Kit") + + def test_save_event_as_template_partial(self): + """Test saving event as template with only some options""" + event = frappe.get_doc( + { + "doctype": "Buzz Event", + "title": "Partial Source Event", + "category": "Test Category", + "host": "Test Host", + "start_date": frappe.utils.today(), + "medium": "In Person", + "about": "Should be copied", + "apply_tax": 1, + "tax_percentage": 18, + } + ) + event.insert() + + # Only save category and about (convert event.name to string as it's an int autoname) + options = {"category": 1, "host": 0, "medium": 0, "about": 1, "apply_tax": 0} + + template_name = create_template_from_event( + str(event.name), "Partial Template 2", frappe.as_json(options) + ) + template = frappe.get_doc("Event Template", template_name) + + self.assertEqual(template.category, "Test Category") + self.assertEqual(template.about, "Should be copied") + self.assertFalse(template.host) + self.assertFalse(template.apply_tax) + + # ==================== Round Trip Tests ==================== + + def test_round_trip_event_to_template_to_event(self): + """Test full round trip: Event -> Template -> New Event""" + # Step 1: Create original event + original_event = frappe.get_doc( + { + "doctype": "Buzz Event", + "title": "Original Conference", + "category": "Test Category", + "host": "Test Host", + "start_date": frappe.utils.today(), + "medium": "In Person", + "about": "Annual conference description", + "apply_tax": 1, + "tax_label": "GST", + "tax_percentage": 18, + } + ) + original_event.insert() + + # Add ticket types + for ticket_data in [ + {"title": "Early Bird", "price": 1000}, + {"title": "Regular", "price": 1500}, + {"title": "VIP", "price": 3000}, + ]: + frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": original_event.name, + "title": ticket_data["title"], + "price": ticket_data["price"], + "currency": "INR", + "is_published": 1, + } + ).insert() + + # Step 2: Save as template (convert event.name to string as it's an int autoname) + template_options = { + "category": 1, + "host": 1, + "medium": 1, + "about": 1, + "apply_tax": 1, + "tax_label": 1, + "tax_percentage": 1, + "ticket_types": 1, + } + template_name = create_template_from_event( + str(original_event.name), "Conference Template", frappe.as_json(template_options) + ) + + # Step 3: Create new event from template + event_options = { + "category": 1, + "host": 1, + "medium": 1, + "about": 1, + "apply_tax": 1, + "tax_label": 1, + "tax_percentage": 1, + "ticket_types": 1, + } + new_event_name = create_from_template(template_name, frappe.as_json(event_options)) + new_event = frappe.get_doc("Buzz Event", new_event_name) + + # Verify new event matches original + self.assertEqual(new_event.category, original_event.category) + self.assertEqual(new_event.host, original_event.host) + self.assertEqual(new_event.medium, original_event.medium) + self.assertEqual(new_event.about, original_event.about) + self.assertEqual(new_event.tax_percentage, original_event.tax_percentage) + + # Verify ticket types match (excluding default "Normal") + new_ticket_types = frappe.get_all( + "Event Ticket Type", + filters={"event": new_event_name, "title": ["in", ["Early Bird", "Regular", "VIP"]]}, + fields=["title", "price"], + order_by="price", + ) + self.assertEqual(len(new_ticket_types), 3) + self.assertEqual(new_ticket_types[0].title, "Early Bird") + self.assertEqual(new_ticket_types[0].price, 1000) + + # ==================== Edge Case Tests ==================== + + def test_create_event_empty_template(self): + """Test creating event from template with minimal data""" + # Template with required fields for Buzz Event (category and host are mandatory) + template = frappe.get_doc( + { + "doctype": "Event Template", + "template_name": "Empty Template", + "category": "Test Category", + "host": "Test Host", + } + ) + template.insert() + + options = {"category": 1, "host": 1} + event_name = create_from_template(template.name, frappe.as_json(options)) + + # Should create event without errors + self.assertTrue(frappe.db.exists("Buzz Event", event_name)) + + def test_template_name_required(self): + """Test that template_name is required""" + template = frappe.get_doc({"doctype": "Event Template", "category": "Test Category"}) + + # Template uses autoname: field:template_name, so it raises ValidationError not MandatoryError + with self.assertRaises(frappe.exceptions.ValidationError): + template.insert() + + def test_duplicate_template_name(self): + """Test handling of duplicate template names""" + frappe.get_doc({"doctype": "Event Template", "template_name": "Duplicate Name"}).insert() + + duplicate = frappe.get_doc({"doctype": "Event Template", "template_name": "Duplicate Name"}) + + with self.assertRaises(frappe.exceptions.DuplicateEntryError): + duplicate.insert() diff --git a/buzz/events/doctype/event_template_add_on/__init__.py b/buzz/events/doctype/event_template_add_on/__init__.py new file mode 100644 index 0000000..f66d03d --- /dev/null +++ b/buzz/events/doctype/event_template_add_on/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt diff --git a/buzz/events/doctype/event_template_add_on/event_template_add_on.json b/buzz/events/doctype/event_template_add_on/event_template_add_on.json new file mode 100644 index 0000000..acc8c2b --- /dev/null +++ b/buzz/events/doctype/event_template_add_on/event_template_add_on.json @@ -0,0 +1,84 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-12-31 12:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enabled", + "title", + "price", + "description", + "column_break_abcd", + "currency", + "user_selects_option", + "options" + ], + "fields": [ + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled?" + }, + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "price", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Price", + "non_negative": 1, + "options": "currency" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description" + }, + { + "fieldname": "column_break_abcd", + "fieldtype": "Column Break" + }, + { + "default": "INR", + "fieldname": "currency", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Currency", + "options": "Currency" + }, + { + "default": "0", + "fieldname": "user_selects_option", + "fieldtype": "Check", + "label": "User Selects Option?" + }, + { + "fieldname": "options", + "fieldtype": "Small Text", + "label": "Options", + "mandatory_depends_on": "eval:doc.user_selects_option==true" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-12-31 12:00:00.000000", + "modified_by": "Administrator", + "module": "Events", + "name": "Event Template Add-on", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/buzz/events/doctype/event_template_add_on/event_template_add_on.py b/buzz/events/doctype/event_template_add_on/event_template_add_on.py new file mode 100644 index 0000000..47a7237 --- /dev/null +++ b/buzz/events/doctype/event_template_add_on/event_template_add_on.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class EventTemplateAddon(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 + + currency: DF.Link | None + description: DF.SmallText | None + enabled: DF.Check + options: DF.SmallText | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + price: DF.Currency + title: DF.Data + user_selects_option: DF.Check + # end: auto-generated types + + pass diff --git a/buzz/events/doctype/event_template_custom_field/__init__.py b/buzz/events/doctype/event_template_custom_field/__init__.py new file mode 100644 index 0000000..f66d03d --- /dev/null +++ b/buzz/events/doctype/event_template_custom_field/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt diff --git a/buzz/events/doctype/event_template_custom_field/event_template_custom_field.json b/buzz/events/doctype/event_template_custom_field/event_template_custom_field.json new file mode 100644 index 0000000..630b5dd --- /dev/null +++ b/buzz/events/doctype/event_template_custom_field/event_template_custom_field.json @@ -0,0 +1,103 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-12-31 12:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enabled", + "label", + "fieldname", + "mandatory", + "placeholder", + "default_value", + "column_break_efgh", + "applied_to", + "fieldtype", + "options", + "order" + ], + "fields": [ + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled?" + }, + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1 + }, + { + "fieldname": "fieldname", + "fieldtype": "Data", + "label": "Name" + }, + { + "default": "0", + "fieldname": "mandatory", + "fieldtype": "Check", + "label": "Mandatory?" + }, + { + "fieldname": "placeholder", + "fieldtype": "Data", + "label": "Placeholder" + }, + { + "fieldname": "default_value", + "fieldtype": "Data", + "label": "Default Value" + }, + { + "fieldname": "column_break_efgh", + "fieldtype": "Column Break" + }, + { + "default": "Booking", + "fieldname": "applied_to", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Applied To", + "options": "Booking\nTicket" + }, + { + "default": "Data", + "fieldname": "fieldtype", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "Data\nPhone\nEmail\nSelect\nDate\nNumber", + "reqd": 1 + }, + { + "fieldname": "options", + "fieldtype": "Small Text", + "label": "Options" + }, + { + "default": "1", + "fieldname": "order", + "fieldtype": "Int", + "label": "Order", + "non_negative": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-12-31 12:00:00.000000", + "modified_by": "Administrator", + "module": "Events", + "name": "Event Template Custom Field", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/buzz/events/doctype/event_template_custom_field/event_template_custom_field.py b/buzz/events/doctype/event_template_custom_field/event_template_custom_field.py new file mode 100644 index 0000000..6491e42 --- /dev/null +++ b/buzz/events/doctype/event_template_custom_field/event_template_custom_field.py @@ -0,0 +1,32 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class EventTemplateCustomField(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 + + applied_to: DF.Literal["Booking", "Ticket"] + default_value: DF.Data | None + enabled: DF.Check + fieldname: DF.Data | None + fieldtype: DF.Literal["Data", "Phone", "Email", "Select", "Date", "Number"] + label: DF.Data + mandatory: DF.Check + options: DF.SmallText | None + order: DF.Int + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + placeholder: DF.Data | None + # end: auto-generated types + + pass diff --git a/buzz/events/doctype/event_template_ticket_type/__init__.py b/buzz/events/doctype/event_template_ticket_type/__init__.py new file mode 100644 index 0000000..f66d03d --- /dev/null +++ b/buzz/events/doctype/event_template_ticket_type/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt diff --git a/buzz/events/doctype/event_template_ticket_type/event_template_ticket_type.json b/buzz/events/doctype/event_template_ticket_type/event_template_ticket_type.json new file mode 100644 index 0000000..ebc8078 --- /dev/null +++ b/buzz/events/doctype/event_template_ticket_type/event_template_ticket_type.json @@ -0,0 +1,79 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-12-31 12:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "price", + "currency", + "column_break_xyzq", + "is_published", + "max_tickets_available", + "auto_unpublish_after" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "price", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Price", + "non_negative": 1, + "options": "currency" + }, + { + "default": "INR", + "fieldname": "currency", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Currency", + "options": "Currency", + "reqd": 1 + }, + { + "fieldname": "column_break_xyzq", + "fieldtype": "Column Break" + }, + { + "default": "1", + "fieldname": "is_published", + "fieldtype": "Check", + "label": "Is Published?" + }, + { + "fieldname": "max_tickets_available", + "fieldtype": "Int", + "label": "Max Tickets Available", + "non_negative": 1 + }, + { + "depends_on": "eval:doc.is_published", + "fieldname": "auto_unpublish_after", + "fieldtype": "Date", + "label": "Auto Unpublish After" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-12-31 12:00:00.000000", + "modified_by": "Administrator", + "module": "Events", + "name": "Event Template Ticket Type", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/buzz/events/doctype/event_template_ticket_type/event_template_ticket_type.py b/buzz/events/doctype/event_template_ticket_type/event_template_ticket_type.py new file mode 100644 index 0000000..0196664 --- /dev/null +++ b/buzz/events/doctype/event_template_ticket_type/event_template_ticket_type.py @@ -0,0 +1,28 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class EventTemplateTicketType(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 + is_published: DF.Check + max_tickets_available: DF.Int + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + price: DF.Currency + title: DF.Data + # end: auto-generated types + + pass diff --git a/buzz/hooks.py b/buzz/hooks.py index 7ad501c..1b84c7b 100644 --- a/buzz/hooks.py +++ b/buzz/hooks.py @@ -64,7 +64,7 @@ # include js, css files in header of desk.html # app_include_css = "/assets/events/css/events.css" -# app_include_js = "/assets/events/js/events.js" +app_include_js = "/assets/buzz/js/events/create_from_template.js" # include js, css files in header of web template # web_include_css = "/assets/events/css/events.css" diff --git a/buzz/public/js/events/create_from_template.js b/buzz/public/js/events/create_from_template.js new file mode 100644 index 0000000..00c866e --- /dev/null +++ b/buzz/public/js/events/create_from_template.js @@ -0,0 +1,717 @@ +frappe.provide("buzz.events"); + +// Field groups for template options +buzz.events.TEMPLATE_FIELD_GROUPS = { + event_details: { + label: __("Event Details"), + fields: [ + "category", + "host", + "banner_image", + "short_description", + "about", + "medium", + "venue", + "time_zone", + ], + }, + ticketing_settings: { + label: __("Ticketing Settings"), + fields: [ + "apply_tax", + "tax_label", + "tax_percentage", + "ticket_email_template", + "ticket_print_format", + ], + }, + sponsorship_settings: { + label: __("Sponsorship Settings"), + fields: [ + "auto_send_pitch_deck", + "sponsor_deck_email_template", + "sponsor_deck_reply_to", + "sponsor_deck_cc", + "sponsor_deck_attachments", + ], + }, +}; + +// Mandatory fields for Buzz Event that must be provided +buzz.events.MANDATORY_FIELDS = ["category", "host"]; + +buzz.events.show_create_from_template_dialog = function () { + let dialog = new frappe.ui.Dialog({ + title: __("Create Event from Template"), + fields: [ + { + fieldtype: "Link", + fieldname: "template", + label: __("Select Template"), + options: "Event Template", + reqd: 1, + change: function () { + buzz.events.on_template_selected(dialog); + }, + }, + { + fieldtype: "Section Break", + fieldname: "missing_fields_section", + label: __("Required Fields"), + depends_on: "eval:doc.template", + hidden: 1, + }, + { + fieldtype: "HTML", + fieldname: "missing_fields_info", + }, + { + fieldtype: "Link", + fieldname: "category", + label: __("Category"), + options: "Event Category", + hidden: 1, + }, + { + fieldtype: "Column Break", + }, + { + fieldtype: "Link", + fieldname: "host", + label: __("Host"), + options: "Event Host", + hidden: 1, + }, + { + fieldtype: "Section Break", + fieldname: "options_section", + label: __("Select What to Copy"), + depends_on: "eval:doc.template", + }, + { + fieldtype: "HTML", + fieldname: "select_buttons", + depends_on: "eval:doc.template", + }, + { + fieldtype: "HTML", + fieldname: "field_options", + depends_on: "eval:doc.template", + }, + ], + size: "large", + primary_action_label: __("Create Event"), + primary_action: function (values) { + buzz.events.create_event_from_template(dialog, values); + }, + }); + + dialog.show(); +}; + +buzz.events.on_template_selected = function (dialog) { + let template_name = dialog.get_value("template"); + if (!template_name) { + dialog.get_field("field_options").$wrapper.html(""); + dialog.get_field("select_buttons").$wrapper.html(""); + return; + } + + frappe.call({ + method: "frappe.client.get", + args: { + doctype: "Event Template", + name: template_name, + }, + callback: function (r) { + if (r.message) { + buzz.events.render_template_options(dialog, r.message); + } + }, + }); +}; + +buzz.events.render_template_options = function (dialog, template) { + let html = ""; + + // Select All / Unselect All buttons + let buttons_html = ` +
${__( + "The following required fields are not set in the template or not selected. Please fill them in:" + )}
` + ); + } else { + dialog.get_field("missing_fields_section").df.hidden = 1; + dialog.get_field("missing_fields_section").refresh(); + dialog.get_field("missing_fields_info").$wrapper.html(""); + } +}; + +buzz.events.render_field_group = function (group_key, template) { + let group = buzz.events.TEMPLATE_FIELD_GROUPS[group_key]; + let html = '