feat(fusion_clock): kiosk app + Kiosk Operator role, full-screen PWA, app-integrated permissions
- PWA manifest on the NFC kiosk page so it installs as a full-screen home-screen app (Chrome "Install" / Safari "Add to Home Screen"). - Dedicated "Kiosk Operator" permission + gated "Fusion Clock Kiosk" top-level app (act_url -> /fusion_clock/kiosk/nfc). Kiosk controllers accept Manager OR Kiosk Operator; all kiosk data ops already run sudo. - Fix 403: read the company kiosk location via sudo on page-load and tap (Kiosk Operator has no fusion.clock.location ACL). - Odoo 19 permissions UX: ir.module.category + res.groups.privilege so User/Team Lead/Manager and Kiosk Operator appear as application-access dropdowns on the user form (no developer mode). Short group display names. - Docs: note res.groups.privilege as the Odoo 19 category_id replacement. Deployed live to entech (odoo-entech / LXC 111 on pve-worker5). v19.0.3.6.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
||||||
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||||
**`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`.
|
**`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`.
|
||||||
6. **res.groups**: NO `users` field, NO `category_id` field.
|
6. **res.groups**: NO `users` field, NO `category_id` field. **The Odoo 19 replacement for `category_id` is `res.groups.privilege`.** To make a module's groups appear as application-access dropdowns on the user form (Settings → Users → *Application Accesses*) instead of only in developer mode: define an `ir.module.category`, a `res.groups.privilege` (with `category_id` → that category), and set each group's `privilege_id` → that privilege. Groups under one privilege that form an `implied_ids` chain render as a single role dropdown; a standalone group in its own privilege renders as a separate row under the same category header. Verified in `fusion_clock/security/security.xml`; mirrors `fusion_plating`/`fusion_tasks`.
|
||||||
**res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`.
|
**res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`.
|
||||||
**`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like `<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like `<page>`, `<button>`, `<field>` is unrelated and still works.)
|
**`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like `<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like `<page>`, `<button>`, `<field>` is unrelated and still works.)
|
||||||
**`ir.rule` `groups` field is additive, not restrictive.** A rule with `groups=[some_group]` applies ONLY to users in that group — it does NOT restrict non-members. So `domain_force=[(1,'=',1)]` + `groups=[base.group_system]` does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (`ir.model.access.csv`), not the rule. To truly restrict by group at the rule layer, pair a global rule (`groups=[]`, `domain_force=[(0,'=',1)]` = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.
|
**`ir.rule` `groups` field is additive, not restrictive.** A rule with `groups=[some_group]` applies ONLY to users in that group — it does NOT restrict non-members. So `domain_force=[(1,'=',1)]` + `groups=[base.group_system]` does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (`ir.model.access.csv`), not the rule. To truly restrict by group at the rule layer, pair a global rule (`groups=[]`, `domain_force=[(0,'=',1)]` = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.3.5.6',
|
'version': '19.0.3.6.0',
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
|||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_kiosk_operator(user):
|
||||||
|
"""Kiosk surfaces accept a full Clock Manager OR a dedicated Kiosk Operator."""
|
||||||
|
return (user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||||
|
or user.has_group('fusion_clock.group_fusion_clock_kiosk_app'))
|
||||||
|
|
||||||
|
|
||||||
class FusionClockKiosk(http.Controller):
|
class FusionClockKiosk(http.Controller):
|
||||||
"""Kiosk mode controller for shared-device clock-in/out."""
|
"""Kiosk mode controller for shared-device clock-in/out."""
|
||||||
|
|
||||||
@@ -17,7 +23,7 @@ class FusionClockKiosk(http.Controller):
|
|||||||
def kiosk_page(self, **kw):
|
def kiosk_page(self, **kw):
|
||||||
"""Kiosk clock-in/out page for shared tablets."""
|
"""Kiosk clock-in/out page for shared tablets."""
|
||||||
user = request.env.user
|
user = request.env.user
|
||||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
if not _is_kiosk_operator(user):
|
||||||
return request.redirect('/my')
|
return request.redirect('/my')
|
||||||
|
|
||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
@@ -34,7 +40,7 @@ class FusionClockKiosk(http.Controller):
|
|||||||
def kiosk_search(self, query='', **kw):
|
def kiosk_search(self, query='', **kw):
|
||||||
"""Search employees for kiosk identification."""
|
"""Search employees for kiosk identification."""
|
||||||
user = request.env.user
|
user = request.env.user
|
||||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
if not _is_kiosk_operator(user):
|
||||||
return {'error': 'Access denied.'}
|
return {'error': 'Access denied.'}
|
||||||
|
|
||||||
employees = request.env['hr.employee'].sudo().search([
|
employees = request.env['hr.employee'].sudo().search([
|
||||||
@@ -55,7 +61,7 @@ class FusionClockKiosk(http.Controller):
|
|||||||
def kiosk_verify_pin(self, employee_id=0, pin='', **kw):
|
def kiosk_verify_pin(self, employee_id=0, pin='', **kw):
|
||||||
"""Verify employee PIN for kiosk mode."""
|
"""Verify employee PIN for kiosk mode."""
|
||||||
user = request.env.user
|
user = request.env.user
|
||||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
if not _is_kiosk_operator(user):
|
||||||
return {'error': 'Access denied.'}
|
return {'error': 'Access denied.'}
|
||||||
|
|
||||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||||
@@ -75,7 +81,7 @@ class FusionClockKiosk(http.Controller):
|
|||||||
def kiosk_clock(self, employee_id=0, latitude=0, longitude=0, **kw):
|
def kiosk_clock(self, employee_id=0, latitude=0, longitude=0, **kw):
|
||||||
"""Perform clock action from kiosk on behalf of an employee."""
|
"""Perform clock action from kiosk on behalf of an employee."""
|
||||||
user = request.env.user
|
user = request.env.user
|
||||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
if not _is_kiosk_operator(user):
|
||||||
return {'error': 'Access denied.'}
|
return {'error': 'Access denied.'}
|
||||||
|
|
||||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@@ -44,6 +45,12 @@ def _strip_data_url_prefix(b64):
|
|||||||
return b64.encode('ascii', errors='ignore') if isinstance(b64, str) else b64
|
return b64.encode('ascii', errors='ignore') if isinstance(b64, str) else b64
|
||||||
|
|
||||||
|
|
||||||
|
def _is_kiosk_operator(user):
|
||||||
|
"""Kiosk surfaces accept a full Clock Manager OR a dedicated Kiosk Operator."""
|
||||||
|
return (user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||||
|
or user.has_group('fusion_clock.group_fusion_clock_kiosk_app'))
|
||||||
|
|
||||||
|
|
||||||
class FusionClockNfcKiosk(http.Controller):
|
class FusionClockNfcKiosk(http.Controller):
|
||||||
"""NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""
|
"""NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""
|
||||||
|
|
||||||
@@ -66,14 +73,14 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
def nfc_kiosk_page(self, **kw):
|
def nfc_kiosk_page(self, **kw):
|
||||||
"""Render the NFC kiosk page for a wall-mounted tablet."""
|
"""Render the NFC kiosk page for a wall-mounted tablet."""
|
||||||
user = request.env.user
|
user = request.env.user
|
||||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
if not _is_kiosk_operator(user):
|
||||||
return request.redirect('/my')
|
return request.redirect('/my')
|
||||||
|
|
||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
|
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
|
||||||
return request.redirect('/my')
|
return request.redirect('/my')
|
||||||
|
|
||||||
company = request.env.company
|
company = request.env.company.sudo()
|
||||||
location = company.x_fclk_nfc_kiosk_location_id
|
location = company.x_fclk_nfc_kiosk_location_id
|
||||||
company_logo_url = (
|
company_logo_url = (
|
||||||
'/web/image/res.company/%s/logo' % company.id if company.logo else ''
|
'/web/image/res.company/%s/logo' % company.id if company.logo else ''
|
||||||
@@ -89,6 +96,42 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
}
|
}
|
||||||
return request.render('fusion_clock.nfc_kiosk_page', values)
|
return request.render('fusion_clock.nfc_kiosk_page', values)
|
||||||
|
|
||||||
|
@http.route('/fusion_clock/kiosk/nfc/manifest.webmanifest', type='http', auth='public')
|
||||||
|
def nfc_kiosk_manifest(self, **kw):
|
||||||
|
"""Web App Manifest so the NFC kiosk installs as a full-screen home-screen app.
|
||||||
|
|
||||||
|
On a wall tablet, 'Install' (Chrome) / 'Add to Home Screen' (Safari) then
|
||||||
|
launches the kiosk standalone -- no address bar or browser tabs, like Odoo's
|
||||||
|
own PWA. Public so the icon/splash can load without a session.
|
||||||
|
"""
|
||||||
|
company = request.env.company.sudo()
|
||||||
|
# Square icons via Odoo's on-the-fly resizer (placeholder if the company has no logo).
|
||||||
|
icon_192 = '/web/image/res.company/%s/logo/192x192' % company.id
|
||||||
|
icon_512 = '/web/image/res.company/%s/logo/512x512' % company.id
|
||||||
|
manifest = {
|
||||||
|
'name': 'Fusion Clock Kiosk',
|
||||||
|
'short_name': 'Clock Kiosk',
|
||||||
|
'description': 'Tap-to-clock NFC kiosk',
|
||||||
|
'start_url': '/fusion_clock/kiosk/nfc',
|
||||||
|
'scope': '/',
|
||||||
|
'display': 'fullscreen',
|
||||||
|
'display_override': ['fullscreen', 'standalone'],
|
||||||
|
'background_color': '#0e1116',
|
||||||
|
'theme_color': '#0e1116',
|
||||||
|
'orientation': 'any',
|
||||||
|
'icons': [
|
||||||
|
{'src': icon_192, 'sizes': '192x192', 'type': 'image/png'},
|
||||||
|
{'src': icon_512, 'sizes': '512x512', 'type': 'image/png'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return request.make_response(
|
||||||
|
json.dumps(manifest),
|
||||||
|
headers=[
|
||||||
|
('Content-Type', 'application/manifest+json; charset=utf-8'),
|
||||||
|
('Cache-Control', 'public, max-age=3600'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _check_enroll_password(env, supplied):
|
def _check_enroll_password(env, supplied):
|
||||||
"""Verify the enroll-mode password. Empty config = always-allow for managers."""
|
"""Verify the enroll-mode password. Empty config = always-allow for managers."""
|
||||||
@@ -101,7 +144,7 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', **kw):
|
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', **kw):
|
||||||
"""Bind an NFC card UID to an employee. Manager-gated, password-gated."""
|
"""Bind an NFC card UID to an employee. Manager-gated, password-gated."""
|
||||||
user = request.env.user
|
user = request.env.user
|
||||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
if not _is_kiosk_operator(user):
|
||||||
return {'error': 'access_denied'}
|
return {'error': 'access_denied'}
|
||||||
|
|
||||||
if not self._check_enroll_password(request.env, enroll_password):
|
if not self._check_enroll_password(request.env, enroll_password):
|
||||||
@@ -146,7 +189,7 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
def nfc_tap(self, card_uid='', photo_b64='', **kw):
|
def nfc_tap(self, card_uid='', photo_b64='', **kw):
|
||||||
"""Toggle attendance state for the employee owning this card UID."""
|
"""Toggle attendance state for the employee owning this card UID."""
|
||||||
user = request.env.user
|
user = request.env.user
|
||||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
if not _is_kiosk_operator(user):
|
||||||
return {'error': 'access_denied'}
|
return {'error': 'access_denied'}
|
||||||
|
|
||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
@@ -165,7 +208,7 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
return {'error': 'photo_required', 'message': 'Camera unavailable. Ask IT to check the kiosk.'}
|
return {'error': 'photo_required', 'message': 'Camera unavailable. Ask IT to check the kiosk.'}
|
||||||
photo_bytes = _strip_data_url_prefix(photo_b64) if photo_b64 else b''
|
photo_bytes = _strip_data_url_prefix(photo_b64) if photo_b64 else b''
|
||||||
|
|
||||||
company = request.env.company
|
company = request.env.company.sudo()
|
||||||
location = company.x_fclk_nfc_kiosk_location_id
|
location = company.x_fclk_nfc_kiosk_location_id
|
||||||
if not location:
|
if not location:
|
||||||
return {'error': 'no_location_configured'}
|
return {'error': 'no_location_configured'}
|
||||||
|
|||||||
@@ -1,25 +1,66 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
App category + privileges (Odoo 19) so Fusion Clock roles appear
|
||||||
|
as selectable application-access dropdowns on the user form,
|
||||||
|
exactly like the other Fusion apps (no developer mode needed).
|
||||||
|
Odoo 19 dropped res.groups.category_id; groups link to a
|
||||||
|
res.groups.privilege, which carries the category_id.
|
||||||
|
================================================================ -->
|
||||||
|
<record id="module_category_fusion_clock" model="ir.module.category">
|
||||||
|
<field name="name">Fusion Clock</field>
|
||||||
|
<field name="sequence">45</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Main role hierarchy (User < Team Lead < Manager) -> one dropdown -->
|
||||||
|
<record id="res_groups_privilege_fusion_clock" model="res.groups.privilege">
|
||||||
|
<field name="name">Fusion Clock</field>
|
||||||
|
<field name="sequence">45</field>
|
||||||
|
<field name="category_id" ref="module_category_fusion_clock"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Standalone kiosk-operator role -> its own row under the same header -->
|
||||||
|
<record id="res_groups_privilege_fusion_clock_kiosk" model="res.groups.privilege">
|
||||||
|
<field name="name">Fusion Clock Kiosk</field>
|
||||||
|
<field name="sequence">46</field>
|
||||||
|
<field name="category_id" ref="module_category_fusion_clock"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
<!-- Groups -->
|
<!-- Groups -->
|
||||||
<record id="group_fusion_clock_user" model="res.groups">
|
<record id="group_fusion_clock_user" model="res.groups">
|
||||||
<field name="name">Fusion Clock / User</field>
|
<field name="name">User</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
|
||||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||||
<field name="comment">Can clock in/out and view own attendance</field>
|
<field name="comment">Can clock in/out and view own attendance</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="group_fusion_clock_team_lead" model="res.groups">
|
<record id="group_fusion_clock_team_lead" model="res.groups">
|
||||||
<field name="name">Fusion Clock / Team Lead</field>
|
<field name="name">Team Lead</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
|
||||||
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
||||||
<field name="comment">Can view direct reports attendance (read-only)</field>
|
<field name="comment">Can view direct reports attendance (read-only)</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="group_fusion_clock_manager" model="res.groups">
|
<record id="group_fusion_clock_manager" model="res.groups">
|
||||||
<field name="name">Fusion Clock / Manager</field>
|
<field name="name">Manager</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
|
||||||
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
|
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
|
||||||
<field name="comment">Can manage locations, view all attendance, generate reports</field>
|
<field name="comment">Can manage locations, view all attendance, generate reports</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- Dedicated kiosk-operator permission: can run the shared clock kiosk
|
||||||
|
(NFC tap / PIN) WITHOUT full Clock Manager access. Gates the
|
||||||
|
"Fusion Clock Kiosk" app menu and is accepted by the kiosk controllers.
|
||||||
|
Implies only base.group_user, so it does NOT reveal the full Fusion
|
||||||
|
Clock app (which is gated to group_fusion_clock_user). -->
|
||||||
|
<record id="group_fusion_clock_kiosk_app" model="res.groups">
|
||||||
|
<field name="name">Kiosk Operator</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_fusion_clock_kiosk"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
<field name="comment">Can open and operate the shared clock kiosk (NFC tap / PIN) without full Clock Manager access. Intended for shared wall-tablet accounts.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
<!-- Auto-assign admin to Manager group -->
|
<!-- Auto-assign admin to Manager group -->
|
||||||
<function model="res.users" name="write">
|
<function model="res.users" name="write">
|
||||||
<value eval="[ref('base.user_admin')]"/>
|
<value eval="[ref('base.user_admin')]"/>
|
||||||
|
|||||||
@@ -8,6 +8,22 @@
|
|||||||
sequence="45"
|
sequence="45"
|
||||||
groups="group_fusion_clock_user"/>
|
groups="group_fusion_clock_user"/>
|
||||||
|
|
||||||
|
<!-- Dedicated kiosk app: a separate top-level icon (same artwork) that opens
|
||||||
|
the NFC kiosk. Gated to Kiosk Operator so shared tablet accounts see ONLY
|
||||||
|
this app, not the full Fusion Clock backend. -->
|
||||||
|
<record id="action_fusion_clock_kiosk_nfc" model="ir.actions.act_url">
|
||||||
|
<field name="name">Fusion Clock Kiosk</field>
|
||||||
|
<field name="url">/fusion_clock/kiosk/nfc</field>
|
||||||
|
<field name="target">self</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_fusion_clock_kiosk_app_root"
|
||||||
|
name="Fusion Clock Kiosk"
|
||||||
|
web_icon="fusion_clock,static/description/icon.png"
|
||||||
|
action="action_fusion_clock_kiosk_nfc"
|
||||||
|
sequence="46"
|
||||||
|
groups="group_fusion_clock_kiosk_app"/>
|
||||||
|
|
||||||
<!-- Dashboard -->
|
<!-- Dashboard -->
|
||||||
<menuitem id="menu_fusion_clock_dashboard"
|
<menuitem id="menu_fusion_clock_dashboard"
|
||||||
name="Dashboard"
|
name="Dashboard"
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
<t t-set="no_footer" t-value="True"/>
|
<t t-set="no_footer" t-value="True"/>
|
||||||
<t t-set="head">
|
<t t-set="head">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
|
||||||
|
<!-- Installable full-screen kiosk app (Chrome "Install" / Safari "Add to Home Screen") -->
|
||||||
|
<link rel="manifest" href="/fusion_clock/kiosk/nfc/manifest.webmanifest"/>
|
||||||
|
<meta name="mobile-web-app-capable" content="yes"/>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Fusion Clock Kiosk"/>
|
||||||
|
<link rel="apple-touch-icon" href="/fusion_clock/static/description/icon.png"/>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<div id="nfc_kiosk_root" class="nfc-kiosk"
|
<div id="nfc_kiosk_root" class="nfc-kiosk"
|
||||||
|
|||||||
Reference in New Issue
Block a user