notifications: timezone fix + configurable site timezone
This commit is contained in:
80
frontend/src/utils/datetime.js
Normal file
80
frontend/src/utils/datetime.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// Site-timezone datetime helpers.
|
||||
//
|
||||
// Notification start/end times are stored as UTC and must be shown + entered in
|
||||
// the site's configured IANA timezone (settings key site_timezone), not the
|
||||
// viewer's browser zone, so every site and remote admin agrees on the wall
|
||||
// clock. These convert between a UTC instant and a 'YYYY-MM-DDTHH:mm' string in
|
||||
// a given zone (the value a datetime-local input holds).
|
||||
|
||||
const DEFAULT_TZ = 'America/New_York'
|
||||
|
||||
function pad(n) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
// Wall-clock parts of a UTC instant AS SEEN in tz. Uses Intl so it is correct
|
||||
// across DST without shipping a tz table.
|
||||
function zonedParts(date, tz) {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
const map = {}
|
||||
for (const p of dtf.formatToParts(date)) {
|
||||
if (p.type !== 'literal') map[p.type] = p.value
|
||||
}
|
||||
return {
|
||||
year: Number(map.year), month: Number(map.month), day: Number(map.day),
|
||||
hour: Number(map.hour), minute: Number(map.minute), second: Number(map.second),
|
||||
}
|
||||
}
|
||||
|
||||
// tz offset (ms) at the given instant: (wall-clock-as-UTC) - (real UTC).
|
||||
function tzOffsetMs(date, tz) {
|
||||
const p = zonedParts(date, tz)
|
||||
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second)
|
||||
return asUtc - date.getTime()
|
||||
}
|
||||
|
||||
// UTC instant (or ISO string) -> 'YYYY-MM-DDTHH:mm' wall clock in tz, for a
|
||||
// datetime-local input. Empty string on no/invalid input.
|
||||
export function zonedInputFromUtc(value, tz = DEFAULT_TZ) {
|
||||
if (!value) return ''
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (isNaN(date.getTime())) return ''
|
||||
const p = zonedParts(date, tz)
|
||||
return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`
|
||||
}
|
||||
|
||||
// 'YYYY-MM-DDTHH:mm' wall clock in tz -> UTC ISO string. Interprets the entered
|
||||
// time as tz-local (DST-aware) and returns the real instant. Empty -> null.
|
||||
export function utcFromZonedInput(localStr, tz = DEFAULT_TZ) {
|
||||
if (!localStr) return null
|
||||
const [datePart, timePart = '00:00'] = localStr.split('T')
|
||||
const [y, mo, d] = datePart.split('-').map(Number)
|
||||
const [h, mi] = timePart.split(':').map(Number)
|
||||
if ([y, mo, d, h, mi].some(Number.isNaN)) return null
|
||||
// Guess the instant as if the wall clock were UTC, then subtract the zone
|
||||
// offset AT that instant. One correction is exact except in the ~1h/yr DST
|
||||
// fold, where either side is a fair reading.
|
||||
const guess = Date.UTC(y, mo - 1, d, h, mi)
|
||||
const offset = tzOffsetMs(new Date(guess), tz)
|
||||
return new Date(guess - offset).toISOString()
|
||||
}
|
||||
|
||||
// UTC instant (or ISO) -> human string in tz, e.g. "Jul 30, 2026, 2:34 PM".
|
||||
export function formatInZone(value, tz = DEFAULT_TZ, opts = {}) {
|
||||
if (!value) return ''
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (isNaN(date.getTime())) return ''
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
...opts,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export { DEFAULT_TZ }
|
||||
@@ -74,6 +74,7 @@ const LABELS = {
|
||||
dualpath_single_machine: 'Dualpath as Single Machine',
|
||||
employee_directory_mode: 'Employee Directory Mode',
|
||||
usb_directory_mode: 'USB Directory Mode',
|
||||
site_timezone: 'Site Timezone',
|
||||
setup_complete: 'Setup Complete'
|
||||
}
|
||||
function prettyLabel(key) {
|
||||
@@ -83,7 +84,12 @@ function prettyLabel(key) {
|
||||
// Enumerated settings render as dropdowns to prevent typos.
|
||||
const OPTIONS = {
|
||||
employee_directory_mode: ['selfhosted', 'external'],
|
||||
usb_directory_mode: ['selfhosted', 'external']
|
||||
usb_directory_mode: ['selfhosted', 'external'],
|
||||
site_timezone: [
|
||||
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Phoenix',
|
||||
'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu',
|
||||
'America/Indiana/Indianapolis'
|
||||
]
|
||||
}
|
||||
|
||||
function isTrue(setting) {
|
||||
@@ -97,6 +103,7 @@ const GROUPS = [
|
||||
{ title: 'Behavior', keys: ['dualpath_single_machine'] },
|
||||
{ title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'contact_email_domain', 'employeeid_pattern'] },
|
||||
{ title: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] },
|
||||
{ title: 'Localization', keys: ['site_timezone'] },
|
||||
{ title: 'System', keys: ['setup_complete'] }
|
||||
]
|
||||
|
||||
@@ -108,7 +115,8 @@ const HELP = {
|
||||
employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.',
|
||||
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net',
|
||||
contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.',
|
||||
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.'
|
||||
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.',
|
||||
site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.'
|
||||
}
|
||||
function fieldHelp(key) {
|
||||
return HELP[key] || ''
|
||||
|
||||
@@ -12,7 +12,7 @@ from shopdb.api import db, success_response, error_response, paginated_response,
|
||||
|
||||
from ..models import Notification, NotificationType
|
||||
|
||||
from shopdb.api import require_permission
|
||||
from shopdb.api import require_permission, Setting
|
||||
|
||||
notifications_bp = Blueprint('notifications', __name__)
|
||||
|
||||
@@ -26,16 +26,41 @@ SPLIT_TYPECOLORS = frozenset({'recognition', 'training', 'recertification'})
|
||||
# an explicit end time, by notification typecolor.
|
||||
# recognition - clears at the next 8:00 AM Eastern (daily reset)
|
||||
# recertification - stays up two weeks (employees have time to book the course)
|
||||
EASTERN = ZoneInfo('America/New_York')
|
||||
_DEFAULT_TZ = 'America/New_York'
|
||||
RECERTIFICATION_DAYS = 14
|
||||
|
||||
|
||||
def _next_eastern_time(after, hour, minute=0):
|
||||
"""Next hour:minute America/New_York strictly after `after` (naive UTC),
|
||||
returned as naive UTC. Uses the tz database so it is correct across EST/EDT."""
|
||||
after_east = after.replace(tzinfo=timezone.utc).astimezone(EASTERN)
|
||||
target = after_east.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
|
||||
if target <= after_east:
|
||||
def _site_tz():
|
||||
"""Site-configured IANA timezone (settings key site_timezone), used for the
|
||||
daily-reset expiry math and any wall-clock computation. Falls back to
|
||||
America/New_York if unset or invalid."""
|
||||
row = Setting.query.filter_by(key='site_timezone').first()
|
||||
name = (row.value if row and row.value else _DEFAULT_TZ)
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return ZoneInfo(_DEFAULT_TZ)
|
||||
|
||||
|
||||
def _parse_utc(date_str):
|
||||
"""Parse an incoming ISO datetime to NAIVE UTC (how starttime/endtime store).
|
||||
|
||||
The client submits new Date(local).toISOString(), i.e. UTC with a 'Z'. A
|
||||
value without an offset is assumed already-UTC (not server-local) so the
|
||||
stored instant is unambiguous."""
|
||||
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _next_site_time(after, hour, minute=0):
|
||||
"""Next hour:minute in the site timezone strictly after `after` (naive UTC),
|
||||
returned as naive UTC. Uses the tz database so it is correct across DST."""
|
||||
tz = _site_tz()
|
||||
after_local = after.replace(tzinfo=timezone.utc).astimezone(tz)
|
||||
target = after_local.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
|
||||
if target <= after_local:
|
||||
target += timedelta(days=1)
|
||||
return target.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
@@ -51,13 +76,13 @@ def _auto_endtime(ntype, starttime):
|
||||
mode = getattr(ntype, 'expirymode', None) or 'none'
|
||||
if mode == 'dailytime':
|
||||
hour = ntype.expiryhour if ntype.expiryhour is not None else 8
|
||||
return _next_eastern_time(starttime, hour, ntype.expiryminute or 0)
|
||||
return _next_site_time(starttime, hour, ntype.expiryminute or 0)
|
||||
if mode == 'duration' and ntype.expirydays:
|
||||
return starttime + timedelta(days=int(ntype.expirydays))
|
||||
if mode == 'none':
|
||||
# legacy fallback for rule-bearing types created before the expiry columns
|
||||
if getattr(ntype, 'typecolor', None) == 'recognition':
|
||||
return _next_eastern_time(starttime, 8, 0)
|
||||
return _next_site_time(starttime, 8, 0)
|
||||
if getattr(ntype, 'typecolor', None) == 'recertification':
|
||||
return starttime + timedelta(days=RECERTIFICATION_DAYS)
|
||||
return None
|
||||
@@ -360,7 +385,7 @@ def create_notification():
|
||||
if data.get('starttime') or data.get('startdate'):
|
||||
try:
|
||||
date_str = data.get('starttime') or data.get('startdate')
|
||||
starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
starttime = _parse_utc(date_str)
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
|
||||
|
||||
@@ -368,7 +393,7 @@ def create_notification():
|
||||
if data.get('endtime') or data.get('enddate'):
|
||||
try:
|
||||
date_str = data.get('endtime') or data.get('enddate')
|
||||
endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
endtime = _parse_utc(date_str)
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
|
||||
|
||||
@@ -447,7 +472,7 @@ def update_notification(notification_id: int):
|
||||
date_str = data.get('starttime') or data.get('startdate')
|
||||
if date_str:
|
||||
try:
|
||||
n.starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
n.starttime = _parse_utc(date_str)
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
|
||||
else:
|
||||
@@ -457,7 +482,7 @@ def update_notification(notification_id: int):
|
||||
date_str = data.get('endtime') or data.get('enddate')
|
||||
if date_str:
|
||||
try:
|
||||
n.endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
n.endtime = _parse_utc(date_str)
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
|
||||
else:
|
||||
|
||||
@@ -231,13 +231,17 @@ function openEventFromTooltip(evt) {
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
// allDay events carry a site-local date-only value (YYYY-MM-DD). Build the
|
||||
// Date from local parts so it is not shifted a day by UTC-midnight parsing.
|
||||
const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr)
|
||||
const date = parts
|
||||
? new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
|
||||
: new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
Now
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-hint">When notification becomes visible</small>
|
||||
<small class="form-hint">When notification becomes visible ({{ siteTimezone }})</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -243,8 +243,9 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi } from '@/api'
|
||||
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi, settingsApi } from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { zonedInputFromUtc, utcFromZonedInput, DEFAULT_TZ } from '@/utils/datetime'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -278,6 +279,10 @@ const form = ref({
|
||||
employeesso: ''
|
||||
})
|
||||
|
||||
// Site timezone (settings key site_timezone) so form times show/enter in the
|
||||
// site's wall clock, not the viewer's browser zone. Loaded in onMounted.
|
||||
const siteTimezone = ref(DEFAULT_TZ)
|
||||
|
||||
function selectedTypeName() {
|
||||
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
|
||||
return selectedType?.typename?.toLowerCase() || ''
|
||||
@@ -303,13 +308,18 @@ const messagePlaceholder = computed(() =>
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load dropdown data in parallel
|
||||
const [typesRes, buRes, appsRes] = await Promise.all([
|
||||
// Load dropdown data + the site timezone in parallel. Resolve the tz FIRST
|
||||
// (before any formatDateForInput) so times render in the site's zone.
|
||||
const [typesRes, buRes, appsRes, tzRes] = await Promise.all([
|
||||
notificationsApi.types.list(),
|
||||
businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
|
||||
applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } }))
|
||||
applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } })),
|
||||
settingsApi.get('site_timezone').catch(() => null)
|
||||
])
|
||||
|
||||
const tzValue = tzRes?.data?.data?.value
|
||||
if (tzValue) siteTimezone.value = tzValue
|
||||
|
||||
types.value = typesRes.data.data || []
|
||||
businessUnits.value = buRes.data?.data || []
|
||||
applications.value = appsRes.data?.data || []
|
||||
@@ -364,14 +374,13 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// UTC value from the API -> a datetime-local string in the SITE timezone.
|
||||
function formatDateForInput(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toISOString().slice(0, 16)
|
||||
return zonedInputFromUtc(dateStr, siteTimezone.value)
|
||||
}
|
||||
|
||||
function setNow(field) {
|
||||
form.value[field] = formatDateForInput(new Date().toISOString())
|
||||
form.value[field] = zonedInputFromUtc(new Date(), siteTimezone.value)
|
||||
}
|
||||
|
||||
function onTypeChange() {
|
||||
@@ -379,7 +388,7 @@ function onTypeChange() {
|
||||
// (recognition clears at 8 AM Eastern; recertification runs two weeks). Set
|
||||
// start to now and leave end blank so the backend applies the per-type rule.
|
||||
if (isEmployeeType.value) {
|
||||
form.value.starttime = formatDateForInput(new Date().toISOString())
|
||||
form.value.starttime = zonedInputFromUtc(new Date(), siteTimezone.value)
|
||||
form.value.endtime = ''
|
||||
}
|
||||
}
|
||||
@@ -481,8 +490,8 @@ async function saveNotification() {
|
||||
appid: parseInt(form.value.appid) || null,
|
||||
ticketnumber: form.value.ticketnumber || null,
|
||||
link: form.value.link || null,
|
||||
starttime: form.value.starttime ? new Date(form.value.starttime).toISOString() : null,
|
||||
endtime: form.value.endtime ? new Date(form.value.endtime).toISOString() : null,
|
||||
starttime: utcFromZonedInput(form.value.starttime, siteTimezone.value),
|
||||
endtime: utcFromZonedInput(form.value.endtime, siteTimezone.value),
|
||||
isactive: form.value.isactive,
|
||||
isshopfloor: form.value.isshopfloor,
|
||||
employeesso: form.value.employeesso || null
|
||||
|
||||
@@ -88,9 +88,10 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { notificationsApi } from '@/api'
|
||||
import { notificationsApi, settingsApi } from '@/api'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
import { formatInZone, DEFAULT_TZ } from '@/utils/datetime'
|
||||
|
||||
const notifications = ref([])
|
||||
const types = ref([])
|
||||
@@ -101,10 +102,16 @@ const currentFilter = ref('')
|
||||
const perPage = ref(20)
|
||||
const total = ref(0)
|
||||
const totalPages = ref(1)
|
||||
const siteTimezone = ref(DEFAULT_TZ)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const tzRes = await settingsApi.get('site_timezone')
|
||||
const tzValue = tzRes?.data?.data?.value
|
||||
if (tzValue) siteTimezone.value = tzValue
|
||||
} catch (e) { /* keep default tz */ }
|
||||
await loadTypes()
|
||||
await loadNotifications()
|
||||
})
|
||||
@@ -170,6 +177,9 @@ function changePerPage(newPerPage) {
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
// startdate/enddate are UTC; show the site-zone calendar date.
|
||||
return formatInZone(dateStr, siteTimezone.value, {
|
||||
year: 'numeric', month: 'numeric', day: 'numeric', hour: undefined, minute: undefined
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,48 @@
|
||||
"""Notifications plugin models - adapted to existing database schema."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
_DEFAULT_TZ = 'America/New_York'
|
||||
|
||||
|
||||
def _site_zone():
|
||||
"""Site-configured IANA zone (settings key site_timezone) for calendar day
|
||||
placement. Imported lazily to avoid a circular import at model load."""
|
||||
from shopdb.api import Setting
|
||||
row = Setting.query.filter_by(key='site_timezone').first()
|
||||
name = row.value if row and row.value else _DEFAULT_TZ
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return ZoneInfo(_DEFAULT_TZ)
|
||||
|
||||
|
||||
def _site_date(dt):
|
||||
"""The calendar day (YYYY-MM-DD) a stored UTC datetime falls on in the site
|
||||
zone. allDay events must key off the site-local day, not the UTC day, or a
|
||||
late-evening notification lands on the wrong date for western sites."""
|
||||
if not dt:
|
||||
return None
|
||||
return dt.replace(tzinfo=timezone.utc).astimezone(_site_zone()).date().isoformat()
|
||||
|
||||
|
||||
def _utc_iso(dt):
|
||||
"""Serialize a stored datetime as an explicit-UTC ISO string.
|
||||
|
||||
starttime/endtime are stored NAIVE but always hold UTC wall-clock (the
|
||||
create/update parse normalizes to UTC). Emitting a bare naive isoformat let
|
||||
the browser read it as LOCAL time, shifting displays by the tz offset (a
|
||||
14:34 EDT notification showed 18:34). Tag it UTC so new Date() parses the
|
||||
real instant and renders in the viewer's zone.
|
||||
"""
|
||||
if not dt:
|
||||
return None
|
||||
return dt.replace(tzinfo=timezone.utc).isoformat()
|
||||
|
||||
|
||||
class NotificationType(db.Model):
|
||||
"""
|
||||
Notification type classification.
|
||||
@@ -120,10 +159,10 @@ class Notification(db.Model):
|
||||
'notification': self.notification,
|
||||
'title': self.title,
|
||||
'message': self.notification,
|
||||
'starttime': self.starttime.isoformat() if self.starttime else None,
|
||||
'endtime': self.endtime.isoformat() if self.endtime else None,
|
||||
'startdate': self.starttime.isoformat() if self.starttime else None,
|
||||
'enddate': self.endtime.isoformat() if self.endtime else None,
|
||||
'starttime': _utc_iso(self.starttime),
|
||||
'endtime': _utc_iso(self.endtime),
|
||||
'startdate': _utc_iso(self.starttime),
|
||||
'enddate': _utc_iso(self.endtime),
|
||||
'ticketnumber': self.ticketnumber,
|
||||
'link': self.link,
|
||||
'linkurl': self.link,
|
||||
@@ -175,8 +214,8 @@ class Notification(db.Model):
|
||||
return {
|
||||
'id': self.notificationid,
|
||||
'title': title,
|
||||
'start': self.starttime.isoformat() if self.starttime else None,
|
||||
'end': self.endtime.isoformat() if self.endtime else None,
|
||||
'start': _site_date(self.starttime),
|
||||
'end': _site_date(self.endtime),
|
||||
'allDay': True,
|
||||
'backgroundColor': color,
|
||||
'borderColor': color,
|
||||
|
||||
@@ -56,6 +56,7 @@ PUBLIC_SETTING_CATEGORIES = {'branding', 'map'}
|
||||
PUBLIC_SETTING_KEYS = {
|
||||
'site_base_url', 'facility_name', 'printer_hostname_template',
|
||||
'contact_email_domain', 'servicenow_enabled', 'setup_complete',
|
||||
'site_timezone',
|
||||
}
|
||||
|
||||
|
||||
@@ -443,6 +444,13 @@ def build_default_settings():
|
||||
'category': 'site',
|
||||
'description': 'Set true once the first-run setup wizard has been finished'
|
||||
},
|
||||
{
|
||||
'key': 'site_timezone',
|
||||
'value': 'America/New_York',
|
||||
'valuetype': 'string',
|
||||
'category': 'site',
|
||||
'description': "IANA timezone for this site (e.g. America/New_York, America/Chicago). Notification start/end times and daily-reset expiry are computed in this zone."
|
||||
},
|
||||
{
|
||||
'key': 'employee_directory_mode',
|
||||
'value': 'selfhosted',
|
||||
|
||||
Reference in New Issue
Block a user