Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions buzz/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ def get_event_booking_data(event_route: str) -> dict:
"start_time": event_doc.start_time,
"end_time": event_doc.end_time,
"time_zone": event_doc.time_zone,
"time_zone_label": event_doc.time_zone_label,
"venue": event_doc.venue,
"medium": event_doc.medium,
"category": event_doc.category,
Expand Down
1 change: 1 addition & 0 deletions buzz/api/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ def get_custom_form_data(event_route: str, form_route: str) -> dict:
"start_time": event_doc.start_time,
"end_time": event_doc.end_time,
"time_zone": event_doc.time_zone,
"time_zone_label": event_doc.time_zone_label,
"venue": event_doc.venue,
"medium": event_doc.medium,
"short_description": event_doc.short_description,
Expand Down
9 changes: 8 additions & 1 deletion buzz/events/doctype/buzz_event/buzz_event.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"start_date",
"start_time",
"time_zone",
"time_zone_label",
"column_break_cjby",
"end_date",
"end_time",
Expand Down Expand Up @@ -175,6 +176,12 @@
"fieldtype": "Autocomplete",
"label": "Time Zone"
},
{
"fieldname": "time_zone_label",
"fieldtype": "Data",
"label": "Time Zone Label",
"read_only": 1
},
{
"fieldname": "banner_image",
"fieldtype": "Attach Image",
Expand Down Expand Up @@ -595,7 +602,7 @@
"link_fieldname": "event"
}
],
"modified": "2026-03-23 17:37:37.770911",
"modified": "2026-07-23 12:00:00.000000",
"modified_by": "Administrator",
"module": "Events",
"name": "Buzz Event",
Expand Down
16 changes: 14 additions & 2 deletions buzz/events/doctype/buzz_event/buzz_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from frappe import _
from frappe.model.document import Document
from frappe.model.naming import append_number_if_name_exists
from frappe.utils.data import get_time, time_diff_in_seconds
from frappe.utils.data import get_datetime, get_time, time_diff_in_seconds

from buzz.api.forms import validate_excluded_fields
from buzz.utils import only_if_app_installed
from buzz.utils import get_time_zone_label, only_if_app_installed

