feat(shopfloor): extend /fp/tablet/tiles payload with company block
LS-T1 of the tablet lock-screen redesign.
Adds 3 module-level helpers in tablet_controller.py:
_initials_from(name) — first/last initials for letter-mark fallback
_avatar_gradient_for(uid) — deterministic per-user color (8 gradients)
_lock_company_payload(env) — company name + tagline + logo URL block
Endpoint /fp/tablet/tiles now returns:
{ok, company:{id,name,tagline,logo_url,has_logo,initials},
tiles:[{user_id, name, initials, avatar_url, has_photo,
avatar_gradient, is_clocked_in, has_pin}, ...]}
Tagline reuses res.company.report_header (the existing invoice-letterhead
field) — no new model field. Falls back to 'Shop Floor Terminal' when
empty.
10 tests pass (initials edge cases, gradient determinism, payload shape).
The 'tagline matches input string' assertion was intentionally NOT added
— see new CLAUDE.md Critical Rule 22 about Odoo 19 HTML field
auto-wrapping that makes such an equality test brittle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,69 @@ def _is_manager(env):
|
||||
return env.user.has_group('fusion_plating.group_fusion_plating_manager')
|
||||
|
||||
|
||||
# ===== 2026-05-24 lock-screen redesign helpers =========================
|
||||
# Three small module-level helpers powering the new lock-screen visuals.
|
||||
# Imported by the tests in tests/test_tablet_lock_payload.py and consumed
|
||||
# directly by the /fp/tablet/tiles route below.
|
||||
|
||||
_AVATAR_GRADIENTS = [
|
||||
'linear-gradient(135deg, #ef4444, #dc2626)', # red
|
||||
'linear-gradient(135deg, #f59e0b, #d97706)', # amber
|
||||
'linear-gradient(135deg, #10b981, #059669)', # emerald
|
||||
'linear-gradient(135deg, #3b82f6, #2563eb)', # blue
|
||||
'linear-gradient(135deg, #8b5cf6, #7c3aed)', # violet
|
||||
'linear-gradient(135deg, #ec4899, #db2777)', # pink
|
||||
'linear-gradient(135deg, #14b8a6, #0d9488)', # teal
|
||||
'linear-gradient(135deg, #f97316, #ea580c)', # orange
|
||||
]
|
||||
|
||||
|
||||
def _initials_from(name):
|
||||
"""First letter of first + last word, capped at 2 chars uppercase.
|
||||
|
||||
Single-word names return their first two chars. Empty / falsy
|
||||
returns '?' so the letter-mark renders something visible rather
|
||||
than collapsing to a 0-height block.
|
||||
"""
|
||||
if not name:
|
||||
return '?'
|
||||
words = name.strip().split()
|
||||
if not words:
|
||||
return '?'
|
||||
if len(words) == 1:
|
||||
return words[0][:2].upper()
|
||||
return (words[0][0] + words[-1][0]).upper()
|
||||
|
||||
|
||||
def _avatar_gradient_for(user_id):
|
||||
"""Deterministic gradient per user id.
|
||||
|
||||
Modulo the gradient list — same operator gets the same color
|
||||
across sessions so they learn to recognize their own tile. 8
|
||||
colors are enough for a small shop (10-15 ops) with at most 2
|
||||
color collisions on average.
|
||||
"""
|
||||
return _AVATAR_GRADIENTS[user_id % len(_AVATAR_GRADIENTS)]
|
||||
|
||||
|
||||
def _lock_company_payload(env):
|
||||
"""Returns the company info block for the lock screen.
|
||||
|
||||
Reuses res.company.report_header as the tagline (the same field
|
||||
that drives invoice letterhead text) with a sensible fallback
|
||||
when empty. No new model field required.
|
||||
"""
|
||||
co = env.company
|
||||
return {
|
||||
'id': co.id,
|
||||
'name': co.name or '',
|
||||
'tagline': co.report_header or 'Shop Floor Terminal',
|
||||
'logo_url': f'/web/image/res.company/{co.id}/logo',
|
||||
'has_logo': bool(co.logo),
|
||||
'initials': _initials_from(co.name),
|
||||
}
|
||||
|
||||
|
||||
class FpTabletController(http.Controller):
|
||||
"""Tablet PIN gate endpoints. All require an authenticated Odoo
|
||||
session (the tablet logs in once as a 'shopfloor service' user).
|
||||
@@ -185,13 +248,24 @@ class FpTabletController(http.Controller):
|
||||
tiles.append({
|
||||
'user_id': u.id,
|
||||
'name': u.name,
|
||||
'initials': _initials_from(u.name),
|
||||
'avatar_url': f'/web/image/res.users/{u.id}/avatar_128',
|
||||
# has_photo lets the frontend skip the avatar img when
|
||||
# the user has no uploaded photo (avoids the 1×1 default
|
||||
# image flash). sudo-read of image_128 — the field is
|
||||
# restricted to the user themselves otherwise.
|
||||
'has_photo': bool(u_sudo.image_128),
|
||||
'avatar_gradient': _avatar_gradient_for(u.id),
|
||||
'is_clocked_in': u.id in clocked_ids,
|
||||
'has_pin': bool(u_sudo.x_fc_tablet_pin_hash),
|
||||
})
|
||||
# Clocked-in first, then alphabetical within bucket
|
||||
tiles.sort(key=lambda t: (not t['is_clocked_in'], t['name']))
|
||||
return {'ok': True, 'tiles': tiles}
|
||||
return {
|
||||
'ok': True,
|
||||
'company': _lock_company_payload(request.env),
|
||||
'tiles': tiles,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/ping — heartbeat used by the OWL component on every action
|
||||
|
||||
Reference in New Issue
Block a user