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:
@@ -6,6 +6,7 @@
|
||||
|
||||
import logging
|
||||
from odoo import http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -89,7 +90,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
'customer': partner.name if partner else '',
|
||||
'product': mo.product_id.display_name if mo.product_id else '',
|
||||
'qty_total': int(mo.product_qty or 0),
|
||||
'date_planned': str(mo.date_start)[:10] if mo.date_start else '',
|
||||
'date_planned': fp_format(request.env, mo.date_start, fmt='%Y-%m-%d'),
|
||||
'recipe': mo.x_fc_recipe_id.name if mo.x_fc_recipe_id else '',
|
||||
'priority_any': max(
|
||||
[int(w.x_fc_priority or '0') for w in wos] + [0]
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import (
|
||||
fp_format,
|
||||
fp_isoformat_utc,
|
||||
fp_time_ago,
|
||||
)
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
|
||||
@@ -103,7 +107,7 @@ class FpShopfloorController(http.Controller):
|
||||
'name': bw.name,
|
||||
'state': bw.state,
|
||||
'time_remaining': bw.time_remaining_display,
|
||||
'bake_required_by': fields.Datetime.to_string(bw.bake_required_by) if bw.bake_required_by else '',
|
||||
'bake_required_by': fp_format(request.env, bw.bake_required_by),
|
||||
}
|
||||
|
||||
if code.startswith('FP-OVEN:'):
|
||||
@@ -194,7 +198,7 @@ class FpShopfloorController(http.Controller):
|
||||
return {
|
||||
'ok': True,
|
||||
'state': bw.state,
|
||||
'bake_start_time': fields.Datetime.to_string(bw.bake_start_time) if bw.bake_start_time else '',
|
||||
'bake_start_time': fp_format(request.env, bw.bake_start_time),
|
||||
}
|
||||
|
||||
@http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user')
|
||||
@@ -206,7 +210,7 @@ class FpShopfloorController(http.Controller):
|
||||
return {
|
||||
'ok': True,
|
||||
'state': bw.state,
|
||||
'bake_end_time': fields.Datetime.to_string(bw.bake_end_time) if bw.bake_end_time else '',
|
||||
'bake_end_time': fp_format(request.env, bw.bake_end_time),
|
||||
'bake_duration_hours': bw.bake_duration_hours,
|
||||
}
|
||||
|
||||
@@ -421,7 +425,7 @@ class FpShopfloorController(http.Controller):
|
||||
role.name if role else '',
|
||||
])),
|
||||
'priority': 90 if wo.state == 'ready' else 60,
|
||||
'due_at': fields.Datetime.to_string(wo.date_start) if wo.date_start else '',
|
||||
'due_at': fp_format(request.env, wo.date_start),
|
||||
'source_model': 'mrp.workorder',
|
||||
'source_id': wo.id,
|
||||
'wo_state': wo.state,
|
||||
@@ -439,7 +443,7 @@ class FpShopfloorController(http.Controller):
|
||||
'label': r.label,
|
||||
'description': r.description,
|
||||
'priority': r.priority,
|
||||
'due_at': fields.Datetime.to_string(r.due_at) if r.due_at else '',
|
||||
'due_at': fp_format(request.env, r.due_at),
|
||||
'source_model': r.source_model,
|
||||
'source_id': r.source_id,
|
||||
'wo_state': '',
|
||||
@@ -477,7 +481,7 @@ class FpShopfloorController(http.Controller):
|
||||
'name': b.display_name or b.name,
|
||||
'tank': b.tank_id.name or '',
|
||||
'state': b.state or '',
|
||||
'last_log_date': fields.Datetime.to_string(b.last_log_date) if b.last_log_date else '',
|
||||
'last_log_date': fp_format(request.env, b.last_log_date),
|
||||
'last_log_status': b.last_log_status or '',
|
||||
'mto': round(b.mto_count or 0, 2),
|
||||
}
|
||||
@@ -496,7 +500,7 @@ class FpShopfloorController(http.Controller):
|
||||
'customer': bw.customer_ref or '',
|
||||
'state': bw.state,
|
||||
'remaining': bw.time_remaining_display or '',
|
||||
'required_by': fields.Datetime.to_string(bw.bake_required_by) if bw.bake_required_by else '',
|
||||
'required_by': fp_format(request.env, bw.bake_required_by),
|
||||
'quantity': bw.quantity or 0,
|
||||
}
|
||||
for bw in bws
|
||||
@@ -513,7 +517,7 @@ class FpShopfloorController(http.Controller):
|
||||
'customer': g.customer_ref or '',
|
||||
'bath': g.bath_id.name or '',
|
||||
'result': g.result,
|
||||
'first_piece': fields.Datetime.to_string(g.first_piece_produced) if g.first_piece_produced else '',
|
||||
'first_piece': fp_format(request.env, g.first_piece_produced),
|
||||
'inspector': g.inspector_id.name or '',
|
||||
}
|
||||
for g in gates
|
||||
@@ -571,7 +575,7 @@ class FpShopfloorController(http.Controller):
|
||||
'gates': gates_data,
|
||||
'holds': holds_data,
|
||||
'stations': stations,
|
||||
'server_time': fields.Datetime.to_string(fields.Datetime.now()),
|
||||
'server_time': fp_format(request.env, fields.Datetime.now(), fmt='%Y-%m-%d %H:%M:%S'),
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -623,7 +627,7 @@ class FpShopfloorController(http.Controller):
|
||||
'label': r.label,
|
||||
'description': r.description,
|
||||
'priority': r.priority,
|
||||
'due_at': fields.Datetime.to_string(r.due_at) if r.due_at else '',
|
||||
'due_at': fp_format(request.env, r.due_at),
|
||||
'source_model': r.source_model,
|
||||
'source_id': r.source_id,
|
||||
}
|
||||
@@ -788,7 +792,7 @@ class FpShopfloorController(http.Controller):
|
||||
last_log = wo.time_ids.sorted('date_start', reverse=True)[:1]
|
||||
if last_log and last_log.user_id:
|
||||
last_operator = last_log.user_id.name or ''
|
||||
last_activity = self._time_ago(wo.write_date or wo.date_start)
|
||||
last_activity = fp_time_ago(request.env, wo.write_date or wo.date_start)
|
||||
|
||||
# Tags from priority field
|
||||
tags = []
|
||||
@@ -798,12 +802,16 @@ class FpShopfloorController(http.Controller):
|
||||
elif prio == '1':
|
||||
tags.append('Priority')
|
||||
|
||||
# Date display
|
||||
date_display = ''
|
||||
if wo.date_start:
|
||||
date_display = wo.date_start.strftime('%-m/%-d')
|
||||
elif production and production.date_start:
|
||||
date_display = production.date_start.strftime('%-m/%-d')
|
||||
# Date display — formatted in the user's timezone (DB is UTC, but
|
||||
# the operator wants to see "today" in their local time).
|
||||
date_display = (
|
||||
fp_format(request.env, wo.date_start, fmt='%-m/%-d')
|
||||
or fp_format(
|
||||
request.env,
|
||||
production.date_start if production else False,
|
||||
fmt='%-m/%-d',
|
||||
)
|
||||
)
|
||||
|
||||
# Step info
|
||||
step_display = getattr(wo, 'x_fc_step_display', '') or ''
|
||||
@@ -860,9 +868,9 @@ class FpShopfloorController(http.Controller):
|
||||
'parts_done': 0,
|
||||
'parts_total': 0,
|
||||
'last_operator': '',
|
||||
'last_activity': self._time_ago(bw.write_date) if bw.write_date else '',
|
||||
'last_activity': fp_time_ago(request.env, bw.write_date),
|
||||
'tags': ['HOT'] if bw.state == 'missed_window' else [],
|
||||
'date_display': bw.bake_required_by.strftime('%-m/%-d') if bw.bake_required_by else '',
|
||||
'date_display': fp_format(request.env, bw.bake_required_by, fmt='%-m/%-d'),
|
||||
'state': bw.state or '',
|
||||
}
|
||||
if not search or self._card_matches_search(card, search):
|
||||
@@ -892,9 +900,9 @@ class FpShopfloorController(http.Controller):
|
||||
'parts_done': 0,
|
||||
'parts_total': 0,
|
||||
'last_operator': gate.inspector_id.name if gate.inspector_id else '',
|
||||
'last_activity': self._time_ago(gate.write_date) if gate.write_date else '',
|
||||
'last_activity': fp_time_ago(request.env, gate.write_date),
|
||||
'tags': [],
|
||||
'date_display': gate.create_date.strftime('%-m/%-d') if gate.create_date else '',
|
||||
'date_display': fp_format(request.env, gate.create_date, fmt='%-m/%-d'),
|
||||
'state': gate.result or '',
|
||||
}
|
||||
if not search or self._card_matches_search(card, search):
|
||||
@@ -918,34 +926,6 @@ class FpShopfloorController(http.Controller):
|
||||
]).lower()
|
||||
return search in searchable
|
||||
|
||||
def _time_ago(self, dt):
|
||||
"""Return a human-readable 'time ago' string from a datetime."""
|
||||
if not dt:
|
||||
return ''
|
||||
now = datetime.now()
|
||||
if hasattr(dt, 'replace'):
|
||||
# Make both naive for comparison
|
||||
dt = dt.replace(tzinfo=None)
|
||||
delta = now - dt
|
||||
total_seconds = int(delta.total_seconds())
|
||||
|
||||
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'
|
||||
|
||||
# ==================================================================
|
||||
# Work Order — Process Flow + Cost Summary
|
||||
# ==================================================================
|
||||
|
||||
Reference in New Issue
Block a user