From da0492bf54db8d82b154d5112d22d4396d08e251 Mon Sep 17 00:00:00 2001 From: Rahul Agrawal <12agrawalrahul@gmail.com> Date: Thu, 26 Mar 2026 16:30:04 +0530 Subject: [PATCH 1/3] feat: add option to publish event proposal form --- buzz/api/forms.py | 62 +++++++++ .../doctype/buzz_settings/buzz_settings.json | 41 ++++++ .../doctype/event_proposal/event_proposal.py | 28 +++- dashboard/src/components/CustomFieldInput.vue | 13 ++ dashboard/src/pages/EventProposalForm.vue | 130 ++++++++++++++++++ dashboard/src/router.ts | 6 + plans/Completed/event-proposal-form.md | 108 +++++++++++++++ 7 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 dashboard/src/pages/EventProposalForm.vue create mode 100644 plans/Completed/event-proposal-form.md diff --git a/buzz/api/forms.py b/buzz/api/forms.py index 77ee1cbd..9880cb27 100644 --- a/buzz/api/forms.py +++ b/buzz/api/forms.py @@ -9,6 +9,17 @@ LAYOUT_FIELDTYPES = set(display_fieldtypes) +EVENT_PROPOSAL_EXCLUDE_FIELDS = DEFAULT_FIELDS | { + "naming_series", + "amended_from", + "host", + "host_company", + "host_company_logo", + "additional_notes", + "status", + "submitted_by", +} + STANDARD_EXCLUDE_FIELDS = DEFAULT_FIELDS | { "additional_fields", "event", @@ -279,3 +290,54 @@ def submit_custom_form( ) doc.insert(ignore_permissions=True) + + +def validate_event_proposal_settings(): + settings = frappe.get_cached_doc("Buzz Settings") + if not settings.accept_event_proposals: + frappe.throw(_("Event Proposals are not being accepted"), frappe.DoesNotExistError) + + if not settings.allow_guest_event_proposals and frappe.session.user == "Guest": + frappe.throw(_("Please log in to submit a proposal"), frappe.AuthenticationError) + + return settings + + +@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method +def get_event_proposal_form_data() -> dict: + settings = validate_event_proposal_settings() + form_fields = get_form_fields("Event Proposal", EVENT_PROPOSAL_EXCLUDE_FIELDS) + + return { + "form_fields": form_fields, + "form_title": _("Event Proposal"), + "success_title": settings.event_proposal_success_title or _("Thank you!"), + "success_message": settings.event_proposal_success_message or "", + } + + +@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method +def submit_event_proposal(data: dict | str) -> None: + validate_event_proposal_settings() + + data = frappe.parse_json(data) or {} + + doc_data = {"doctype": "Event Proposal"} + + if frappe.session.user != "Guest": + doc_data["submitted_by"] = frappe.session.user + + allowed_fieldnames = { + f["fieldname"] for f in get_form_fields("Event Proposal", EVENT_PROPOSAL_EXCLUDE_FIELDS) + } + for fieldname, value in data.items(): + if fieldname in allowed_fieldnames: + doc_data[fieldname] = value + + meta = frappe.get_meta("Event Proposal") + for df in meta.fields: + if df.fieldtype == "Table" and df.fieldname not in EVENT_PROPOSAL_EXCLUDE_FIELDS: + if df.fieldname in data and isinstance(data[df.fieldname], list): + doc_data[df.fieldname] = data[df.fieldname] + + frappe.get_doc(doc_data).insert(ignore_permissions=True) diff --git a/buzz/events/doctype/buzz_settings/buzz_settings.json b/buzz/events/doctype/buzz_settings/buzz_settings.json index 83cdc042..8020cbc9 100644 --- a/buzz/events/doctype/buzz_settings/buzz_settings.json +++ b/buzz/events/doctype/buzz_settings/buzz_settings.json @@ -13,6 +13,12 @@ "allow_add_ons_change_before_event_start_days", "column_break_hagy", "allow_ticket_cancellation_request_before_event_start_days", + "proposals_tab", + "event_proposals_section", + "accept_event_proposals", + "allow_guest_event_proposals", + "event_proposal_success_title", + "event_proposal_success_message", "communications_tab", "ticketing_emails_section", "default_ticket_email_template", @@ -73,6 +79,41 @@ "label": "Support Email", "options": "Email" }, + { + "fieldname": "proposals_tab", + "fieldtype": "Tab Break", + "label": "Proposals" + }, + { + "fieldname": "event_proposals_section", + "fieldtype": "Section Break", + "label": "Event Proposals" + }, + { + "default": "0", + "fieldname": "accept_event_proposals", + "fieldtype": "Check", + "label": "Accept Event Proposals" + }, + { + "default": "0", + "depends_on": "eval:doc.accept_event_proposals", + "fieldname": "allow_guest_event_proposals", + "fieldtype": "Check", + "label": "Allow Guest Submission" + }, + { + "depends_on": "eval:doc.accept_event_proposals", + "fieldname": "event_proposal_success_title", + "fieldtype": "Data", + "label": "Success Title" + }, + { + "depends_on": "eval:doc.accept_event_proposals", + "fieldname": "event_proposal_success_message", + "fieldtype": "Markdown Editor", + "label": "Success Message" + }, { "fieldname": "communications_tab", "fieldtype": "Tab Break", diff --git a/buzz/proposals/doctype/event_proposal/event_proposal.py b/buzz/proposals/doctype/event_proposal/event_proposal.py index bb50efcb..aaff3b8e 100644 --- a/buzz/proposals/doctype/event_proposal/event_proposal.py +++ b/buzz/proposals/doctype/event_proposal/event_proposal.py @@ -2,9 +2,10 @@ # For license information, please see license.txt import frappe +from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc -from frappe.utils.data import get_url_to_form +from frappe.utils.data import get_url_to_form, getdate, today class EventProposal(Document): @@ -35,9 +36,28 @@ class EventProposal(Document): title: DF.Data # end: auto-generated types + def validate(self): + self.validate_dates() + self.validate_times() + + def validate_dates(self): + if getdate(self.start_date) < getdate(today()): + frappe.throw(_("Start Date cannot be in the past.")) + + if self.end_date and getdate(self.end_date) < getdate(self.start_date): + frappe.throw(_("End Date cannot be before Start Date.")) + + def validate_times(self): + if not self.start_time or not self.end_time: + return + + same_day = not self.end_date or getdate(self.end_date) == getdate(self.start_date) + if same_day and self.end_time <= self.start_time: + frappe.throw(_("End Time must be after Start Time for same-day events.")) + def before_submit(self): if self.status not in ("Approved", "Rejected"): - frappe.throw(frappe._("Only Approved or Rejected proposals can be submitted.")) + frappe.throw(_("Only Approved or Rejected proposals can be submitted.")) self.create_event() @@ -46,7 +66,7 @@ def create_event(self): return if not self.host: - frappe.throw(frappe._("Please create or set a Host before submitting the proposal.")) + frappe.throw(_("Please create or set a Host before submitting the proposal.")) buzz_event = get_mapped_doc( "Event Proposal", self.name, {"Event Proposal": {"doctype": "Buzz Event"}} @@ -57,7 +77,7 @@ def create_event(self): self.status = "Event Created" frappe.msgprint( - frappe._("Buzz Event {0} created successfully.").format( + _("Buzz Event {0} created successfully.").format( f'{buzz_event.title}' ) ) diff --git a/dashboard/src/components/CustomFieldInput.vue b/dashboard/src/components/CustomFieldInput.vue index cfc8545a..fe287733 100644 --- a/dashboard/src/components/CustomFieldInput.vue +++ b/dashboard/src/components/CustomFieldInput.vue @@ -23,6 +23,18 @@ /> +
+ + +
+
-
- -

+
+ +

{{ __("Not Found") }}

-

+

{{ loadError }}

@@ -163,7 +163,6 @@ import { marked } from "marked"; import { computed, reactive, ref } from "vue"; import LucideAlertCircle from "~icons/lucide/alert-circle"; import LucideCheckCircle from "~icons/lucide/check-circle"; -import LucideXCircle from "~icons/lucide/x-circle"; const props = defineProps({ eventRoute: { diff --git a/dashboard/src/pages/EventProposalForm.vue b/dashboard/src/pages/EventProposalForm.vue index 7bdb5fc7..4cd198b5 100644 --- a/dashboard/src/pages/EventProposalForm.vue +++ b/dashboard/src/pages/EventProposalForm.vue @@ -28,14 +28,16 @@
-

- {{ form_data.form_title }} -

+
+

+ {{ form_data.banner_title }} +

+
-
+
- +
+ +
-
- -

+
+ +

{{ __("Not Found") }}

-

+

{{ load_error }}

@@ -77,8 +81,8 @@ import LoginRequired from "@/components/LoginRequired.vue"; import { Button, Spinner, createResource, toast } from "frappe-ui"; import { marked } from "marked"; import { computed, reactive, ref } from "vue"; +import LucideAlertCircle from "~icons/lucide/alert-circle"; import LucideCheckCircle from "~icons/lucide/check-circle"; -import LucideXCircle from "~icons/lucide/x-circle"; const form_data = ref(null); const form_values = reactive({}); From e2df92b062fee3fd03d2e1979b1e4d3ccb962f46 Mon Sep 17 00:00:00 2001 From: Rahul Agrawal <12agrawalrahul@gmail.com> Date: Sun, 29 Mar 2026 11:19:43 +0530 Subject: [PATCH 3/3] fix: add E2E tests for Event Proposal --- e2e/pages/custom-form.page.ts | 2 +- e2e/pages/event-proposal.page.ts | 81 ++++++++++++++++ e2e/pages/index.ts | 1 + e2e/tests/event-proposal.setup.ts | 22 +++++ e2e/tests/event-proposal.spec.ts | 154 ++++++++++++++++++++++++++++++ playwright.config.ts | 19 +++- 6 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 e2e/pages/event-proposal.page.ts create mode 100644 e2e/tests/event-proposal.setup.ts create mode 100644 e2e/tests/event-proposal.spec.ts diff --git a/e2e/pages/custom-form.page.ts b/e2e/pages/custom-form.page.ts index fade9d2f..217110fa 100644 --- a/e2e/pages/custom-form.page.ts +++ b/e2e/pages/custom-form.page.ts @@ -14,7 +14,7 @@ export class CustomFormPage { this.submitButton = page.locator('button[type="submit"]').filter({ hasText: /^Submit$/ }); this.successBanner = page.locator(".bg-surface-green-1"); this.closedBanner = page.locator(".bg-surface-orange-1"); - this.errorBanner = page.locator(".bg-surface-red-1"); + this.errorBanner = page.locator(".bg-surface-amber-1"); } async goto(eventRoute: string, formRoute: string): Promise { diff --git a/e2e/pages/event-proposal.page.ts b/e2e/pages/event-proposal.page.ts new file mode 100644 index 00000000..ec6b3dec --- /dev/null +++ b/e2e/pages/event-proposal.page.ts @@ -0,0 +1,81 @@ +import { expect, Locator, Page } from "@playwright/test"; + +export class EventProposalPage { + private page: Page; + private form: Locator; + private submitButton: Locator; + private successBanner: Locator; + private notFoundBanner: Locator; + + constructor(page: Page) { + this.page = page; + this.form = page.locator("form"); + this.submitButton = page.locator('button[type="submit"]').filter({ hasText: /^Submit$/ }); + this.successBanner = page.locator(".bg-surface-green-1"); + this.notFoundBanner = page.locator(".bg-surface-amber-1"); + } + + async goto(): Promise { + await this.page.goto("/dashboard/event-proposal"); + await this.page.waitForLoadState("networkidle"); + } + + async waitForFormLoad(): Promise { + await expect(this.form).toBeVisible({ timeout: 15000 }); + } + + getInputByLabel(label: string): Locator { + return this.page + .locator(`label:has-text("${label}")`) + .locator("..") + .locator("input, textarea, select") + .first(); + } + + async expectFormVisible(): Promise { + await expect(this.form).toBeVisible(); + } + + async expectBannerTitle(title: string): Promise { + await expect(this.page.locator(`h1:has-text("${title}")`)).toBeVisible(); + } + + async expectFieldVisible(label: string): Promise { + await expect(this.page.locator(`label:has-text("${label}")`)).toBeVisible(); + } + + async expectSubmitButtonVisible(): Promise { + await expect(this.submitButton).toBeVisible(); + } + + async expectSuccess(): Promise { + await expect(this.successBanner).toBeVisible({ timeout: 15000 }); + } + + async expectNotFound(): Promise { + await expect(this.notFoundBanner).toBeVisible({ timeout: 15000 }); + } + + async submit(): Promise { + await this.submitButton.click(); + } + + async submitAndExpectResponse(): Promise<{ succeeded: boolean; status: number }> { + const responsePromise = this.page.waitForResponse( + (resp) => resp.url().includes("submit_event_proposal"), + { timeout: 20000 }, + ); + + await this.submitButton.click(); + + const response = await responsePromise; + const status = response.status(); + const succeeded = status === 200; + + if (succeeded) { + await expect(this.successBanner).toBeVisible({ timeout: 15000 }); + } + + return { succeeded, status }; + } +} diff --git a/e2e/pages/index.ts b/e2e/pages/index.ts index dcbfb0c1..4f2d96d6 100644 --- a/e2e/pages/index.ts +++ b/e2e/pages/index.ts @@ -1,3 +1,4 @@ export { LoginPage } from "./login.page"; export { BookingPage } from "./booking.page"; export { CustomFormPage } from "./custom-form.page"; +export { EventProposalPage } from "./event-proposal.page"; diff --git a/e2e/tests/event-proposal.setup.ts b/e2e/tests/event-proposal.setup.ts new file mode 100644 index 00000000..dcee0014 --- /dev/null +++ b/e2e/tests/event-proposal.setup.ts @@ -0,0 +1,22 @@ +import { test as setup } from "@playwright/test"; +import { createDoc, docExists, updateDoc } from "../helpers/frappe"; + +const testCategoryName = "E2E Test Category"; + +setup("setup event proposal form", async ({ request }) => { + if (!(await docExists(request, "Event Category", testCategoryName))) { + await createDoc(request, "Event Category", { + name: testCategoryName, + enabled: 1, + slug: "e2e-test-category", + }); + console.log(`Created Event Category: ${testCategoryName}`); + } + + await updateDoc(request, "Buzz Settings", "Buzz Settings", { + accept_event_proposals: 1, + allow_guest_event_proposals: 1, + }); + + console.log("Event proposal form enabled in Buzz Settings"); +}); diff --git a/e2e/tests/event-proposal.spec.ts b/e2e/tests/event-proposal.spec.ts new file mode 100644 index 00000000..a630d899 --- /dev/null +++ b/e2e/tests/event-proposal.spec.ts @@ -0,0 +1,154 @@ +import { test, expect } from "@playwright/test"; +import { EventProposalPage } from "../pages"; +import { callMethod, updateDoc } from "../helpers/frappe"; + +test.describe("Event Proposal Form - Rendering", () => { + test("should display the proposal form", async ({ page }) => { + const proposalPage = new EventProposalPage(page); + await proposalPage.goto(); + await proposalPage.waitForFormLoad(); + await proposalPage.expectFormVisible(); + }); + + test("should display the banner title", async ({ page }) => { + const proposalPage = new EventProposalPage(page); + await proposalPage.goto(); + await proposalPage.waitForFormLoad(); + await proposalPage.expectBannerTitle("Propose an Event"); + }); + + test("should display the title field", async ({ page }) => { + const proposalPage = new EventProposalPage(page); + await proposalPage.goto(); + await proposalPage.waitForFormLoad(); + await proposalPage.expectFieldVisible("Title"); + }); + + test("should display the submit button", async ({ page }) => { + const proposalPage = new EventProposalPage(page); + await proposalPage.goto(); + await proposalPage.waitForFormLoad(); + await proposalPage.expectSubmitButtonVisible(); + }); +}); + +test.describe("Event Proposal Form - Submission", () => { + test("should fill and submit a proposal", async ({ page }) => { + const proposalPage = new EventProposalPage(page); + await proposalPage.goto(); + await proposalPage.waitForFormLoad(); + + const titleInput = proposalPage.getInputByLabel("Title"); + await titleInput.fill("E2E Test: Automated Testing with Playwright"); + + const categorySelect = page.locator("select").first(); + await categorySelect.waitFor({ state: "visible", timeout: 5000 }); + await categorySelect.selectOption({ label: "E2E Test Category" }); + + const aboutTextarea = page + .locator('label:has-text("About the event")') + .locator("..") + .locator("textarea"); + await aboutTextarea.fill("An E2E test proposal about automated testing practices."); + + const { succeeded, status } = await proposalPage.submitAndExpectResponse(); + console.log(`Proposal submission: status=${status}, succeeded=${succeeded}`); + }); + + test("should show success banner after submission", async ({ page }) => { + await page.route(/submit_event_proposal/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ message: null }), + }), + ); + + const proposalPage = new EventProposalPage(page); + await proposalPage.goto(); + await proposalPage.waitForFormLoad(); + + const titleInput = proposalPage.getInputByLabel("Title"); + await titleInput.fill("E2E Test: Another Proposal for Success Banner"); + + const categorySelect = page.locator("select").first(); + await categorySelect.waitFor({ state: "visible", timeout: 5000 }); + await categorySelect.selectOption({ label: "E2E Test Category" }); + + const aboutTextarea = page + .locator('label:has-text("About the event")') + .locator("..") + .locator("textarea"); + await aboutTextarea.fill("An E2E test proposal about building better events."); + + await proposalPage.submitAndExpectResponse(); + await proposalPage.expectSuccess(); + }); +}); + +test.describe("Event Proposal Form - Access Control", () => { + test("should show not found when proposals are disabled", async ({ page, request }) => { + await updateDoc(request, "Buzz Settings", "Buzz Settings", { + accept_event_proposals: 0, + }); + + try { + const proposalPage = new EventProposalPage(page); + await proposalPage.goto(); + await proposalPage.expectNotFound(); + } finally { + await updateDoc(request, "Buzz Settings", "Buzz Settings", { + accept_event_proposals: 1, + }); + } + }); + + test("should return error via API when proposals are disabled", async ({ request }) => { + await updateDoc(request, "Buzz Settings", "Buzz Settings", { + accept_event_proposals: 0, + }); + + try { + const result = await callMethod( + request, + "buzz.api.forms.get_event_proposal_form_data", + ).catch((err: Error) => err); + + expect(result).toBeInstanceOf(Error); + } finally { + await updateDoc(request, "Buzz Settings", "Buzz Settings", { + accept_event_proposals: 1, + }); + } + }); +}); + +test.describe("Event Proposal Form - API", () => { + test("should return form data with expected shape", async ({ request }) => { + const data = await callMethod<{ + form_fields: Array<{ fieldname: string }>; + banner_title: string; + form_title: string; + success_title: string; + }>(request, "buzz.api.forms.get_event_proposal_form_data"); + + expect(data.form_fields).toBeInstanceOf(Array); + expect(data.form_fields.length).toBeGreaterThan(0); + expect(data.banner_title).toBeTruthy(); + expect(data.form_title).toBeTruthy(); + expect(data.success_title).toBeTruthy(); + }); + + test("should not expose excluded fields in form data", async ({ request }) => { + const data = await callMethod<{ + form_fields: Array<{ fieldname: string }>; + }>(request, "buzz.api.forms.get_event_proposal_form_data"); + + const fieldnames = data.form_fields.map((f) => f.fieldname); + const excludedFields = ["status", "submitted_by", "host", "naming_series", "amended_from"]; + + for (const excluded of excludedFields) { + expect(fieldnames).not.toContain(excluded); + } + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 5948e00c..e8ee151f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -52,7 +52,7 @@ export default defineConfig({ }, { name: "chromium", - testIgnore: /guest-booking|custom-forms/, + testIgnore: /guest-booking|custom-forms|event-proposal/, use: { ...devices["Desktop Chrome"], storageState: authFile, @@ -92,6 +92,23 @@ export default defineConfig({ }, dependencies: ["custom-forms-setup"], }, + { + name: "event-proposal-setup", + testMatch: /event-proposal\.setup\.ts/, + use: { + storageState: authFile, + }, + dependencies: ["setup"], + }, + { + name: "event-proposal-chromium", + testMatch: /event-proposal\.spec\.ts/, + use: { + ...devices["Desktop Chrome"], + storageState: authFile, + }, + dependencies: ["event-proposal-setup"], + }, { name: "offline-payment-setup", testMatch: /offline-payment\.setup\.ts/,