Implement timezone-aware datetime display across all pages
All datetime fields now automatically convert from UTC (database storage) to the configured UI timezone (Settings > General > UI Timezone) for display. Changes: - Added local_datetime Jinja2 template filter in app/__init__.py - Converts UTC datetime to UI timezone using zoneinfo.ZoneInfo - Accepts optional strftime format parameter (default: '%Y-%m-%d %H:%M:%S') - Returns empty string for None values - Falls back to UTC display if conversion fails - Updated all datetime displays across 15+ templates: - customers.html: autotask_last_sync_at - settings.html: autotask_last_sync_at - feedback.html, feedback_detail.html: created_at, updated_at, resolved_at - logging.html: created_at - overrides.html: start_at, end_at - archived_jobs.html: archived_at - tickets.html, ticket_detail.html: resolved_at, run_at - inbox.html, inbox_deleted.html, admin_all_mail.html: received_at, parsed_at, deleted_at - job_detail.html: run_at, reviewed_at - remark_detail.html: run_at - settings_news_reads.html: read_at - Database values remain in UTC for consistency - Default timezone: Europe/Amsterdam (configurable via SystemSettings.ui_timezone) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
86308d2001
commit
8e8b7a4412
@ -63,6 +63,57 @@ def create_app():
|
|||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
|
|
||||||
|
@app.template_filter("local_datetime")
|
||||||
|
def format_local_datetime(utc_dt, format="%Y-%m-%d %H:%M:%S"):
|
||||||
|
"""Convert UTC datetime to UI timezone and format as string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
utc_dt: datetime object in UTC (or None)
|
||||||
|
format: strftime format string (default: '%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted datetime string in UI timezone, or empty string if input is None
|
||||||
|
"""
|
||||||
|
if utc_dt is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
except ImportError:
|
||||||
|
ZoneInfo = None # type: ignore
|
||||||
|
|
||||||
|
# Get UI timezone from settings
|
||||||
|
tz_name = "Europe/Amsterdam"
|
||||||
|
try:
|
||||||
|
from .models import SystemSettings
|
||||||
|
|
||||||
|
settings = SystemSettings.query.first()
|
||||||
|
if settings and getattr(settings, "ui_timezone", None):
|
||||||
|
tz_name = settings.ui_timezone
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Convert UTC to UI timezone
|
||||||
|
if ZoneInfo:
|
||||||
|
try:
|
||||||
|
utc_tz = ZoneInfo("UTC")
|
||||||
|
local_tz = ZoneInfo(tz_name)
|
||||||
|
|
||||||
|
# Ensure utc_dt is timezone-aware (assume UTC if naive)
|
||||||
|
if utc_dt.tzinfo is None:
|
||||||
|
utc_dt = utc_dt.replace(tzinfo=utc_tz)
|
||||||
|
|
||||||
|
# Convert to local timezone
|
||||||
|
local_dt = utc_dt.astimezone(local_tz)
|
||||||
|
return local_dt.strftime(format)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: return UTC time if conversion fails
|
||||||
|
return utc_dt.strftime(format)
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def _redirect_to_dashboard_on_first_open_each_day():
|
def _redirect_to_dashboard_on_first_open_each_day():
|
||||||
"""Redirect the first authenticated page view of the day to the dashboard.
|
"""Redirect the first authenticated page view of the day to the dashboard.
|
||||||
|
|||||||
@ -126,7 +126,7 @@
|
|||||||
{% if rows %}
|
{% if rows %}
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr class="mail-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
|
<tr class="mail-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
|
||||||
<td>{{ row.received_at }}</td>
|
<td>{{ row.received_at|local_datetime }}</td>
|
||||||
<td>{{ row.from_address }}</td>
|
<td>{{ row.from_address }}</td>
|
||||||
<td>{{ row.subject }}</td>
|
<td>{{ row.subject }}</td>
|
||||||
<td>{{ row.backup_software }}</td>
|
<td>{{ row.backup_software }}</td>
|
||||||
@ -139,7 +139,7 @@
|
|||||||
<span class="badge bg-warning text-dark">Unlinked</span>
|
<span class="badge bg-warning text-dark">Unlinked</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ row.parsed_at }}</td>
|
<td>{{ row.parsed_at|local_datetime }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if row.has_eml %}
|
{% if row.has_eml %}
|
||||||
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}" onclick="event.stopPropagation();">EML</a>
|
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}" onclick="event.stopPropagation();">EML</a>
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<a class="text-decoration-none" href="{{ url_for('main.job_detail', job_id=j.id) }}">{{ j.job_name }}</a>
|
<a class="text-decoration-none" href="{{ url_for('main.job_detail', job_id=j.id) }}">{{ j.job_name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ j.archived_at }}</td>
|
<td>{{ j.archived_at|local_datetime }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<form method="post" action="{{ url_for('main.unarchive_job', job_id=j.id) }}" style="display:inline;">
|
<form method="post" action="{{ url_for('main.unarchive_job', job_id=j.id) }}" style="display:inline;">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Restore</button>
|
<button type="submit" class="btn btn-sm btn-outline-secondary">Restore</button>
|
||||||
|
|||||||
@ -88,7 +88,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if c.autotask_last_sync_at %}
|
{% if c.autotask_last_sync_at %}
|
||||||
<div class="text-muted small">Checked: {{ c.autotask_last_sync_at }}</div>
|
<div class="text-muted small">Checked: {{ c.autotask_last_sync_at|local_datetime }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% if can_manage %}
|
{% if can_manage %}
|
||||||
@ -104,7 +104,7 @@
|
|||||||
data-autotask-company-id="{{ c.autotask_company_id or '' }}"
|
data-autotask-company-id="{{ c.autotask_company_id or '' }}"
|
||||||
data-autotask-company-name="{{ c.autotask_company_name or '' }}"
|
data-autotask-company-name="{{ c.autotask_company_name or '' }}"
|
||||||
data-autotask-mapping-status="{{ c.autotask_mapping_status or '' }}"
|
data-autotask-mapping-status="{{ c.autotask_mapping_status or '' }}"
|
||||||
data-autotask-last-sync-at="{{ c.autotask_last_sync_at or '' }}"
|
data-autotask-last-sync-at="{{ c.autotask_last_sync_at|local_datetime if c.autotask_last_sync_at else '' }}"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -87,8 +87,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div>{{ i.created_at }}</div>
|
<div>{{ i.created_at|local_datetime }}</div>
|
||||||
<div class="text-muted" style="font-size: 0.85rem;">Updated {{ i.updated_at }}</div>
|
<div class="text-muted" style="font-size: 0.85rem;">Updated {{ i.updated_at|local_datetime }}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -32,12 +32,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||||
<div class="text-muted" style="font-size: 0.9rem;">
|
<div class="text-muted" style="font-size: 0.9rem;">
|
||||||
Created {{ item.created_at }}
|
Created {{ item.created_at|local_datetime }}
|
||||||
<span class="mx-1">•</span>
|
<span class="mx-1">•</span>
|
||||||
Updated {{ item.updated_at }}
|
Updated {{ item.updated_at|local_datetime }}
|
||||||
{% if item.status == 'resolved' and item.resolved_at %}
|
{% if item.status == 'resolved' and item.resolved_at %}
|
||||||
<span class="mx-1">•</span>
|
<span class="mx-1">•</span>
|
||||||
Resolved {{ item.resolved_at }}{% if resolved_by_name %} by {{ resolved_by_name }}{% endif %}
|
Resolved {{ item.resolved_at|local_datetime }}{% if resolved_by_name %} by {{ resolved_by_name }}{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('main.feedback_vote', item_id=item.id) }}">
|
<form method="post" action="{{ url_for('main.feedback_vote', item_id=item.id) }}">
|
||||||
@ -59,7 +59,7 @@
|
|||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<strong>{{ reply_user_map.get(r.user_id, '') or ('User #' ~ r.user_id) }}</strong>
|
<strong>{{ reply_user_map.get(r.user_id, '') or ('User #' ~ r.user_id) }}</strong>
|
||||||
<span class="text-muted" style="font-size: 0.85rem;">
|
<span class="text-muted" style="font-size: 0.85rem;">
|
||||||
{{ r.created_at.strftime('%d-%m-%Y %H:%M:%S') if r.created_at else '' }}
|
{{ r.created_at|local_datetime('%d-%m-%Y %H:%M:%S') if r.created_at else '' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="white-space: pre-wrap;">{{ r.message }}</div>
|
<div style="white-space: pre-wrap;">{{ r.message }}</div>
|
||||||
|
|||||||
@ -98,12 +98,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<td>{{ row.from_address }}</td>
|
<td>{{ row.from_address }}</td>
|
||||||
<td>{{ row.subject }}</td>
|
<td>{{ row.subject }}</td>
|
||||||
<td>{{ row.received_at }}</td>
|
<td>{{ row.received_at|local_datetime }}</td>
|
||||||
<td>{{ row.backup_software }}</td>
|
<td>{{ row.backup_software }}</td>
|
||||||
<td>{{ row.backup_type }}</td>
|
<td>{{ row.backup_type }}</td>
|
||||||
<td>{{ row.job_name }}</td>
|
<td>{{ row.job_name }}</td>
|
||||||
<td>{{ row.overall_status }}</td>
|
<td>{{ row.overall_status }}</td>
|
||||||
<td>{{ row.parsed_at }}</td>
|
<td>{{ row.parsed_at|local_datetime }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if row.has_eml %}
|
{% if row.has_eml %}
|
||||||
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}">EML</a>
|
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}">EML</a>
|
||||||
|
|||||||
@ -60,9 +60,9 @@
|
|||||||
<tr class="deleted-mail-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
|
<tr class="deleted-mail-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
|
||||||
<td>{{ row.from_address }}</td>
|
<td>{{ row.from_address }}</td>
|
||||||
<td>{{ row.subject }}</td>
|
<td>{{ row.subject }}</td>
|
||||||
<td>{{ row.received_at }}</td>
|
<td>{{ row.received_at|local_datetime }}</td>
|
||||||
<td>{{ row.deleted_by }}</td>
|
<td>{{ row.deleted_by }}</td>
|
||||||
<td>{{ row.deleted_at }}</td>
|
<td>{{ row.deleted_at|local_datetime }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if row.has_eml %}
|
{% if row.has_eml %}
|
||||||
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}">EML</a>
|
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}">EML</a>
|
||||||
|
|||||||
@ -82,7 +82,7 @@
|
|||||||
{% for r in history_rows %}
|
{% for r in history_rows %}
|
||||||
<tr{% if r.mail_message_id %} class="jobrun-row" data-message-id="{{ r.mail_message_id }}" data-run-id="{{ r.id }}" data-ticket-codes="{{ (r.ticket_codes or [])|tojson|forceescape }}" data-remark-items="{{ (r.remark_items or [])|tojson|forceescape }}" style="cursor: pointer;"{% endif %}>
|
<tr{% if r.mail_message_id %} class="jobrun-row" data-message-id="{{ r.mail_message_id }}" data-run-id="{{ r.id }}" data-ticket-codes="{{ (r.ticket_codes or [])|tojson|forceescape }}" data-remark-items="{{ (r.remark_items or [])|tojson|forceescape }}" style="cursor: pointer;"{% endif %}>
|
||||||
<td>{{ r.run_day }}</td>
|
<td>{{ r.run_day }}</td>
|
||||||
<td>{{ r.run_at }}</td>
|
<td>{{ r.run_at|local_datetime }}</td>
|
||||||
{% set _s = (r.status or "")|lower %}
|
{% set _s = (r.status or "")|lower %}
|
||||||
{% set _is_override = (r.override_applied is defined and r.override_applied) or ('override' in _s) %}
|
{% set _is_override = (r.override_applied is defined and r.override_applied) or ('override' in _s) %}
|
||||||
{% set _dot = '' %}
|
{% set _dot = '' %}
|
||||||
@ -99,7 +99,7 @@
|
|||||||
<td class="status-text {% if r.override_applied %}status-override{% endif %}">{% if r.override_applied %}<span class="status-dot dot-override me-2" aria-hidden="true"></span>Override{% endif %}</td>
|
<td class="status-text {% if r.override_applied %}status-override{% endif %}">{% if r.override_applied %}<span class="status-dot dot-override me-2" aria-hidden="true"></span>Override{% endif %}</td>
|
||||||
{% if active_role == 'admin' %}
|
{% if active_role == 'admin' %}
|
||||||
<td>{{ r.reviewed_by }}</td>
|
<td>{{ r.reviewed_by }}</td>
|
||||||
<td>{{ r.reviewed_at }}</td>
|
<td>{{ r.reviewed_at|local_datetime }}</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -46,7 +46,7 @@
|
|||||||
{% if logs %}
|
{% if logs %}
|
||||||
{% for log in logs %}
|
{% for log in logs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ log.created_at }}</td>
|
<td>{{ log.created_at|local_datetime }}</td>
|
||||||
<td>{{ log.user or "-" }}</td>
|
<td>{{ log.user or "-" }}</td>
|
||||||
<td>{{ log.event_type }}</td>
|
<td>{{ log.event_type }}</td>
|
||||||
<td>{{ log.message }}</td>
|
<td>{{ log.message }}</td>
|
||||||
|
|||||||
@ -130,8 +130,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ ov.level }}</td>
|
<td>{{ ov.level }}</td>
|
||||||
<td>{{ ov.scope }}</td>
|
<td>{{ ov.scope }}</td>
|
||||||
<td>{{ ov.start_at }}</td>
|
<td>{{ ov.start_at|local_datetime }}</td>
|
||||||
<td>{{ ov.end_at or "-" }}</td>
|
<td>{{ ov.end_at|local_datetime if ov.end_at else "-" }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if ov.active %}
|
{% if ov.active %}
|
||||||
<span class="badge bg-success">Active</span>
|
<span class="badge bg-success">Active</span>
|
||||||
|
|||||||
@ -76,7 +76,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for r in runs %}
|
{% for r in runs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-nowrap">{{ r.run_at }}</td>
|
<td class="text-nowrap">{{ r.run_at|local_datetime }}</td>
|
||||||
<td>{{ r.customer_name }}</td>
|
<td>{{ r.customer_name }}</td>
|
||||||
<td>{{ r.job_name }}</td>
|
<td>{{ r.job_name }}</td>
|
||||||
<td>{{ r.status }}</td>
|
<td>{{ r.status }}</td>
|
||||||
|
|||||||
@ -476,7 +476,7 @@
|
|||||||
<div class="text-muted small">Last reference data sync</div>
|
<div class="text-muted small">Last reference data sync</div>
|
||||||
<div class="fw-semibold">
|
<div class="fw-semibold">
|
||||||
{% if autotask_last_sync_at %}
|
{% if autotask_last_sync_at %}
|
||||||
{{ autotask_last_sync_at }}
|
{{ autotask_last_sync_at|local_datetime }}
|
||||||
{% else %}
|
{% else %}
|
||||||
never
|
never
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
{% for read, user in reads %}
|
{% for read, user in reads %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user.username }}</td>
|
<td>{{ user.username }}</td>
|
||||||
<td>{{ read.read_at }}</td>
|
<td>{{ read.read_at|local_datetime }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -73,7 +73,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for r in runs %}
|
{% for r in runs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-nowrap">{{ r.run_at }}</td>
|
<td class="text-nowrap">{{ r.run_at|local_datetime }}</td>
|
||||||
<td>{{ r.customer_name }}</td>
|
<td>{{ r.customer_name }}</td>
|
||||||
<td>{{ r.job_name }}</td>
|
<td>{{ r.job_name }}</td>
|
||||||
<td>{{ r.status }}</td>
|
<td>{{ r.status }}</td>
|
||||||
|
|||||||
@ -87,7 +87,7 @@
|
|||||||
<td class="text-end">{{ t.linked_runs }}</td>
|
<td class="text-end">{{ t.linked_runs }}</td>
|
||||||
<td class="text-nowrap">{{ t.active_from_date }}</td>
|
<td class="text-nowrap">{{ t.active_from_date }}</td>
|
||||||
<td class="text-nowrap">{{ t.start_date }}</td>
|
<td class="text-nowrap">{{ t.start_date }}</td>
|
||||||
<td class="text-nowrap">{{ t.resolved_at }}</td>
|
<td class="text-nowrap">{{ t.resolved_at|local_datetime }}</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.ticket_detail', ticket_id=t.id) }}">View</a>
|
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.ticket_detail', ticket_id=t.id) }}">View</a>
|
||||||
{% if t.active and t.job_id %}
|
{% if t.active and t.job_id %}
|
||||||
@ -142,7 +142,7 @@
|
|||||||
<td>{{ r.scope_summary }}</td>
|
<td>{{ r.scope_summary }}</td>
|
||||||
<td class="text-end">{{ r.linked_runs }}</td>
|
<td class="text-end">{{ r.linked_runs }}</td>
|
||||||
<td class="text-nowrap">{{ r.start_date }}</td>
|
<td class="text-nowrap">{{ r.start_date }}</td>
|
||||||
<td class="text-nowrap">{{ r.resolved_at }}</td>
|
<td class="text-nowrap">{{ r.resolved_at|local_datetime }}</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.remark_detail', remark_id=r.id) }}">View</a>
|
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.remark_detail', remark_id=r.id) }}">View</a>
|
||||||
{% if r.active and r.job_id %}
|
{% if r.active and r.job_id %}
|
||||||
|
|||||||
@ -44,6 +44,14 @@ This file documents all changes made to this project via Claude Code.
|
|||||||
- Schema remains `approved_jobs_export_v1` for backwards compatibility
|
- Schema remains `approved_jobs_export_v1` for backwards compatibility
|
||||||
- Import message now shows both created and updated customer counts
|
- Import message now shows both created and updated customer counts
|
||||||
- Enables preservation of Autotask company mappings during system reset/migration workflows
|
- Enables preservation of Autotask company mappings during system reset/migration workflows
|
||||||
|
- Implemented timezone-aware datetime display across all pages:
|
||||||
|
- **Template Filter**: Added `local_datetime` Jinja2 filter to convert UTC datetimes to UI timezone
|
||||||
|
- **Automatic Conversion**: All datetime fields now automatically display in configured timezone (Settings > General > UI Timezone)
|
||||||
|
- **Database**: All datetime values remain stored in UTC for consistency
|
||||||
|
- **Affected Pages**: Customers (Autotask sync time), Settings (reference data sync), Feedback, Logging, Overrides, Archived Jobs, Tickets, Remarks, Inbox, Job Detail, Admin Mail
|
||||||
|
- **Custom Format Support**: Filter accepts strftime format parameter (e.g., `|local_datetime('%d-%m-%Y %H:%M')`)
|
||||||
|
- **Graceful Fallback**: Falls back to UTC display if timezone conversion fails
|
||||||
|
- **Default Timezone**: Europe/Amsterdam (configurable via SystemSettings.ui_timezone)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Renamed "Refresh" button to "Search" in Link existing Autotask ticket modal for better clarity (the button performs a search operation)
|
- Renamed "Refresh" button to "Search" in Link existing Autotask ticket modal for better clarity (the button performs a search operation)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user