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)
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",
}


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