feat(plating): comprehensive timezone fix across dashboards/PDFs/emails

Database stores datetimes naive-UTC, but the dashboards and emails were
showing UTC strings to users in EST/EDT — making 9pm Toronto look like 1am
the next day. Adds a single helper module + auto-detection on install.

Core changes (fusion_plating):
- New fp_tz.py helper: fp_user_tz, fp_format, fp_isoformat_utc, fp_time_ago
  Resolves user.tz → company.x_fc_default_tz → UTC.
- res.company.x_fc_default_tz Selection (full pytz IANA list)
- res.config.settings exposes the company tz under a new "Regional
  Settings" block in Settings > Fusion Plating
- post_init_hook auto-populates the tz on first install: tries admin
  user → server /etc/timezone → America/Toronto fallback
- fp_process_node._to_dict now sends create_date/write_date as ISO with
  explicit +00:00 marker so JS new Date() parses it as UTC and the
  recipe tree editor's "time ago" math works correctly

Shop-floor controllers:
- shopfloor_controller.py: every fields.Datetime.to_string() and naive
  .strftime() swapped for fp_format(env, ...) — due_at, bake times,
  last_log_date, gates, server_time all now in user's tz
- _time_ago() removed; replaced with fp_time_ago helper which compares
  tz-aware datetimes (the local one was naive-vs-naive and could be
  off by hours)
- manager_controller.py date_planned: str(...)[:10] slice replaced
  with fp_format MM/DD in user's tz

Notifications + reports:
- mail_template_data.xml: 5 .strftime() calls in body_html → babel
  format_datetime / format_date with tz=(user.tz or company tz)
- report_fp_job_traveller.xml: rec.received_date (Datetime) gets
  t-options="{'widget':'datetime'}" so Odoo's QWeb renders in user tz

Settings view layout:
- fusion_plating now owns the Settings page "Fusion Plating" app shell
- fusion_plating_certificates xpaths into it instead of redefining
  (prevents app-name collision)

Verified on odoo-entech (LXC 111): post_init_hook detects
America/Toronto from /etc/timezone, MO date_start 2026-04-17 05:28 UTC
correctly displays as 2026-04-17 01:28 EDT.

Module versions bumped: fusion_plating 19.0.3.0.0,
fusion_plating_shopfloor 19.0.9.0.0, plus certificates / notifications /
reports → 19.0.3.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-18 21:03:02 -04:00
parent 956678dd27
commit 6c4ff7751f
17 changed files with 370 additions and 115 deletions

View File