# Top-level dashboard route segments (/b/<segment>) an event route must not shadow.
RESERVED_EVENT_ROUTES = {
Expand Down Expand Up @@ -84,6 +84,7 @@ class BuzzEvent(Document):
ticket_email_template: DF.Link | None
ticket_print_format: DF.Link | None
time_zone: DF.Autocomplete | None
time_zone_label: DF.Data | None
title: DF.Data
venue: DF.Link | None
# end: auto-generated types
Expand All @@ -95,6 +96,17 @@ def validate(self):
self.validate_tax_settings()
self.validate_guest_verification_config()
self.validate_custom_forms()
self.set_time_zone_label()

def set_time_zone_label(self):
# validate runs before the mandatory check, so dates may still be empty here
if not (self.time_zone and self.start_date and self.start_time):
self.time_zone_label = ""
return

# computed at event start so DST zones get the abbreviation in effect then
event_start = get_datetime(f"{self.start_date} {self.start_time}")
self.time_zone_label = get_time_zone_label(self.time_zone, event_start)

def validate_custom_forms(self):
for form in self.custom_forms:
Expand Down
132 changes: 132 additions & 0 deletions buzz/events/doctype/buzz_event/test_buzz_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from buzz.api import are_registrations_closed
from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES, create_from_template
from buzz.events.doctype.event_template.event_template import create_template_from_event
from buzz.patches.set_time_zone_label_for_existing_events import execute as backfill_time_zone_labels
from buzz.utils import get_time_zone_label


class TestBuzzEvent(FrappeTestCase):
Expand Down Expand Up @@ -890,3 +892,133 @@ def test_event_end_fallback_is_also_timezone_consistent(self):
after_end = datetime(2026, 6, 15, 16, 31, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
with patch("buzz.api.get_datetime_in_timezone", return_value=after_end):
self.assertTrue(are_registrations_closed(event))


class TestTimeZoneLabel(FrappeTestCase):
"""Tests for get_time_zone_label: IANA name -> short display label."""

def test_tzdb_abbreviation_when_alphabetic(self):
"""Zones where tzdata ships a real abbreviation use it directly."""
reference = datetime(2026, 6, 15, 12, 0)
self.assertEqual(get_time_zone_label("Asia/Kolkata", reference), "IST")
self.assertEqual(get_time_zone_label("Asia/Tokyo", reference), "JST")
self.assertEqual(get_time_zone_label("Africa/Nairobi", reference), "EAT")
self.assertEqual(get_time_zone_label("UTC", reference), "UTC")

def test_dst_variant_follows_reference_date(self):
"""DST zones get the abbreviation in effect on the reference date."""
winter = datetime(2026, 1, 15, 12, 0)
summer = datetime(2026, 7, 15, 12, 0)
self.assertEqual(get_time_zone_label("America/New_York", winter), "EST")
self.assertEqual(get_time_zone_label("America/New_York", summer), "EDT")
self.assertEqual(get_time_zone_label("Europe/Berlin", winter), "CET")
self.assertEqual(get_time_zone_label("Europe/Berlin", summer), "CEST")

def test_curated_abbreviation_when_tzdb_is_numeric(self):
"""Zones where tzdata returns a bare offset fall back to the curated map."""
reference = datetime(2026, 6, 15, 12, 0)
self.assertEqual(get_time_zone_label("Asia/Dubai", reference), "GST")
self.assertEqual(get_time_zone_label("Asia/Riyadh", reference), "AST")
self.assertEqual(get_time_zone_label("Asia/Bangkok", reference), "ICT")
self.assertEqual(get_time_zone_label("Asia/Kathmandu", reference), "NPT")

def test_gmt_offset_fallback_for_unmapped_zone(self):
"""Zones outside tzdata abbreviations and the curated map show a GMT offset."""
reference = datetime(2026, 6, 15, 12, 0)
# Bhutan: tzname is "+06", not in the curated map
self.assertEqual(get_time_zone_label("Asia/Thimphu", reference), "GMT+6")
# Myanmar: half-hour offset formatting
self.assertEqual(get_time_zone_label("Asia/Yangon", reference), "GMT+6:30")
# Marquesas: negative half-hour offset
self.assertEqual(get_time_zone_label("Pacific/Marquesas", reference), "GMT-9:30")

def test_empty_or_invalid_time_zone_returns_empty(self):
reference = datetime(2026, 6, 15, 12, 0)
self.assertEqual(get_time_zone_label(None, reference), "")
self.assertEqual(get_time_zone_label("", reference), "")
self.assertEqual(get_time_zone_label("Not/A_Zone", reference), "")

def test_current_iana_names_for_renamed_zones(self):
"""Renamed zones resolve under both the legacy and current IANA names."""
reference = datetime(2026, 6, 15, 12, 0)
self.assertEqual(get_time_zone_label("Asia/Ho_Chi_Minh", reference), "ICT")
self.assertEqual(get_time_zone_label("America/Nuuk", reference), "WGT")

def test_aware_reference_datetime_converted_not_reinterpreted(self):
"""US DST ends 2026-11-01 06:00 UTC; 05:30 UTC is still 01:30 EDT.

Naive .replace() would read 05:30 as New York wall clock (past the
switch, EST); a correct conversion lands on EDT.
"""
aware_reference = datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc)
self.assertEqual(get_time_zone_label("America/New_York", aware_reference), "EDT")


class TestEventTimeZoneLabelField(FrappeTestCase):
"""Saving a Buzz Event stores the display label for its time zone."""

def tearDown(self):
frappe.db.rollback()

def _make_event(self, **overrides):
event_defaults = {
"doctype": "Buzz Event",
"title": "TZ Label Test Event",
"category": "Test Category",
"host": "Test Host",
"start_date": "2026-03-05",
"end_date": "2026-03-06",
"start_time": "9:00:00",
"end_time": "18:00:00",
}
event_defaults.update(overrides)
return frappe.get_doc(event_defaults)

@classmethod
def setUpClass(cls):
super().setUpClass()
TestBuzzEvent.create_test_fixtures()

def test_label_set_on_insert(self):
event = self._make_event(time_zone="Asia/Kolkata")
event.insert()
self.assertEqual(event.time_zone_label, "IST")

def test_label_updates_when_time_zone_changes(self):
event = self._make_event(time_zone="Asia/Kolkata")
event.insert()
event.time_zone = "Asia/Dubai"
event.save()
self.assertEqual(event.time_zone_label, "GST")

def test_label_cleared_when_time_zone_removed(self):
event = self._make_event(time_zone="Asia/Kolkata")
event.insert()
event.time_zone = ""
event.save()
self.assertEqual(event.time_zone_label, "")

def test_label_uses_event_start_date_for_dst(self):
"""July New York event shows EDT, not EST."""
event = self._make_event(
time_zone="America/New_York",
start_date="2026-07-10",
end_date="2026-07-10",
)
event.insert()
self.assertEqual(event.time_zone_label, "EDT")

def test_backfill_patch_skips_events_missing_start_fields(self):
"""Legacy rows can have time_zone without start fields; patch must not abort."""
event = self._make_event(time_zone="Asia/Kolkata")
event.insert()
frappe.db.set_value(
"Buzz Event",
event.name,
{"start_time": None, "time_zone_label": ""},
update_modified=False,
)

backfill_time_zone_labels()

self.assertEqual(frappe.db.get_value("Buzz Event", event.name, "time_zone_label"), "")
3 changes: 2 additions & 1 deletion buzz/patches.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ buzz.patches.migrate_offline_payment_to_methods
buzz.patches.populate_slug_in_event_category
buzz.patches.set_applies_to_for_existing_coupons
buzz.patches.set_payment_status_for_existing_bookings
buzz.patches.normalize_phone_format
buzz.patches.normalize_phone_format
buzz.patches.set_time_zone_label_for_existing_events
20 changes: 20 additions & 0 deletions buzz/patches/set_time_zone_label_for_existing_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import frappe
from frappe.utils.data import get_datetime

from buzz.utils import get_time_zone_label


def execute():
events = frappe.get_all(
"Buzz Event",
filters={
"time_zone": ("is", "set"),
"start_date": ("is", "set"),
"start_time": ("is", "set"),
},
fields=["name", "time_zone", "start_date", "start_time"],
)
for event in events:
event_start = get_datetime(f"{event.start_date} {event.start_time}")
label = get_time_zone_label(event.time_zone, event_start)
frappe.db.set_value("Buzz Event", event.name, "time_zone_label", label, update_modified=False)
Comment on lines +7 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Patch missing null-guard for start_date/start_time

The filter only requires time_zone to be set, but not start_date or start_time. If any event in the database has time_zone populated but those fields null, get_datetime("None None") is called — which either returns None (causing get_time_zone_label to fall back to now_datetime() and potentially stamp the wrong DST abbreviation) or raises, aborting the migration mid-run and leaving the backfill incomplete. set_time_zone_label in buzz_event.py explicitly guards against this same combination with if not (self.time_zone and self.start_date and self.start_time) — the patch should apply the same defence, either by adding both fields to the filter or by skipping events where they are absent inside the loop.

Prompt To Fix With AI
This is a comment left during a code review.
Path: buzz/patches/set_time_zone_label_for_existing_events.py
Line: 7-16

Comment:
**Patch missing null-guard for `start_date`/`start_time`**

The filter only requires `time_zone` to be set, but not `start_date` or `start_time`. If any event in the database has `time_zone` populated but those fields null, `get_datetime("None None")` is called — which either returns `None` (causing `get_time_zone_label` to fall back to `now_datetime()` and potentially stamp the wrong DST abbreviation) or raises, aborting the migration mid-run and leaving the backfill incomplete. `set_time_zone_label` in `buzz_event.py` explicitly guards against this same combination with `if not (self.time_zone and self.start_date and self.start_time)` — the patch should apply the same defence, either by adding both fields to the filter or by skipping events where they are absent inside the loop.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1318210 — patch filters now require start_date and start_time set, matching the controller guard. Regression test added (legacy row with null start_time no longer aborts the run).

85 changes: 85 additions & 0 deletions buzz/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import functools
import re
from collections.abc import Callable
from datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.utils import now_datetime


def is_app_installed(app_name: str) -> bool:
Expand Down Expand Up @@ -189,3 +193,84 @@ def generate_ics_file(event_doc, attendee_email: str):

# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
return frappe.render_template("templates/ics/ics.jinja2", context, is_path=True)


# Curated abbreviations for zones where tzdata only provides a numeric offset
# (tzdata dropped invented abbreviations in 2017). Zones with real tzdata
# abbreviations (IST, EST, CET, ...) never reach this map.
# ponytail: DST-observing zones here (e.g. Chile) are pinned to their standard
# form; extend get_time_zone_label with per-date variants if that ever matters.
TIMEZONE_ABBREVIATIONS = {
"America/Araguaina": "BRT",
"America/Argentina/Buenos_Aires": "ART",
"America/Bogota": "COT",
"America/Caracas": "VET",
"America/Godthab": "WGT",
"America/Nuuk": "WGT",
"America/Lima": "PET",
"America/Montevideo": "UYT",
"America/Santiago": "CLT",
"America/Sao_Paulo": "BRT",
"Asia/Aden": "AST",
"Asia/Almaty": "ALMT",
"Asia/Baghdad": "AST",
"Asia/Bahrain": "AST",
"Asia/Baku": "AZT",
"Asia/Bangkok": "ICT",
"Asia/Dacca": "BST",
"Asia/Dhaka": "BST",
"Asia/Dubai": "GST",
"Asia/Ho_Chi_Minh": "ICT",
"Asia/Irkutsk": "IRKT",
"Asia/Kabul": "AFT",
"Asia/Kathmandu": "NPT",
"Asia/Krasnoyarsk": "KRAT",
"Asia/Kuwait": "AST",
"Asia/Muscat": "GST",
"Asia/Novosibirsk": "NOVT",
"Asia/Qatar": "AST",
"Asia/Riyadh": "AST",
"Asia/Saigon": "ICT",
"Asia/Tashkent": "UZT",
"Asia/Tehran": "IRST",
"Asia/Yekaterinburg": "YEKT",
"Atlantic/Azores": "AZOT",
"Atlantic/Cape_Verde": "CVT",
"Europe/Istanbul": "TRT",
Comment on lines +208 to +239

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Curated map uses deprecated IANA names without their current aliases

America/Godthab was renamed to America/Nuuk in tzdata 2020b, and Asia/Saigon was superseded by Asia/Ho_Chi_Minh. Both old names still work as backward-compatibility aliases in ZoneInfo, so events that stored the old name resolve correctly — but if a user selects the current IANA name (e.g. America/Nuuk from a modern timezone picker), the curated-map lookup misses and falls through to the GMT offset format (GMT-3 instead of WGT). Adding the current canonical names as parallel entries (similar to how Asia/Dacca and Asia/Dhaka both appear for BST) would keep both spellings covered.

Prompt To Fix With AI
This is a comment left during a code review.
Path: buzz/utils.py
Line: 207-236

Comment:
**Curated map uses deprecated IANA names without their current aliases**

`America/Godthab` was renamed to `America/Nuuk` in tzdata 2020b, and `Asia/Saigon` was superseded by `Asia/Ho_Chi_Minh`. Both old names still work as backward-compatibility aliases in `ZoneInfo`, so events that stored the old name resolve correctly — but if a user selects the current IANA name (e.g. `America/Nuuk` from a modern timezone picker), the curated-map lookup misses and falls through to the GMT offset format (`GMT-3` instead of `WGT`). Adding the current canonical names as parallel entries (similar to how `Asia/Dacca` and `Asia/Dhaka` both appear for BST) would keep both spellings covered.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added America/Nuuk and Asia/Ho_Chi_Minh entries in 1318210. Note the field's options come from the Zoom-supported list in buzz_event.js (legacy names only), so the new spellings aren't reachable from the picker today — entries added for robustness, tested.

}


def get_time_zone_label(time_zone: str | None, reference_datetime: datetime | None = None) -> str:
"""Short display label for an IANA time zone, e.g. "IST", "GST", "GMT+5:45".

Resolution order: tzdata abbreviation for the reference date (DST-aware),
then the curated map, then a formatted GMT offset.
"""
if not time_zone:
return ""

try:
zone = ZoneInfo(time_zone)
except (ZoneInfoNotFoundError, ValueError):
return ""

reference = reference_datetime or now_datetime()
if reference.tzinfo:
moment = reference.astimezone(zone)
else:
moment = reference.replace(tzinfo=zone)

abbreviation = moment.tzname()
if re.fullmatch(r"[A-Z]{2,5}", abbreviation):
return abbreviation

if time_zone in TIMEZONE_ABBREVIATIONS:
return TIMEZONE_ABBREVIATIONS[time_zone]

total_minutes = int(moment.utcoffset().total_seconds()) // 60
sign = "+" if total_minutes >= 0 else "-"
hours, minutes = divmod(abs(total_minutes), 60)
label = f"GMT{sign}{hours}"
if minutes:
label += f":{minutes:02d}"
return label
4 changes: 2 additions & 2 deletions dashboard/src/components/EventDetailsHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@
<div>
<p class="font-medium text-ink-gray-8">
{{ formatEventTime(eventDetails.start_time, eventDetails.end_time) }}
<span v-if="eventDetails.time_zone"
>({{ eventDetails.time_zone }})</span
<span v-if="eventDetails.time_zone_label"
>({{ eventDetails.time_zone_label }})</span
>
</p>
</div>
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/types/Events/BuzzEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface BuzzEvent {
start_time?: string
/** Time Zone : Autocomplete */
time_zone?: any
/** Time Zone Label : Data */
time_zone_label?: string
/** End Date : Date */
end_date?: string
/** End Time : Time */
Expand Down
Loading