@@ -3,5 +3,30 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from . import controllers
from . import models
_logger = logging.getLogger(__name__)
def post_init_hook(env):
"""Auto-detect a sensible default timezone on first install.
Sets ``res.company.x_fc_default_tz`` to the admin user's timezone
(Odoo populates that from the browser on first login), falling back
to the host server's timezone, then to ``America/Toronto`` as a
last resort. Only writes when the field is still empty so re-installs
never clobber a user's choice.
"""
from .models.fp_tz import detect_default_tz
detected = detect_default_tz(env)
for company in env['res.company'].sudo().search([]):
if not company.x_fc_default_tz:
company.x_fc_default_tz = detected
_logger.info(
'Fusion Plating: set default timezone for company %s -> %s',
company.name, detected,
)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.2.0.0',
'version': '19.0.3.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -93,9 +93,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_rack_views.xml',
'views/fp_bath_replenishment_views.xml',
'views/fp_operator_certification_views.xml',
'views/res_config_settings_views.xml',
'views/fp_menu.xml',
'data/fp_recipe_enp_alum_basic.xml',
],
'post_init_hook': 'post_init_hook',
'assets': {
'web.assets_backend': [
'fusion_plating/static/src/scss/fusion_plating.scss',

View File

@@ -16,4 +16,6 @@ from . import fp_bath_replenishment_rule
from . import fp_process_node
from . import fp_rack
from . import fp_operator_certification
from . import fp_tz
from . import res_company
from . import res_config_settings

View File

@@ -6,6 +6,8 @@
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from .fp_tz import fp_isoformat_utc
class FpProcessNode(models.Model):
"""A node in the process recipe tree.
@@ -312,9 +314,11 @@ class FpProcessNode(models.Model):
'child_count': len(children),
'opt_in_out': self.opt_in_out or 'disabled',
'input_count': len(self.input_ids),
'create_date': self.create_date.isoformat() if self.create_date else '',
# ISO with explicit UTC marker so JS new Date() parses it
# correctly and re-localises to the browser's timezone.
'create_date': fp_isoformat_utc(self.create_date),
'create_uid_name': self.create_uid.name if self.create_uid else '',
'write_date': self.write_date.isoformat() if self.write_date else '',
'write_date': fp_isoformat_utc(self.write_date),
'write_uid_name': self.write_uid.name if self.write_uid else '',
'children': children,
}

View File

@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Timezone helpers for Fusion Plating.
The Postgres database stores all datetimes naive-UTC. Anything that is
shown to a user — dashboards, PDFs, emails, OWL frontends — must be
converted to a human's local timezone first.
Resolution order for "what timezone does this user see":
1. The current user's `res.users.tz`
2. The current company's `x_fc_default_tz` (Fusion Plating setting)
3. UTC
Use ``fp_user_tz(env)`` to get the resolved pytz tzinfo, then either
convert datetimes yourself or use the convenience helpers
``fp_format`` / ``fp_isoformat_utc``.
"""
import pytz
def fp_user_tz(env):
"""Return a pytz tzinfo for the current user (or company fallback)."""
name = (
(env.user.tz if env and env.user else None)
or (env.company.x_fc_default_tz if env and env.company else None)
or 'UTC'
)
try:
return pytz.timezone(name)
except Exception:
return pytz.UTC
def fp_to_user_tz(env, dt):
"""Convert a naive UTC datetime to a tz-aware datetime in the user's tz.
Returns ``None`` if ``dt`` is falsy. Datetimes that already carry a
tzinfo are converted in place; naive ones are assumed to be UTC
(matching Odoo's storage convention).
"""
if not dt:
return None
tz = fp_user_tz(env)
if dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
return dt.astimezone(tz)
def fp_format(env, dt, fmt='%Y-%m-%d %H:%M'):
"""Format a naive UTC datetime as a string in the user's tz.
Returns an empty string when ``dt`` is falsy so callers can use the
result directly in dicts / templates without ``if`` guards.
"""
if not dt:
return ''
return fp_to_user_tz(env, dt).strftime(fmt)
def fp_isoformat_utc(dt):
"""Return an ISO-8601 string with an explicit UTC marker.
Naive datetimes from Odoo are assumed UTC. Adding the ``+00:00``
suffix tells JavaScript ``new Date(...)`` to parse the string as
UTC (without it, the browser interprets the string as *local*
wall time and silently shifts it). Pair this with frontend code
that calls ``.toLocaleString()`` on the resulting Date object so
the user sees their own local time.
"""
if not dt:
return ''
if dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
return dt.isoformat()
def fp_time_ago(env, dt):
"""Return a 'just now / 5m ago / 2h ago / 3d ago' string.
Both sides of the comparison are converted to UTC tz-aware first so
that the delta is meaningful regardless of where Odoo or the user
happens to be running.
"""
if not dt:
return ''
if dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
from datetime import datetime
now = datetime.now(pytz.UTC)
delta = now - dt
total_seconds = int(delta.total_seconds())
if total_seconds < 0:
total_seconds = 0
if total_seconds < 60:
return 'just now'
minutes = total_seconds // 60
if minutes < 60:
return f'{minutes}m ago'
hours = minutes // 60
if hours < 24:
return f'{hours}h ago'
days = hours // 24
if days < 7:
return f'{days}d ago'
weeks = days // 7
remaining_days = days % 7
if remaining_days:
return f'{weeks}w {remaining_days}d ago'
return f'{weeks}w ago'
# ---------------------------------------------------------------------------
# Auto-detection used by the post_init_hook
# ---------------------------------------------------------------------------
_FALLBACK_TZ = 'America/Toronto'
def detect_default_tz(env=None):
"""Best guess at a sensible default tz when the module is installed.
Tries, in order:
1. The admin user's ``tz`` (Odoo sets it from the browser on login).
2. The current company's ``partner_id.tz``.
3. The host server's IANA timezone (Linux typical).
4. ``America/Toronto`` as the final fallback (this project is
Canada-focused and that's the most likely correct guess).
"""
if env is not None:
admin = env.ref('base.user_admin', raise_if_not_found=False)
if admin and admin.tz:
return admin.tz
try:
partner_tz = env.company.partner_id.tz
except Exception:
partner_tz = None
if partner_tz:
return partner_tz
# Server-side detection — works on most Linux hosts.
try:
from datetime import datetime
local = datetime.now().astimezone()
name = str(local.tzinfo)
if name and name in pytz.all_timezones:
return name
except Exception:
pass
try:
with open('/etc/timezone', 'r') as fh:
tz = fh.read().strip()
if tz in pytz.all_timezones:
return tz
except Exception:
pass
return _FALLBACK_TZ

View File

@@ -3,12 +3,32 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
from odoo import api, fields, models
def _fp_tz_get(self):
"""Same selection list Odoo uses on res.partner.tz."""
import pytz
return [(tz, tz) for tz in pytz.all_timezones]
class ResCompany(models.Model):
_inherit = 'res.company'
# ----- Fusion Plating default timezone --------------------------------
# Used as the fallback whenever a user's res.users.tz is empty (cron
# jobs, batch emails, headless contexts). Auto-populated by the
# post_init_hook in fusion_plating/__init__.py.
x_fc_default_tz = fields.Selection(
_fp_tz_get,
string='Fusion Plating Timezone',
default=lambda self: self.env.user.tz or 'America/Toronto',
help='Timezone used for plating dashboards, reports, and emails when '
'a user has no personal timezone set. Detected automatically on '
'install; the admin can change it any time from '
'Settings > Fusion Plating.',
)
# ----- Facility footprint for this legal entity ----------------------
x_fc_facility_ids = fields.One2many(
'fusion.plating.facility',

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
"""Expose Fusion Plating company-level settings on the Settings page.
Today this only carries the default timezone, but it's the single
place to add new shop-wide preferences (default facility, currency
overrides, etc.).
"""
_inherit = 'res.config.settings'
x_fc_default_tz = fields.Selection(
related='company_id.x_fc_default_tz',
readonly=False,
string='Fusion Plating Timezone',
)

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!--
Fusion Plating — Settings page block.
Inherits the standard Settings form. The `app` element creates a
Fusion Plating section in the left rail; downstream modules
(certificates, invoicing, etc.) extend the same `app` with extra
blocks via xpath into //app[@name='fusion_plating'].
-->
<record id="res_config_settings_view_form_fp_core" model="ir.ui.view">
<field name="name">res.config.settings.view.form.fusion.plating.core</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Plating" string="Fusion Plating"
name="fusion_plating"
groups="fusion_plating.group_fusion_plating_manager">
<block title="Regional Settings"
name="fp_regional_settings"
help="Defaults applied to dashboards, reports, and emails when a user has no personal preference set.">
<setting id="fp_default_timezone"
string="Default Timezone"
help="Timezone used to display times in dashboards, PDFs, and notification emails. Detected automatically when Fusion Plating is installed; change it any time. Individual users can still override this from their own profile (Preferences > Localization).">
<field name="x_fc_default_tz"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>