chnages
This commit is contained in:
3
fusion_helpdesk/__init__.py
Normal file
3
fusion_helpdesk/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import controllers
|
||||||
|
from . import models
|
||||||
47
fusion_helpdesk/__manifest__.py
Normal file
47
fusion_helpdesk/__manifest__.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
{
|
||||||
|
'name': 'Fusion Helpdesk Reporter',
|
||||||
|
'version': '19.0.1.2.0',
|
||||||
|
'category': 'Productivity',
|
||||||
|
'summary': 'One-click in-app bug reporting & feature requesting — '
|
||||||
|
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
|
||||||
|
'description': """
|
||||||
|
Fusion Helpdesk Reporter
|
||||||
|
========================
|
||||||
|
A standalone module that gives every backend user a quick "Report an
|
||||||
|
Issue / Request a Feature" button in the Odoo top systray.
|
||||||
|
|
||||||
|
Submissions are forwarded over JSON-RPC / XML-RPC to a central Odoo
|
||||||
|
instance (typically the support shop's main Odoo) and become
|
||||||
|
`helpdesk.ticket` records with screenshots, file attachments, and
|
||||||
|
diagnostic context (URL, user agent, current user/company, error
|
||||||
|
code) automatically captured.
|
||||||
|
|
||||||
|
Designed to ship alongside any other Fusion / Nexa Systems client
|
||||||
|
module bundle. No dependencies on the rest of Fusion Plating.
|
||||||
|
""",
|
||||||
|
'author': 'Nexa Systems Inc.',
|
||||||
|
'website': 'https://www.nexasystems.ca',
|
||||||
|
'license': 'OPL-1',
|
||||||
|
'depends': ['base', 'web', 'mail'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/ir_config_parameter_data.xml',
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'fusion_helpdesk/static/src/scss/fusion_helpdesk.scss',
|
||||||
|
'fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml',
|
||||||
|
'fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml',
|
||||||
|
'fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js',
|
||||||
|
'fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'images': ['static/description/icon.png'],
|
||||||
|
'installable': True,
|
||||||
|
'auto_install': False,
|
||||||
|
'application': False,
|
||||||
|
}
|
||||||
2
fusion_helpdesk/controllers/__init__.py
Normal file
2
fusion_helpdesk/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import main
|
||||||
402
fusion_helpdesk/controllers/main.py
Normal file
402
fusion_helpdesk/controllers/main.py
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""HTTP routes for the Fusion Helpdesk Reporter.
|
||||||
|
|
||||||
|
`/fusion_helpdesk/submit` accepts the dialog payload, forwards it to a
|
||||||
|
remote Odoo Helpdesk over XML-RPC, attaches uploaded files +
|
||||||
|
screenshot, and returns the resulting ticket id/url to the OWL dialog.
|
||||||
|
|
||||||
|
Why XML-RPC and not JSON-RPC? Helpdesk's external API surface is
|
||||||
|
exposed via Odoo's standard `/xmlrpc/2/object` endpoint which is
|
||||||
|
the most stable cross-version contract. JSON-RPC `/jsonrpc` works
|
||||||
|
too but historically had quirkier error handling on file payloads.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import xmlrpc.client
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
from odoo import _, http
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionHelpdeskController(http.Controller):
|
||||||
|
|
||||||
|
@http.route(
|
||||||
|
'/fusion_helpdesk/submit',
|
||||||
|
type='jsonrpc', auth='user', methods=['POST'],
|
||||||
|
)
|
||||||
|
def submit(self, kind, subject, description,
|
||||||
|
error_code=None, attachments=None,
|
||||||
|
page_url=None, user_agent=None):
|
||||||
|
"""Forward a bug report or feature request to the central Odoo
|
||||||
|
Helpdesk and return {ok, ticket_id, ticket_url, error}.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kind: 'bug' or 'feature'.
|
||||||
|
subject: short title.
|
||||||
|
description: long-form. May contain HTML or plain text.
|
||||||
|
error_code: optional traceback / error string.
|
||||||
|
attachments: list of {name, mimetype, data_b64} dicts.
|
||||||
|
page_url: client-side window.location.href at submit time.
|
||||||
|
user_agent: client-side navigator.userAgent.
|
||||||
|
"""
|
||||||
|
cfg = self._read_config()
|
||||||
|
if not all([cfg['url'], cfg['db'], cfg['login'], cfg['password']]):
|
||||||
|
return {
|
||||||
|
'ok': False,
|
||||||
|
'error': 'config_missing',
|
||||||
|
'message': _(
|
||||||
|
'Fusion Helpdesk is not fully configured. Ask an '
|
||||||
|
'administrator to fill in the remote URL, database, '
|
||||||
|
'login and password under Settings → Fusion Helpdesk.'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Build the ticket payload ---------------------------------
|
||||||
|
prefix = ('[%s] ' % cfg['client_label']) if cfg['client_label'] else ''
|
||||||
|
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
|
||||||
|
full_subject = '%s%s: %s' % (prefix, kind_label, subject or '(untitled)')
|
||||||
|
|
||||||
|
body_parts = []
|
||||||
|
if description:
|
||||||
|
body_parts.append(
|
||||||
|
'<h4>Description</h4><div>%s</div>' % _html_escape(description)
|
||||||
|
)
|
||||||
|
if error_code:
|
||||||
|
body_parts.append(
|
||||||
|
'<h4>Error Code / Traceback</h4>'
|
||||||
|
'<pre style="background:#f5f5f5;padding:8px;border-radius:4px;'
|
||||||
|
'white-space:pre-wrap;">%s</pre>' % _html_escape(error_code)
|
||||||
|
)
|
||||||
|
body_parts.append(self._build_diag_block(page_url, user_agent))
|
||||||
|
|
||||||
|
ticket_vals = {
|
||||||
|
'name': full_subject,
|
||||||
|
'description': '\n'.join(body_parts),
|
||||||
|
}
|
||||||
|
if cfg['team_id']:
|
||||||
|
ticket_vals['team_id'] = cfg['team_id']
|
||||||
|
|
||||||
|
# ---- Talk to remote Odoo --------------------------------------
|
||||||
|
try:
|
||||||
|
uid, models_proxy = self._authenticate(cfg)
|
||||||
|
except _RemoteError as e:
|
||||||
|
return e.to_response()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ticket_id = models_proxy.execute_kw(
|
||||||
|
cfg['db'], uid, cfg['password'],
|
||||||
|
'helpdesk.ticket', 'create', [ticket_vals],
|
||||||
|
)
|
||||||
|
except xmlrpc.client.Fault as e:
|
||||||
|
fault = (e.faultString or '').strip()
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk: helpdesk.ticket.create failed: %s',
|
||||||
|
fault,
|
||||||
|
)
|
||||||
|
# The Helpdesk app might not be installed on the remote.
|
||||||
|
if 'helpdesk.ticket' in fault and 'does not exist' in fault.lower():
|
||||||
|
return {
|
||||||
|
'ok': False, 'error': 'helpdesk_not_installed',
|
||||||
|
'message': _(
|
||||||
|
'The Helpdesk app is not installed on the central '
|
||||||
|
'Odoo at %s. Install the Helpdesk app there before '
|
||||||
|
'submitting tickets.'
|
||||||
|
) % cfg['url'],
|
||||||
|
}
|
||||||
|
if 'access' in fault.lower() or 'rights' in fault.lower():
|
||||||
|
return {
|
||||||
|
'ok': False, 'error': 'permission_denied',
|
||||||
|
'message': _(
|
||||||
|
'The remote service account "%s" does not have '
|
||||||
|
'permission to create helpdesk tickets. Ask a '
|
||||||
|
'central Odoo admin to grant Helpdesk Officer '
|
||||||
|
'rights to that user.'
|
||||||
|
) % cfg['login'],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'ok': False, 'error': 'create_failed',
|
||||||
|
'message': _('The remote Helpdesk rejected the ticket: %s'
|
||||||
|
) % fault,
|
||||||
|
}
|
||||||
|
except (socket.timeout, OSError, ssl.SSLError) as e:
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk: ticket create network error: %s', e,
|
||||||
|
)
|
||||||
|
return _network_error_response(cfg['url'], e)
|
||||||
|
|
||||||
|
# ---- Push attachments -----------------------------------------
|
||||||
|
attached = 0
|
||||||
|
for att in attachments or []:
|
||||||
|
data_b64 = (att or {}).get('data_b64')
|
||||||
|
name = (att or {}).get('name') or 'attachment.bin'
|
||||||
|
mimetype = (att or {}).get('mimetype') or 'application/octet-stream'
|
||||||
|
if not data_b64:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
models_proxy.execute_kw(
|
||||||
|
cfg['db'], uid, cfg['password'],
|
||||||
|
'ir.attachment', 'create', [{
|
||||||
|
'name': name,
|
||||||
|
'datas': data_b64,
|
||||||
|
'res_model': 'helpdesk.ticket',
|
||||||
|
'res_id': ticket_id,
|
||||||
|
'mimetype': mimetype,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
attached += 1
|
||||||
|
except xmlrpc.client.Fault as e:
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk: attachment "%s" upload failed: %s',
|
||||||
|
name, e.faultString,
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket_url = urljoin(
|
||||||
|
cfg['url'].rstrip('/') + '/',
|
||||||
|
'odoo/helpdesk/%s' % ticket_id,
|
||||||
|
)
|
||||||
|
_logger.info(
|
||||||
|
'fusion_helpdesk: created remote ticket #%s (%s attachments) '
|
||||||
|
'on %s for user %s',
|
||||||
|
ticket_id, attached, cfg['url'],
|
||||||
|
request.env.user.login,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'ticket_id': ticket_id,
|
||||||
|
'ticket_url': ticket_url,
|
||||||
|
'attached': attached,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _read_config(self):
|
||||||
|
"""Return the active config as a plain dict. Run as sudo so
|
||||||
|
regular users can submit even without read-access on
|
||||||
|
ir.config_parameter (the password row is system-write but
|
||||||
|
readable by anyone with backend access)."""
|
||||||
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
return {
|
||||||
|
'url': (ICP.get_param('fusion_helpdesk.remote_url') or '').strip(),
|
||||||
|
'db': (ICP.get_param('fusion_helpdesk.remote_db') or '').strip(),
|
||||||
|
'login': (ICP.get_param('fusion_helpdesk.remote_login') or '').strip(),
|
||||||
|
'password': ICP.get_param('fusion_helpdesk.remote_password') or '',
|
||||||
|
'team_id': int(
|
||||||
|
ICP.get_param('fusion_helpdesk.remote_team_id') or 0
|
||||||
|
) or False,
|
||||||
|
'client_label': (
|
||||||
|
ICP.get_param('fusion_helpdesk.client_label') or ''
|
||||||
|
).strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _authenticate(self, cfg):
|
||||||
|
"""Authenticate against the remote and return (uid, models_proxy).
|
||||||
|
|
||||||
|
Raises _RemoteError with a granular `error` code and a friendly
|
||||||
|
end-user message so the dialog can show what actually broke
|
||||||
|
(network vs. credentials vs. server problem).
|
||||||
|
"""
|
||||||
|
url = cfg['url'].rstrip('/')
|
||||||
|
try:
|
||||||
|
common = xmlrpc.client.ServerProxy(
|
||||||
|
'%s/xmlrpc/2/common' % url, allow_none=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
uid = common.authenticate(
|
||||||
|
cfg['db'], cfg['login'], cfg['password'], {},
|
||||||
|
)
|
||||||
|
except xmlrpc.client.ProtocolError as e:
|
||||||
|
# Server returned an HTTP error status.
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk: HTTP %s from %s during authenticate: %s',
|
||||||
|
e.errcode, url, e.errmsg,
|
||||||
|
)
|
||||||
|
if e.errcode in (401, 403):
|
||||||
|
raise _RemoteError(
|
||||||
|
'auth_failed',
|
||||||
|
_('The central Odoo at %s rejected the login. '
|
||||||
|
'Check the Service Login and API Key in '
|
||||||
|
'Settings → Fusion Helpdesk.') % url,
|
||||||
|
)
|
||||||
|
if e.errcode == 404:
|
||||||
|
raise _RemoteError(
|
||||||
|
'endpoint_not_found',
|
||||||
|
_('The XML-RPC endpoint at %s/xmlrpc/2/common '
|
||||||
|
'returned 404. Verify the Remote URL points '
|
||||||
|
'at an Odoo server (no trailing path).') % url,
|
||||||
|
)
|
||||||
|
raise _RemoteError(
|
||||||
|
'remote_http_error',
|
||||||
|
_('The central Odoo returned HTTP %(code)s while '
|
||||||
|
'authenticating. Try again, or check that '
|
||||||
|
'%(url)s is reachable from a browser.'
|
||||||
|
) % {'code': e.errcode, 'url': url},
|
||||||
|
)
|
||||||
|
except xmlrpc.client.Fault as e:
|
||||||
|
# Server-side application error — usually wrong DB name.
|
||||||
|
fault = (e.faultString or '').strip()
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk: XML-RPC fault during authenticate: %s',
|
||||||
|
fault,
|
||||||
|
)
|
||||||
|
if 'database' in fault.lower() and (
|
||||||
|
'not exist' in fault.lower() or 'unknown' in fault.lower()
|
||||||
|
):
|
||||||
|
raise _RemoteError(
|
||||||
|
'wrong_database',
|
||||||
|
_('Database "%(db)s" does not exist on %(url)s. '
|
||||||
|
'Verify the Remote DB in Settings → Fusion '
|
||||||
|
'Helpdesk.') % {'db': cfg['db'], 'url': url},
|
||||||
|
)
|
||||||
|
raise _RemoteError(
|
||||||
|
'auth_failed',
|
||||||
|
_('The central Odoo rejected authentication: %s'
|
||||||
|
) % fault,
|
||||||
|
)
|
||||||
|
except ssl.SSLError as e:
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk: TLS error against %s: %s', url, e,
|
||||||
|
)
|
||||||
|
raise _RemoteError(
|
||||||
|
'tls_error',
|
||||||
|
_('TLS / SSL handshake with %(url)s failed: '
|
||||||
|
'%(msg)s. Either the server cert is invalid or '
|
||||||
|
'the system cert store on this host is out of '
|
||||||
|
'date.') % {'url': url, 'msg': str(e)},
|
||||||
|
)
|
||||||
|
except socket.gaierror as e:
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk: DNS lookup failed for %s: %s', url, e,
|
||||||
|
)
|
||||||
|
raise _RemoteError(
|
||||||
|
'dns_error',
|
||||||
|
_('Could not resolve "%(host)s". Check Settings → '
|
||||||
|
'Fusion Helpdesk → Remote URL, or your server\'s '
|
||||||
|
'DNS / outbound network.'
|
||||||
|
) % {'host': urlparse(url).hostname or url},
|
||||||
|
)
|
||||||
|
except (ConnectionRefusedError, socket.timeout) as e:
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk: connection problem to %s: %s', url, e,
|
||||||
|
)
|
||||||
|
raise _RemoteError(
|
||||||
|
'unreachable',
|
||||||
|
_('Could not reach %(url)s — connection refused or '
|
||||||
|
'timed out. Check that this server can make '
|
||||||
|
'outbound HTTPS to %(host)s (firewall, proxy).'
|
||||||
|
) % {'url': url, 'host': urlparse(url).hostname or url},
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
# Catch-all for socket / network issues we didn't classify.
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk: network error to %s: %s', url, e,
|
||||||
|
)
|
||||||
|
raise _RemoteError(
|
||||||
|
'unreachable',
|
||||||
|
_('Network error reaching %(url)s: %(msg)s. Verify '
|
||||||
|
'this server has outbound internet access.'
|
||||||
|
) % {'url': url, 'msg': str(e)},
|
||||||
|
)
|
||||||
|
except _RemoteError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception(
|
||||||
|
'fusion_helpdesk: unexpected error during authenticate '
|
||||||
|
'against %s', url,
|
||||||
|
)
|
||||||
|
raise _RemoteError(
|
||||||
|
'unknown_error',
|
||||||
|
_('Unexpected error contacting the central Helpdesk: %s'
|
||||||
|
) % str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not uid:
|
||||||
|
raise _RemoteError(
|
||||||
|
'auth_failed',
|
||||||
|
_('The central Odoo at %(url)s did not accept the '
|
||||||
|
'login "%(login)s". Verify the Service Login and '
|
||||||
|
'API Key in Settings → Fusion Helpdesk.'
|
||||||
|
) % {'url': url, 'login': cfg['login']},
|
||||||
|
)
|
||||||
|
|
||||||
|
models_proxy = xmlrpc.client.ServerProxy(
|
||||||
|
'%s/xmlrpc/2/object' % url, allow_none=True,
|
||||||
|
)
|
||||||
|
return uid, models_proxy
|
||||||
|
|
||||||
|
def _build_diag_block(self, page_url, user_agent):
|
||||||
|
env = request.env
|
||||||
|
company = env.company
|
||||||
|
user = env.user
|
||||||
|
rows = [
|
||||||
|
('User', '%s (#%s, %s)' % (user.name, user.id, user.login)),
|
||||||
|
('Company', '%s (#%s)' % (company.name, company.id)),
|
||||||
|
('Source page', page_url or '—'),
|
||||||
|
('User agent', user_agent or '—'),
|
||||||
|
('Source DB', request.env.cr.dbname),
|
||||||
|
('Source host', request.httprequest.host_url),
|
||||||
|
]
|
||||||
|
body = '<h4>Diagnostic context</h4><table>'
|
||||||
|
for k, v in rows:
|
||||||
|
body += (
|
||||||
|
'<tr><td style="padding:2px 8px;color:#666;">%s</td>'
|
||||||
|
'<td style="padding:2px 8px;"><code>%s</code></td></tr>'
|
||||||
|
) % (_html_escape(k), _html_escape(str(v)))
|
||||||
|
body += '</table>'
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _html_escape(s):
|
||||||
|
return (
|
||||||
|
(s or '')
|
||||||
|
.replace('&', '&')
|
||||||
|
.replace('<', '<')
|
||||||
|
.replace('>', '>')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _RemoteError(Exception):
|
||||||
|
"""Typed wrapper that carries an `error` code + a friendly message
|
||||||
|
so the dialog can show the right thing."""
|
||||||
|
def __init__(self, code, message):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def to_response(self):
|
||||||
|
return {'ok': False, 'error': self.code, 'message': self.message}
|
||||||
|
|
||||||
|
|
||||||
|
def _network_error_response(url, err):
|
||||||
|
"""Map a raw network exception raised mid-RPC into the same
|
||||||
|
response shape the dialog already knows how to render."""
|
||||||
|
host = urlparse(url).hostname or url
|
||||||
|
if isinstance(err, ssl.SSLError):
|
||||||
|
return {
|
||||||
|
'ok': False, 'error': 'tls_error',
|
||||||
|
'message': _('TLS / SSL error talking to %(url)s: %(msg)s'
|
||||||
|
) % {'url': url, 'msg': str(err)},
|
||||||
|
}
|
||||||
|
if isinstance(err, socket.gaierror):
|
||||||
|
return {
|
||||||
|
'ok': False, 'error': 'dns_error',
|
||||||
|
'message': _('Could not resolve "%s" — check DNS / outbound network.'
|
||||||
|
) % host,
|
||||||
|
}
|
||||||
|
if isinstance(err, socket.timeout):
|
||||||
|
return {
|
||||||
|
'ok': False, 'error': 'unreachable',
|
||||||
|
'message': _('Timed out talking to %s — check the firewall '
|
||||||
|
'allows outbound HTTPS.') % url,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'ok': False, 'error': 'unreachable',
|
||||||
|
'message': _('Network error talking to %(url)s: %(msg)s'
|
||||||
|
) % {'url': url, 'msg': str(err)},
|
||||||
|
}
|
||||||
32
fusion_helpdesk/data/ir_config_parameter_data.xml
Normal file
32
fusion_helpdesk/data/ir_config_parameter_data.xml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1
|
||||||
|
|
||||||
|
Defaults for the Fusion Helpdesk Reporter. noupdate=1 so admins
|
||||||
|
can override on the Settings page without losing their values
|
||||||
|
on `-u`.
|
||||||
|
-->
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="fhd_default_remote_url" model="ir.config_parameter">
|
||||||
|
<field name="key">fusion_helpdesk.remote_url</field>
|
||||||
|
<field name="value">https://erp.nexasystems.ca</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fhd_default_remote_db" model="ir.config_parameter">
|
||||||
|
<field name="key">fusion_helpdesk.remote_db</field>
|
||||||
|
<field name="value">nexamain</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fhd_default_remote_login" model="ir.config_parameter">
|
||||||
|
<field name="key">fusion_helpdesk.remote_login</field>
|
||||||
|
<field name="value">helpdesk_bot@nexasystems.ca</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fhd_default_client_label" model="ir.config_parameter">
|
||||||
|
<field name="key">fusion_helpdesk.client_label</field>
|
||||||
|
<field name="value"></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
2
fusion_helpdesk/models/__init__.py
Normal file
2
fusion_helpdesk/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import res_config_settings
|
||||||
52
fusion_helpdesk/models/res_config_settings.py
Normal file
52
fusion_helpdesk/models/res_config_settings.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Configuration for the Fusion Helpdesk Reporter.
|
||||||
|
|
||||||
|
Stores the central Odoo Helpdesk endpoint that submissions are
|
||||||
|
forwarded to. Defaults point at erp.nexasystems.ca / nexamain;
|
||||||
|
each client deployment can override per system parameter.
|
||||||
|
"""
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
|
|
||||||
|
fhd_remote_url = fields.Char(
|
||||||
|
string='Helpdesk Remote URL',
|
||||||
|
config_parameter='fusion_helpdesk.remote_url',
|
||||||
|
help='Base URL of the central Odoo running the Helpdesk app, '
|
||||||
|
'e.g. https://erp.nexasystems.ca',
|
||||||
|
)
|
||||||
|
fhd_remote_db = fields.Char(
|
||||||
|
string='Helpdesk Remote DB',
|
||||||
|
config_parameter='fusion_helpdesk.remote_db',
|
||||||
|
help='Database name on the remote Odoo (e.g. nexamain).',
|
||||||
|
)
|
||||||
|
fhd_remote_login = fields.Char(
|
||||||
|
string='Helpdesk Remote Login',
|
||||||
|
config_parameter='fusion_helpdesk.remote_login',
|
||||||
|
help='Service-account login on the remote Odoo. Needs create '
|
||||||
|
'rights on helpdesk.ticket and ir.attachment.',
|
||||||
|
)
|
||||||
|
fhd_remote_password = fields.Char(
|
||||||
|
string='Helpdesk Remote Password / API Key',
|
||||||
|
config_parameter='fusion_helpdesk.remote_password',
|
||||||
|
help='Service-account password or API key. Stored in '
|
||||||
|
'ir.config_parameter — restrict read access if needed.',
|
||||||
|
)
|
||||||
|
fhd_remote_team_id = fields.Integer(
|
||||||
|
string='Helpdesk Team ID',
|
||||||
|
config_parameter='fusion_helpdesk.remote_team_id',
|
||||||
|
help='Optional. ID of the helpdesk.team on the remote that '
|
||||||
|
'should own all incoming tickets. Leave blank to use '
|
||||||
|
'the remote default routing.',
|
||||||
|
)
|
||||||
|
fhd_client_label = fields.Char(
|
||||||
|
string='Client Label (auto-prepended to subject)',
|
||||||
|
config_parameter='fusion_helpdesk.client_label',
|
||||||
|
help='Short tag prefixed onto the ticket subject so support '
|
||||||
|
'can tell which client deployment a ticket came from. '
|
||||||
|
'e.g. "ENTECH" → "[ENTECH] My subject"',
|
||||||
|
)
|
||||||
1
fusion_helpdesk/security/ir.model.access.csv
Normal file
1
fusion_helpdesk/security/ir.model.access.csv
Normal file
@@ -0,0 +1 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
BIN
fusion_helpdesk/static/description/icon.png
Normal file
BIN
fusion_helpdesk/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
244
fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js
Normal file
244
fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
// Fusion Helpdesk — submission dialog. Lets the user pick Bug or
|
||||||
|
// Feature, fill in subject + description, paste an error code, attach
|
||||||
|
// files, and capture a screenshot via the browser's getDisplayMedia
|
||||||
|
// API. On submit, the payload is POSTed to /fusion_helpdesk/submit
|
||||||
|
// which forwards it (XML-RPC) to a central Odoo Helpdesk.
|
||||||
|
|
||||||
|
import { Component, useState } from "@odoo/owl";
|
||||||
|
import { Dialog } from "@web/core/dialog/dialog";
|
||||||
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
const MAX_BYTES_PER_FILE = 10 * 1024 * 1024; // 10 MB hard cap per file
|
||||||
|
|
||||||
|
export class FusionHelpdeskDialog extends Component {
|
||||||
|
static template = "fusion_helpdesk.Dialog";
|
||||||
|
static components = { Dialog };
|
||||||
|
static props = {
|
||||||
|
close: Function,
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.notification = useService("notification");
|
||||||
|
this.state = useState({
|
||||||
|
kind: "bug", // 'bug' | 'feature'
|
||||||
|
subject: "",
|
||||||
|
description: "",
|
||||||
|
errorCode: "",
|
||||||
|
attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}]
|
||||||
|
capturing: false,
|
||||||
|
submitting: false,
|
||||||
|
error: "",
|
||||||
|
success: false,
|
||||||
|
ticketId: null,
|
||||||
|
ticketUrl: "",
|
||||||
|
attached: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get dialogTitle() {
|
||||||
|
return this.state.kind === "bug"
|
||||||
|
? _t("Report a Bug")
|
||||||
|
: _t("Request a Feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
setKind(kind) {
|
||||||
|
this.state.kind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// File input → b64
|
||||||
|
async onFilesPicked(ev) {
|
||||||
|
const files = Array.from(ev.target.files || []);
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.size > MAX_BYTES_PER_FILE) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("File '%s' is over 10 MB and was skipped.").replace("%s", f.name),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const b64 = await this._fileToB64(f);
|
||||||
|
this._addAttachment({
|
||||||
|
name: f.name,
|
||||||
|
mimetype: f.type || "application/octet-stream",
|
||||||
|
data_b64: b64,
|
||||||
|
rawSize: f.size,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Could not read '%s'.").replace("%s", f.name),
|
||||||
|
{ type: "danger" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset the input so picking the same file again re-fires onchange.
|
||||||
|
ev.target.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
_fileToB64(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result || "";
|
||||||
|
const idx = result.indexOf(",");
|
||||||
|
resolve(idx >= 0 ? result.slice(idx + 1) : result);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Screenshot capture via getDisplayMedia
|
||||||
|
async onTakeScreenshot() {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Your browser doesn't support screen capture. Use Attach files instead."),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.capturing = true;
|
||||||
|
let stream;
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: { displaySurface: "browser" },
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
const blob = await this._streamToBlob(stream);
|
||||||
|
const b64 = await this._blobToB64(blob);
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
this._addAttachment({
|
||||||
|
name: `screenshot-${ts}.png`,
|
||||||
|
mimetype: "image/png",
|
||||||
|
data_b64: b64,
|
||||||
|
rawSize: blob.size,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// User cancelled the picker — silently swallow. Other errors → notify.
|
||||||
|
if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Screenshot failed: %s").replace("%s", err.message || err),
|
||||||
|
{ type: "danger" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
}
|
||||||
|
this.state.capturing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _streamToBlob(stream) {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.srcObject = stream;
|
||||||
|
await video.play();
|
||||||
|
// Give the browser one frame to settle the picker chrome.
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
return await new Promise((resolve) =>
|
||||||
|
canvas.toBlob((b) => resolve(b), "image/png", 0.92)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_blobToB64(blob) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result || "";
|
||||||
|
const idx = result.indexOf(",");
|
||||||
|
resolve(idx >= 0 ? result.slice(idx + 1) : result);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_addAttachment({ name, mimetype, data_b64, rawSize }) {
|
||||||
|
this.state.attachments.push({
|
||||||
|
name,
|
||||||
|
mimetype,
|
||||||
|
data_b64,
|
||||||
|
sizeLabel: this._formatBytes(rawSize),
|
||||||
|
iconClass: this._iconForMime(mimetype),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAttachment(idx) {
|
||||||
|
this.state.attachments.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatBytes(n) {
|
||||||
|
if (!n) return "";
|
||||||
|
if (n < 1024) return n + " B";
|
||||||
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||||
|
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||||
|
}
|
||||||
|
|
||||||
|
_iconForMime(mt) {
|
||||||
|
mt = (mt || "").toLowerCase();
|
||||||
|
if (mt.startsWith("image/")) return "fa fa-file-image-o";
|
||||||
|
if (mt.startsWith("video/")) return "fa fa-file-video-o";
|
||||||
|
if (mt.startsWith("audio/")) return "fa fa-file-audio-o";
|
||||||
|
if (mt.includes("pdf")) return "fa fa-file-pdf-o";
|
||||||
|
if (mt.includes("zip") || mt.includes("tar") || mt.includes("rar")) return "fa fa-file-archive-o";
|
||||||
|
if (mt.includes("text")) return "fa fa-file-text-o";
|
||||||
|
return "fa fa-file-o";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Submit
|
||||||
|
async onSubmit() {
|
||||||
|
if (this.state.submitting) return;
|
||||||
|
const subject = (this.state.subject || "").trim();
|
||||||
|
if (!subject) {
|
||||||
|
this.state.error = _t("Subject is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.error = "";
|
||||||
|
this.state.success = false;
|
||||||
|
this.state.submitting = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
kind: this.state.kind,
|
||||||
|
subject,
|
||||||
|
description: this.state.description || "",
|
||||||
|
error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
|
||||||
|
attachments: this.state.attachments.map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
mimetype: a.mimetype,
|
||||||
|
data_b64: a.data_b64,
|
||||||
|
})),
|
||||||
|
page_url: window.location.href,
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
};
|
||||||
|
const res = await rpc("/fusion_helpdesk/submit", payload);
|
||||||
|
if (!res.ok) {
|
||||||
|
this.state.error = res.message || _t("Submission failed.");
|
||||||
|
} else {
|
||||||
|
this.state.success = true;
|
||||||
|
this.state.ticketId = res.ticket_id;
|
||||||
|
this.state.ticketUrl = res.ticket_url;
|
||||||
|
this.state.attached = res.attached || 0;
|
||||||
|
// Reset the editable fields so user can file another if they want.
|
||||||
|
this.state.subject = "";
|
||||||
|
this.state.description = "";
|
||||||
|
this.state.errorCode = "";
|
||||||
|
this.state.attachments = [];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.state.error = (err && err.message) || _t("Network error.");
|
||||||
|
} finally {
|
||||||
|
this.state.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js
Normal file
33
fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
// Fusion Helpdesk — top systray icon. Sequence chosen so the icon
|
||||||
|
// appears to the LEFT of the attendance check-in button. Odoo
|
||||||
|
// systray ordering is by sequence ascending (lower = leftmost in the
|
||||||
|
// systray bar). hr_attendance ships at sequence 100, so we use 99.
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog";
|
||||||
|
|
||||||
|
class FusionHelpdeskSystray extends Component {
|
||||||
|
static template = "fusion_helpdesk.SystrayItem";
|
||||||
|
static props = {};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.dialog = useService("dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick() {
|
||||||
|
this.dialog.add(FusionHelpdeskDialog, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fusionHelpdeskSystrayItem = {
|
||||||
|
Component: FusionHelpdeskSystray,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry
|
||||||
|
.category("systray")
|
||||||
|
.add("fusion_helpdesk.report_button", fusionHelpdeskSystrayItem, {
|
||||||
|
sequence: 99,
|
||||||
|
});
|
||||||
172
fusion_helpdesk/static/src/scss/fusion_helpdesk.scss
Normal file
172
fusion_helpdesk/static/src/scss/fusion_helpdesk.scss
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// Fusion Helpdesk Reporter — systray button + dialog styling.
|
||||||
|
// Dark-mode aware via Odoo's $o-webclient-color-scheme branch.
|
||||||
|
|
||||||
|
$o-webclient-color-scheme: bright !default;
|
||||||
|
|
||||||
|
$_fhd-text-hex: #21252b;
|
||||||
|
$_fhd-muted-hex: #6c757d;
|
||||||
|
$_fhd-bg-hex: #ffffff;
|
||||||
|
$_fhd-border-hex: #d8dadd;
|
||||||
|
$_fhd-hover-hex: #f3f4f6;
|
||||||
|
$_fhd-accent-hex: #2c89e9;
|
||||||
|
|
||||||
|
@if $o-webclient-color-scheme == dark {
|
||||||
|
$_fhd-text-hex: #e6e9ef !global;
|
||||||
|
$_fhd-muted-hex: #9aa3ad !global;
|
||||||
|
$_fhd-bg-hex: #22262d !global;
|
||||||
|
$_fhd-border-hex: #3a3f47 !global;
|
||||||
|
$_fhd-hover-hex: #2c313a !global;
|
||||||
|
$_fhd-accent-hex: #4ea3ff !global;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fhd-text: var(--fhd-text, $_fhd-text-hex);
|
||||||
|
$fhd-muted: var(--fhd-muted, $_fhd-muted-hex);
|
||||||
|
$fhd-bg: var(--fhd-bg, $_fhd-bg-hex);
|
||||||
|
$fhd-border: var(--fhd-border, $_fhd-border-hex);
|
||||||
|
$fhd-hover: var(--fhd-hover, $_fhd-hover-hex);
|
||||||
|
$fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
|
||||||
|
|
||||||
|
// Systray icon
|
||||||
|
.o_fhd_systray {
|
||||||
|
.o_fhd_systray_btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover .o_fhd_systray_img {
|
||||||
|
transform: scale(1.08);
|
||||||
|
filter: drop-shadow(0 0 2px rgba(78, 163, 255, 0.45));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_systray_img {
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
object-fit: contain;
|
||||||
|
transition: transform 120ms ease, filter 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the dropdown caret Bootstrap injects.
|
||||||
|
.dropdown-toggle::after { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog
|
||||||
|
.o_fhd_dialog {
|
||||||
|
color: $fhd-text;
|
||||||
|
|
||||||
|
.o_fhd_kind_row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_kind_chip {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background-color: $fhd-bg;
|
||||||
|
border: 1px solid $fhd-border;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: $fhd-text;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover { background-color: $fhd-hover; }
|
||||||
|
|
||||||
|
&.o_fhd_kind_active {
|
||||||
|
border-color: $fhd-accent;
|
||||||
|
color: $fhd-accent;
|
||||||
|
background-color: rgba(44, 137, 233, 0.10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_field {
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: $fhd-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_hint {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $fhd-muted;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_mono {
|
||||||
|
font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_actions_row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: 1px solid $fhd-border;
|
||||||
|
background-color: $fhd-bg;
|
||||||
|
color: $fhd-text;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) { background-color: $fhd-hover; }
|
||||||
|
|
||||||
|
&:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_attach_list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_attach_item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
background-color: $fhd-hover;
|
||||||
|
border: 1px solid $fhd-border;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_attach_name {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_attach_size {
|
||||||
|
color: $fhd-muted;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fhd_attach_remove {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: $fhd-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
|
||||||
|
&:hover { color: #d32f2f; }
|
||||||
|
}
|
||||||
|
}
|
||||||
110
fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml
Normal file
110
fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_helpdesk.Dialog">
|
||||||
|
<Dialog title="dialogTitle" size="'lg'">
|
||||||
|
<div class="o_fhd_dialog">
|
||||||
|
<!-- Kind selector -->
|
||||||
|
<div class="o_fhd_kind_row">
|
||||||
|
<button type="button"
|
||||||
|
class="o_fhd_kind_chip"
|
||||||
|
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
|
||||||
|
t-on-click="() => this.setKind('bug')">
|
||||||
|
<i class="fa fa-bug me-1"/> Report a Bug
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="o_fhd_kind_chip"
|
||||||
|
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
|
||||||
|
t-on-click="() => this.setKind('feature')">
|
||||||
|
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subject -->
|
||||||
|
<div class="o_fhd_field">
|
||||||
|
<label>Subject *</label>
|
||||||
|
<input type="text" class="form-control"
|
||||||
|
t-att-value="state.subject"
|
||||||
|
t-on-input="(ev) => state.subject = ev.target.value"
|
||||||
|
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="o_fhd_field">
|
||||||
|
<label t-esc="state.kind === 'bug' ? 'What were you doing? What did you expect?' : 'Describe the desired behaviour and the use case'"/>
|
||||||
|
<textarea class="form-control" rows="5"
|
||||||
|
t-att-value="state.description"
|
||||||
|
t-on-input="(ev) => state.description = ev.target.value"
|
||||||
|
placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error code (bug only) -->
|
||||||
|
<div class="o_fhd_field" t-if="state.kind === 'bug'">
|
||||||
|
<label>
|
||||||
|
Error code / traceback
|
||||||
|
<span class="o_fhd_hint">paste any error message or stack trace</span>
|
||||||
|
</label>
|
||||||
|
<textarea class="form-control o_fhd_mono" rows="3"
|
||||||
|
t-att-value="state.errorCode"
|
||||||
|
t-on-input="(ev) => state.errorCode = ev.target.value"
|
||||||
|
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachments -->
|
||||||
|
<div class="o_fhd_field">
|
||||||
|
<label>Attachments</label>
|
||||||
|
<div class="o_fhd_actions_row">
|
||||||
|
<label class="o_fhd_btn o_fhd_btn_secondary">
|
||||||
|
<i class="fa fa-paperclip me-1"/> Attach files
|
||||||
|
<input type="file" multiple="multiple" class="d-none"
|
||||||
|
t-on-change="onFilesPicked"/>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="o_fhd_btn o_fhd_btn_secondary"
|
||||||
|
t-on-click="onTakeScreenshot"
|
||||||
|
t-att-disabled="state.capturing">
|
||||||
|
<i class="fa fa-camera me-1"/>
|
||||||
|
<t t-if="state.capturing">Capturing…</t>
|
||||||
|
<t t-else="">Capture screenshot</t>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div t-if="state.attachments.length" class="o_fhd_attach_list">
|
||||||
|
<div t-foreach="state.attachments" t-as="att" t-key="att_index"
|
||||||
|
class="o_fhd_attach_item">
|
||||||
|
<i t-att-class="att.iconClass"/>
|
||||||
|
<span class="o_fhd_attach_name" t-esc="att.name"/>
|
||||||
|
<span class="o_fhd_attach_size" t-esc="att.sizeLabel"/>
|
||||||
|
<button type="button" class="o_fhd_attach_remove"
|
||||||
|
t-on-click="() => this.removeAttachment(att_index)">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result feedback -->
|
||||||
|
<div t-if="state.error" class="alert alert-danger mt-2">
|
||||||
|
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
|
||||||
|
</div>
|
||||||
|
<div t-if="state.success" class="alert alert-success mt-2">
|
||||||
|
<i class="fa fa-check-circle me-1"/>
|
||||||
|
Thanks — ticket
|
||||||
|
<a t-att-href="state.ticketUrl" target="_blank">
|
||||||
|
#<t t-esc="state.ticketId"/>
|
||||||
|
</a> created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-set-slot="footer">
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
t-on-click="onSubmit"
|
||||||
|
t-att-disabled="state.submitting or !state.subject.trim()">
|
||||||
|
<t t-if="state.submitting"><i class="fa fa-spinner fa-spin me-1"/></t>
|
||||||
|
<t t-else=""><i class="fa fa-paper-plane me-1"/></t>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" t-on-click="props.close">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</Dialog>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
17
fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml
Normal file
17
fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_helpdesk.SystrayItem">
|
||||||
|
<div class="o_fhd_systray dropdown">
|
||||||
|
<button type="button"
|
||||||
|
class="o_fhd_systray_btn dropdown-toggle"
|
||||||
|
title="Report a bug or request a feature"
|
||||||
|
t-on-click="onClick">
|
||||||
|
<img src="/fusion_helpdesk/static/description/icon.png"
|
||||||
|
alt="Help"
|
||||||
|
class="o_fhd_systray_img"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
51
fusion_helpdesk/views/res_config_settings_views.xml
Normal file
51
fusion_helpdesk/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="res_config_settings_view_form_fhd" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.fusion.helpdesk</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 Helpdesk"
|
||||||
|
string="Fusion Helpdesk"
|
||||||
|
name="fusion_helpdesk">
|
||||||
|
<block title="Central Helpdesk Endpoint"
|
||||||
|
name="fhd_endpoint">
|
||||||
|
<setting id="fhd_remote_url"
|
||||||
|
string="Remote URL"
|
||||||
|
help="Base URL of the Odoo instance running the Helpdesk app.">
|
||||||
|
<field name="fhd_remote_url" placeholder="https://erp.nexasystems.ca"/>
|
||||||
|
</setting>
|
||||||
|
<setting id="fhd_remote_db"
|
||||||
|
string="Remote Database">
|
||||||
|
<field name="fhd_remote_db" placeholder="nexamain"/>
|
||||||
|
</setting>
|
||||||
|
<setting id="fhd_remote_login"
|
||||||
|
string="Service Login">
|
||||||
|
<field name="fhd_remote_login" placeholder="helpdesk_bot@nexasystems.ca"/>
|
||||||
|
</setting>
|
||||||
|
<setting id="fhd_remote_password"
|
||||||
|
string="Service Password / API Key">
|
||||||
|
<field name="fhd_remote_password" password="True"/>
|
||||||
|
</setting>
|
||||||
|
<setting id="fhd_remote_team_id"
|
||||||
|
string="Helpdesk Team ID (optional)">
|
||||||
|
<field name="fhd_remote_team_id"/>
|
||||||
|
</setting>
|
||||||
|
<setting id="fhd_client_label"
|
||||||
|
string="Client Label"
|
||||||
|
help="Tag prefixed to every ticket subject so support can identify the source deployment.">
|
||||||
|
<field name="fhd_client_label" placeholder="ENTECH"/>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
2
fusion_helpdesk_central/__init__.py
Normal file
2
fusion_helpdesk_central/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
36
fusion_helpdesk_central/__manifest__.py
Normal file
36
fusion_helpdesk_central/__manifest__.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
{
|
||||||
|
'name': 'Fusion Helpdesk Central — Client API Keys',
|
||||||
|
'version': '19.0.1.0.2',
|
||||||
|
'category': 'Productivity',
|
||||||
|
'summary': 'Admin UI on the central Odoo for issuing per-client API '
|
||||||
|
'keys used by fusion_helpdesk client deployments.',
|
||||||
|
'description': """
|
||||||
|
Fusion Helpdesk Central
|
||||||
|
=======================
|
||||||
|
Companion to `fusion_helpdesk`. Install on the central Odoo (the one
|
||||||
|
running the Helpdesk app) to manage **per-client API keys** instead of
|
||||||
|
shipping a shared bot password to every client deployment.
|
||||||
|
|
||||||
|
Each row in *Helpdesk → Configuration → Client API Keys* maps a client
|
||||||
|
label (e.g. ENTECH, MOBILITY) to a real `res.users.apikeys` row on the
|
||||||
|
shared bot user. The plaintext key is shown ONCE on creation; revoke
|
||||||
|
in one click if a deployment is compromised.
|
||||||
|
|
||||||
|
Depends only on `helpdesk`. No client-side install needed.
|
||||||
|
""",
|
||||||
|
'author': 'Nexa Systems Inc.',
|
||||||
|
'website': 'https://www.nexasystems.ca',
|
||||||
|
'license': 'OPL-1',
|
||||||
|
'depends': ['helpdesk'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/ir_config_parameter_data.xml',
|
||||||
|
'views/fusion_helpdesk_client_key_views.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'auto_install': False,
|
||||||
|
'application': False,
|
||||||
|
}
|
||||||
13
fusion_helpdesk_central/data/ir_config_parameter_data.xml
Normal file
13
fusion_helpdesk_central/data/ir_config_parameter_data.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1
|
||||||
|
-->
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="fhc_default_bot_login" model="ir.config_parameter">
|
||||||
|
<field name="key">fusion_helpdesk.bot_login</field>
|
||||||
|
<field name="value">helpdesk_bot@nexasystems.ca</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
2
fusion_helpdesk_central/models/__init__.py
Normal file
2
fusion_helpdesk_central/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import fusion_helpdesk_client_key
|
||||||
186
fusion_helpdesk_central/models/fusion_helpdesk_client_key.py
Normal file
186
fusion_helpdesk_central/models/fusion_helpdesk_client_key.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Per-client API key registry for fusion_helpdesk.
|
||||||
|
|
||||||
|
Each row links a client deployment label (e.g. ENTECH) to a real
|
||||||
|
`res.users.apikeys` row on a shared bot account. The plaintext key is
|
||||||
|
shown ONCE on creation, then cleared. Revoking a row deletes the
|
||||||
|
underlying API key so the client deployment can no longer authenticate.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionHelpdeskClientKey(models.Model):
|
||||||
|
_name = 'fusion.helpdesk.client.key'
|
||||||
|
_description = 'Fusion Helpdesk — Client API Key'
|
||||||
|
_order = 'create_date desc'
|
||||||
|
_rec_name = 'client_label'
|
||||||
|
|
||||||
|
client_label = fields.Char(
|
||||||
|
string='Client Label', required=True, index=True,
|
||||||
|
help='Short tag identifying this client deployment '
|
||||||
|
'(e.g. ENTECH, MOBILITY). Free text — used for your '
|
||||||
|
'reference and to find keys quickly when revoking.',
|
||||||
|
)
|
||||||
|
notes = fields.Text(
|
||||||
|
string='Notes',
|
||||||
|
help='Optional. Stamp deployment URL, contact, install date.',
|
||||||
|
)
|
||||||
|
bot_user_id = fields.Many2one(
|
||||||
|
'res.users', string='Bot User', readonly=True,
|
||||||
|
ondelete='restrict',
|
||||||
|
)
|
||||||
|
apikey_id = fields.Many2one(
|
||||||
|
'res.users.apikeys', string='API Key Record',
|
||||||
|
readonly=True, ondelete='set null',
|
||||||
|
help='Underlying res.users.apikeys row. Cleared if the key '
|
||||||
|
'is revoked or deleted out-of-band.',
|
||||||
|
)
|
||||||
|
apikey_name = fields.Char(
|
||||||
|
string='Key Name', related='apikey_id.name', store=True,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
plaintext_key = fields.Char(
|
||||||
|
string='Plaintext Key (one-time display)',
|
||||||
|
readonly=True, copy=False,
|
||||||
|
help='Shown ONCE right after creation. Copy it now — once you '
|
||||||
|
'click "Mark Stored" it is wiped from this record forever. '
|
||||||
|
'The key keeps working; we just stop showing it.',
|
||||||
|
)
|
||||||
|
is_revoked = fields.Boolean(
|
||||||
|
string='Revoked', compute='_compute_is_revoked', store=True,
|
||||||
|
)
|
||||||
|
create_date = fields.Datetime(readonly=True)
|
||||||
|
display_name = fields.Char(
|
||||||
|
string='Display Name', compute='_compute_display_name', store=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('client_label', 'is_revoked')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for rec in self:
|
||||||
|
base = rec.client_label or _('(unnamed)')
|
||||||
|
rec.display_name = ('%s [REVOKED]' % base) if rec.is_revoked else base
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('fhc_client_label_unique',
|
||||||
|
'unique(client_label)',
|
||||||
|
'Client label must be unique — one row per client deployment.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.depends('apikey_id')
|
||||||
|
def _compute_is_revoked(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.is_revoked = not rec.apikey_id
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _default_expiration_date(self):
|
||||||
|
"""Odoo 19 requires API keys to carry an expiration. Default to
|
||||||
|
10 years out so client deployments don't break unexpectedly.
|
||||||
|
Override per row by writing to apikey_id.expiration_date later."""
|
||||||
|
years = int(
|
||||||
|
self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_helpdesk.key_expiration_years', '10',
|
||||||
|
) or 10
|
||||||
|
)
|
||||||
|
return datetime.utcnow() + timedelta(days=365 * years)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_bot_user(self):
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
login = (ICP.get_param('fusion_helpdesk.bot_login') or '').strip()
|
||||||
|
if not login:
|
||||||
|
raise UserError(_(
|
||||||
|
'No bot user configured. Set `fusion_helpdesk.bot_login` '
|
||||||
|
'in System Parameters first.'
|
||||||
|
))
|
||||||
|
user = self.env['res.users'].search([('login', '=', login)], limit=1)
|
||||||
|
if not user:
|
||||||
|
raise UserError(_(
|
||||||
|
'Bot user "%s" not found. Create it first or update '
|
||||||
|
'`fusion_helpdesk.bot_login`.', login
|
||||||
|
))
|
||||||
|
return user
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
ApiKey = self.env['res.users.apikeys'].with_context(
|
||||||
|
mail_create_nosubscribe=True,
|
||||||
|
)
|
||||||
|
bot = self._get_bot_user()
|
||||||
|
records = super().create(vals_list)
|
||||||
|
for rec in records:
|
||||||
|
# Generate the apikey AS the bot user — the key is bound to
|
||||||
|
# the user that creates it.
|
||||||
|
label = 'fusion_helpdesk:%s' % (rec.client_label or rec.id)
|
||||||
|
key = ApiKey.with_user(bot)._generate(
|
||||||
|
'rpc', label, self._default_expiration_date(),
|
||||||
|
)
|
||||||
|
# Find the apikey row that was just created so we can link it.
|
||||||
|
apikey_row = self.env['res.users.apikeys'].sudo().search(
|
||||||
|
[('user_id', '=', bot.id), ('name', '=', label)],
|
||||||
|
limit=1, order='id desc',
|
||||||
|
)
|
||||||
|
rec.write({
|
||||||
|
'bot_user_id': bot.id,
|
||||||
|
'apikey_id': apikey_row.id if apikey_row else False,
|
||||||
|
'plaintext_key': key,
|
||||||
|
})
|
||||||
|
_logger.info(
|
||||||
|
'fusion_helpdesk_central: issued API key for client "%s" '
|
||||||
|
'(apikey_id=%s, bot_user=%s)',
|
||||||
|
rec.client_label, apikey_row.id if apikey_row else '?',
|
||||||
|
bot.login,
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
def action_mark_stored(self):
|
||||||
|
"""User confirms they have copied the plaintext key. Wipe it
|
||||||
|
from the DB so a later read can't recover it. The underlying
|
||||||
|
res.users.apikeys row keeps working — only the plaintext copy
|
||||||
|
on this row is destroyed."""
|
||||||
|
self.write({'plaintext_key': False})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_revoke(self):
|
||||||
|
"""Delete the underlying res.users.apikeys row. The client
|
||||||
|
deployment's next request will get an auth_failed error."""
|
||||||
|
for rec in self:
|
||||||
|
if rec.apikey_id:
|
||||||
|
rec.apikey_id.sudo().unlink()
|
||||||
|
_logger.warning(
|
||||||
|
'fusion_helpdesk_central: REVOKED API key for "%s" '
|
||||||
|
'(was apikey_id=%s)',
|
||||||
|
rec.client_label, rec.apikey_id.id,
|
||||||
|
)
|
||||||
|
rec.write({'plaintext_key': False})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_rotate(self):
|
||||||
|
"""One-shot: revoke the existing key + issue a new one. Plaintext
|
||||||
|
is shown once on the same row."""
|
||||||
|
ApiKey = self.env['res.users.apikeys']
|
||||||
|
bot = self._get_bot_user()
|
||||||
|
for rec in self:
|
||||||
|
if rec.apikey_id:
|
||||||
|
rec.apikey_id.sudo().unlink()
|
||||||
|
label = 'fusion_helpdesk:%s' % rec.client_label
|
||||||
|
key = ApiKey.with_user(bot)._generate(
|
||||||
|
'rpc', label, self._default_expiration_date(),
|
||||||
|
)
|
||||||
|
apikey_row = self.env['res.users.apikeys'].sudo().search(
|
||||||
|
[('user_id', '=', bot.id), ('name', '=', label)],
|
||||||
|
limit=1, order='id desc',
|
||||||
|
)
|
||||||
|
rec.write({
|
||||||
|
'apikey_id': apikey_row.id if apikey_row else False,
|
||||||
|
'plaintext_key': key,
|
||||||
|
})
|
||||||
|
return True
|
||||||
2
fusion_helpdesk_central/security/ir.model.access.csv
Normal file
2
fusion_helpdesk_central/security/ir.model.access.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_fhc_client_key_admin,fusion.helpdesk.client.key.admin,model_fusion_helpdesk_client_key,base.group_system,1,1,1,1
|
||||||
|
@@ -0,0 +1,136 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fhc_client_key_list" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.helpdesk.client.key.list</field>
|
||||||
|
<field name="model">fusion.helpdesk.client.key</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Helpdesk Client API Keys"
|
||||||
|
decoration-muted="is_revoked">
|
||||||
|
<field name="client_label"/>
|
||||||
|
<field name="apikey_name" optional="hide"/>
|
||||||
|
<field name="bot_user_id"/>
|
||||||
|
<field name="create_date"/>
|
||||||
|
<field name="is_revoked" widget="boolean_toggle" readonly="1"/>
|
||||||
|
<field name="notes" optional="hide"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fhc_client_key_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.helpdesk.client.key.form</field>
|
||||||
|
<field name="model">fusion.helpdesk.client.key</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Helpdesk Client API Key">
|
||||||
|
<header>
|
||||||
|
<button name="action_mark_stored" type="object"
|
||||||
|
string="Mark Key Stored"
|
||||||
|
class="btn-primary"
|
||||||
|
invisible="not plaintext_key"
|
||||||
|
confirm="Wipe the plaintext key from this record? It can't be recovered after this."/>
|
||||||
|
<button name="action_rotate" type="object"
|
||||||
|
string="Rotate Key"
|
||||||
|
class="btn-secondary"
|
||||||
|
invisible="is_revoked or plaintext_key"
|
||||||
|
confirm="Revoke the existing key and issue a fresh one? The client deployment will need its config updated to keep working."/>
|
||||||
|
<button name="action_revoke" type="object"
|
||||||
|
string="Revoke"
|
||||||
|
class="btn-danger"
|
||||||
|
invisible="is_revoked"
|
||||||
|
confirm="Revoke this key permanently? The client deployment will stop being able to file tickets."/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<widget name="web_ribbon" title="Revoked"
|
||||||
|
bg_color="bg-danger"
|
||||||
|
invisible="not is_revoked"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="client_label"/>
|
||||||
|
<h1><field name="client_label" placeholder="e.g. ENTECH"/></h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning"
|
||||||
|
role="alert"
|
||||||
|
invisible="not plaintext_key">
|
||||||
|
<h4 class="alert-heading">
|
||||||
|
<i class="fa fa-key me-1"/> Copy the key now
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
This is the <strong>only time</strong> the plaintext key
|
||||||
|
is shown. After you click <em>Mark Key Stored</em> in the
|
||||||
|
header it will be wiped from this record forever.
|
||||||
|
The key keeps working — we just stop displaying it.
|
||||||
|
</p>
|
||||||
|
<pre class="mt-2 p-2 bg-light text-dark"
|
||||||
|
style="user-select: all; word-break: break-all;"
|
||||||
|
><field name="plaintext_key" nolabel="1" readonly="1"/></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="bot_user_id"/>
|
||||||
|
<field name="apikey_name"/>
|
||||||
|
<field name="create_date"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="apikey_id" readonly="1"/>
|
||||||
|
<field name="is_revoked" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Notes">
|
||||||
|
<field name="notes" nolabel="1" placeholder="Deployment URL, contact email, install date…"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fhc_client_key_search" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.helpdesk.client.key.search</field>
|
||||||
|
<field name="model">fusion.helpdesk.client.key</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="client_label"/>
|
||||||
|
<field name="notes"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Active" name="active"
|
||||||
|
domain="[('is_revoked', '=', False)]"/>
|
||||||
|
<filter string="Revoked" name="revoked"
|
||||||
|
domain="[('is_revoked', '=', True)]"/>
|
||||||
|
<group>
|
||||||
|
<filter string="Bot User" name="g_bot"
|
||||||
|
context="{'group_by': 'bot_user_id'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fhc_client_key" model="ir.actions.act_window">
|
||||||
|
<field name="name">Helpdesk Client API Keys</field>
|
||||||
|
<field name="res_model">fusion.helpdesk.client.key</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="context">{'search_default_active': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Issue a new API key
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Each row maps a client deployment (e.g. ENTECH, MOBILITY)
|
||||||
|
to a real API key on the shared bot user. The plaintext
|
||||||
|
key is shown ONCE on creation — copy it then click
|
||||||
|
"Mark Key Stored". Revoke any key in one click if a
|
||||||
|
deployment is compromised.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_fhc_client_key"
|
||||||
|
name="Client API Keys"
|
||||||
|
parent="helpdesk.helpdesk_menu_config"
|
||||||
|
action="action_fhc_client_key"
|
||||||
|
sequence="90"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# FP Step Kind — User-Extensible Model
|
||||||
|
|
||||||
|
Date: 2026-05-04
|
||||||
|
Status: design + implementation
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
`default_kind` on `fp.step.template` is a hardcoded `Selection` of 24 entries. Users can't add new kinds (e.g. "Shot Peen", "Passivation"). The same Selection list is duplicated on `fusion.plating.process.node`. Default-input seeding (`DEFAULT_INPUTS_BY_KIND`) is also locked in Python — adding a new kind would require a code change + module update.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Convert `default_kind` from `Selection` → `Many2one('fp.step.kind')`. Move the default-input templates from a Python dict into seeded data records on a new `fp.step.kind.default.input` child model. Users add new kinds through the standard Odoo CRUD form.
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
### `fp.step.kind`
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `code` | Char, required, unique per company | Technical key (lowercase). Stable XML IDs use this. |
|
||||||
|
| `name` | Char, required, translated | UI label (e.g. "Cleaning") |
|
||||||
|
| `sequence` | Integer | Order in dropdown |
|
||||||
|
| `active` | Boolean default True | Archive instead of delete |
|
||||||
|
| `icon` | Selection (reuses 24-icon list from `fp.process.node`) | Optional |
|
||||||
|
| `description` | Html | Optional ops note |
|
||||||
|
| `company_id` | Many2one res.company | Multi-co support |
|
||||||
|
| `default_input_ids` | One2many → `fp.step.kind.default.input` | The seed list |
|
||||||
|
|
||||||
|
### `fp.step.kind.default.input`
|
||||||
|
Same shape as `fp.step.template.input`: `name`, `input_type`, `target_unit`, `sequence`, `required`, `hint`, `selection_options`. Plus `kind_id` parent FK.
|
||||||
|
|
||||||
|
## Field changes
|
||||||
|
|
||||||
|
### `fp.step.template`
|
||||||
|
- **add** `kind_id = Many2one('fp.step.kind', ondelete='restrict', string='Step Kind')` — user-facing input
|
||||||
|
- **change** existing `default_kind` from `Selection(...)` to `default_kind = fields.Char(related='kind_id.code', store=True, readonly=True)` — back-compat shim. Lets every legacy `node.default_kind == 'cleaning'` comparison keep working without touching 30+ sites. Stored=True so existing search domains (`('default_kind', '=', 'foo')`) still work.
|
||||||
|
|
||||||
|
### `fusion.plating.process.node`
|
||||||
|
- Same: add `kind_id`, convert `default_kind` to `related='kind_id.code'` stored Char.
|
||||||
|
|
||||||
|
### `fp.job.workflow.state.trigger_default_kinds`
|
||||||
|
- **No change in Phase 1.** Stays a Char of comma-separated codes. The codes still match `kind_id.code` after migration. Phase 2 deferred convert to m2m.
|
||||||
|
|
||||||
|
## DEFAULT_INPUTS_BY_KIND
|
||||||
|
Removed from `fp_step_template.py`. Lives in seed data XML. `action_seed_default_inputs` reads `tpl.kind_id.default_input_ids` instead of the dict.
|
||||||
|
|
||||||
|
## Migration `19.0.18.13.0/post-migrate.py`
|
||||||
|
1. SQL-read existing `default_kind` text values from `fp_step_template` and `fusion_plating_process_node` BEFORE Odoo recomputes the related field.
|
||||||
|
2. Build map `code → fp.step.kind.id` from seeded records.
|
||||||
|
3. SQL UPDATE `kind_id` per row.
|
||||||
|
4. Trigger recompute of stored related `default_kind` (or leave — values are already there from step 1).
|
||||||
|
5. Log counts.
|
||||||
|
|
||||||
|
## Why not drop `default_kind` entirely?
|
||||||
|
- Comma-CSV `trigger_default_kinds` on workflow state still searches it.
|
||||||
|
- 30+ comparison sites and existing search domains in views.
|
||||||
|
- Stored related Char keeps it a single source of truth (m2o-derived) without a churn-PR through every dependent.
|
||||||
|
|
||||||
|
## View changes
|
||||||
|
- `fp_step_template_views.xml` — replace `<field name="default_kind"/>` with `<field name="kind_id" options="{'no_create_edit': false, 'no_quick_create': false}"/>`. Same for search filter and `group_by="default_kind"` → `group_by="kind_id"`.
|
||||||
|
- `fp_process_node_views.xml` — same swap on the one occurrence.
|
||||||
|
- New `fp_step_kind_views.xml` — list/form/menu so admins can manage kinds. Form embeds `default_input_ids` as inline list.
|
||||||
|
|
||||||
|
## Controller / JS changes
|
||||||
|
- `simple_recipe_controller.py`:
|
||||||
|
- Add `kind_id` (id) and `kind_name` (label) to all step-payload responses.
|
||||||
|
- Accept `kind_id` int on `_save_recipe`/template-create endpoints.
|
||||||
|
- Keep returning `default_kind` (the code Char) for back-compat with deployed editor sessions until cache flushes.
|
||||||
|
- `simple_recipe_editor.js`:
|
||||||
|
- Replace the `<select>` over selection options with a Many2one-style typeahead. Fetch list from new endpoint `/fusion_plating/recipe/kinds` returning `[{id, code, name, icon}]`.
|
||||||
|
- Submit `kind_id` (int) instead of `default_kind` (string).
|
||||||
|
- `simple_recipe_editor.xml`:
|
||||||
|
- Replace `<select t-model=".default_kind">` block with a `<select t-model=".kind_id">` populated from fetched list, plus a "+ New kind…" link that opens a small inline form via `/fusion_plating/recipe/kinds/create`.
|
||||||
|
|
||||||
|
## ACLs (`ir.model.access.csv`)
|
||||||
|
```
|
||||||
|
access_fp_step_kind_operator,fp.step.kind.operator,model_fp_step_kind,group_fusion_plating_operator,1,0,0,0
|
||||||
|
access_fp_step_kind_supervisor,fp.step.kind.supervisor,model_fp_step_kind,group_fusion_plating_supervisor,1,1,1,0
|
||||||
|
access_fp_step_kind_manager,fp.step.kind.manager,model_fp_step_kind,group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_step_kind_default_input_* … same triplet
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manifest
|
||||||
|
- Bump `version` to `19.0.18.13.0`
|
||||||
|
- Add to `data`:
|
||||||
|
- `data/fp_step_kind_data.xml` (BEFORE `fp_step_template_data.xml` since templates reference kinds)
|
||||||
|
- `views/fp_step_kind_views.xml`
|
||||||
|
|
||||||
|
## Out of scope (Phase 2)
|
||||||
|
- Convert `trigger_default_kinds` Char-CSV to m2m on `fp.job.workflow.state`
|
||||||
|
- Remove DEFAULT_INPUTS_BY_KIND dict from migration scripts (one-shot dev scripts — no harm leaving)
|
||||||
|
- Migrate stored `default_kind` Char to a non-stored related once all callers refactored
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
1. `odoo -d admin -u fusion_plating --stop-after-init` (entech) — install + migration runs cleanly
|
||||||
|
2. UI smoke: open a step template, kind dropdown shows 24 seeded kinds, dropdown allows quick-create
|
||||||
|
3. Create new kind "Passivation" with 3 default inputs → save → return to step template → "Seed defaults" button populates the 3 inputs
|
||||||
|
4. Open recipe editor (simple): kind dropdown matches; existing recipes show correct kind label
|
||||||
|
5. Run a step that's wired through workflow-state trigger (e.g. completion of a `receiving` step → state changes to "On Floor") to confirm CSV-trigger backwards compat still works
|
||||||
@@ -32,6 +32,18 @@ def post_init_hook(env):
|
|||||||
_migrate_legacy_uom_columns(env)
|
_migrate_legacy_uom_columns(env)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_kind_id(env, code):
|
||||||
|
"""Look up an fp.step.kind id by code. Returns False if not found.
|
||||||
|
Cheap helper used during seeding so legacy code paths that referenced
|
||||||
|
string codes can keep their semantics."""
|
||||||
|
if not code:
|
||||||
|
return False
|
||||||
|
rec = env['fp.step.kind'].search(
|
||||||
|
[('code', '=', code)], limit=1,
|
||||||
|
)
|
||||||
|
return rec.id or False
|
||||||
|
|
||||||
|
|
||||||
def _backfill_contract_review_template(env):
|
def _backfill_contract_review_template(env):
|
||||||
"""Idempotent — ensure the Contract Review library template exists.
|
"""Idempotent — ensure the Contract Review library template exists.
|
||||||
|
|
||||||
@@ -45,7 +57,7 @@ def _backfill_contract_review_template(env):
|
|||||||
return # already there
|
return # already there
|
||||||
tpl = Tpl.create({
|
tpl = Tpl.create({
|
||||||
'name': 'Contract Review',
|
'name': 'Contract Review',
|
||||||
'default_kind': 'contract_review',
|
'kind_id': _resolve_kind_id(env, 'contract_review'),
|
||||||
})
|
})
|
||||||
tpl.action_seed_default_inputs()
|
tpl.action_seed_default_inputs()
|
||||||
_logger.info(
|
_logger.info(
|
||||||
@@ -236,7 +248,7 @@ def _create_template_from_node(env, node, seen):
|
|||||||
'process_type_id': node.process_type_id.id,
|
'process_type_id': node.process_type_id.id,
|
||||||
'requires_signoff': node.requires_signoff,
|
'requires_signoff': node.requires_signoff,
|
||||||
'requires_predecessor_done': node.requires_predecessor_done,
|
'requires_predecessor_done': node.requires_predecessor_done,
|
||||||
'default_kind': kind,
|
'kind_id': _resolve_kind_id(env, kind),
|
||||||
}
|
}
|
||||||
# Snapshot tank_ids if the node has them (added by Sub 12a;
|
# Snapshot tank_ids if the node has them (added by Sub 12a;
|
||||||
# existing nodes may not).
|
# existing nodes may not).
|
||||||
@@ -275,7 +287,10 @@ def _seed_minimal_library(env):
|
|||||||
('Shipping', 'ship'),
|
('Shipping', 'ship'),
|
||||||
]
|
]
|
||||||
for name, kind in minimal:
|
for name, kind in minimal:
|
||||||
tpl = Tpl.create({'name': name, 'default_kind': kind})
|
tpl = Tpl.create({
|
||||||
|
'name': name,
|
||||||
|
'kind_id': _resolve_kind_id(env, kind),
|
||||||
|
})
|
||||||
tpl.action_seed_default_inputs()
|
tpl.action_seed_default_inputs()
|
||||||
_logger.info(
|
_logger.info(
|
||||||
'Fusion Plating: seeded minimal step library (%s entries)',
|
'Fusion Plating: seeded minimal step library (%s entries)',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.18.12.4',
|
'version': '19.0.18.13.8',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -98,6 +98,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'views/fp_facility_views.xml',
|
'views/fp_facility_views.xml',
|
||||||
'views/fp_bath_views.xml',
|
'views/fp_bath_views.xml',
|
||||||
'views/fp_process_node_views.xml',
|
'views/fp_process_node_views.xml',
|
||||||
|
# Sub 14b — fp.step.kind catalog. MUST load before
|
||||||
|
# fp_step_template_data.xml (templates reference kinds via
|
||||||
|
# kind_id) AND before fp_step_template_views.xml (the form
|
||||||
|
# references the kind action menu).
|
||||||
|
'views/fp_step_kind_views.xml',
|
||||||
|
'data/fp_step_kind_data.xml',
|
||||||
'views/fp_step_template_views.xml',
|
'views/fp_step_template_views.xml',
|
||||||
'views/fp_rack_tag_views.xml',
|
'views/fp_rack_tag_views.xml',
|
||||||
'views/fp_job_step_move_views.xml',
|
'views/fp_job_step_move_views.xml',
|
||||||
@@ -128,10 +134,14 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'fusion_plating/static/src/scss/recipe_tree_editor.scss',
|
'fusion_plating/static/src/scss/recipe_tree_editor.scss',
|
||||||
'fusion_plating/static/src/scss/fp_chatter_dark.scss',
|
'fusion_plating/static/src/scss/fp_chatter_dark.scss',
|
||||||
'fusion_plating/static/src/scss/simple_recipe_editor.scss',
|
'fusion_plating/static/src/scss/simple_recipe_editor.scss',
|
||||||
|
# Sub 14b — visual icon picker for fp.step.kind etc.
|
||||||
|
'fusion_plating/static/src/scss/fp_icon_picker.scss',
|
||||||
'fusion_plating/static/src/xml/recipe_tree_editor.xml',
|
'fusion_plating/static/src/xml/recipe_tree_editor.xml',
|
||||||
'fusion_plating/static/src/xml/simple_recipe_editor.xml',
|
'fusion_plating/static/src/xml/simple_recipe_editor.xml',
|
||||||
|
'fusion_plating/static/src/xml/fp_icon_picker.xml',
|
||||||
'fusion_plating/static/src/js/recipe_tree_editor.js',
|
'fusion_plating/static/src/js/recipe_tree_editor.js',
|
||||||
'fusion_plating/static/src/js/simple_recipe_editor.js',
|
'fusion_plating/static/src/js/simple_recipe_editor.js',
|
||||||
|
'fusion_plating/static/src/js/fp_icon_picker.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'demo': [
|
'demo': [
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ _SNAPSHOT_FIELDS = [
|
|||||||
'parallel_start',
|
'parallel_start',
|
||||||
'triggers_workflow_state_id', # Sub 14 — workflow milestone trigger
|
'triggers_workflow_state_id', # Sub 14 — workflow milestone trigger
|
||||||
'requires_rack_assignment', 'requires_transition_form',
|
'requires_rack_assignment', 'requires_transition_form',
|
||||||
'default_kind',
|
'kind_id', # Sub 14b — replaces default_kind (now a related Char)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Fields on fp.step.template.input that copy 1:1 into
|
# Fields on fp.step.template.input that copy 1:1 into
|
||||||
@@ -90,6 +90,8 @@ class SimpleRecipeController(http.Controller):
|
|||||||
'sequence': step.sequence,
|
'sequence': step.sequence,
|
||||||
'icon': step.icon,
|
'icon': step.icon,
|
||||||
'default_kind': step.default_kind,
|
'default_kind': step.default_kind,
|
||||||
|
'kind_id': step.kind_id.id if step.kind_id else False,
|
||||||
|
'kind_name': step.kind_id.name if step.kind_id else '',
|
||||||
'requires_signoff': step.requires_signoff,
|
'requires_signoff': step.requires_signoff,
|
||||||
'requires_rack_assignment': step.requires_rack_assignment,
|
'requires_rack_assignment': step.requires_rack_assignment,
|
||||||
'requires_transition_form': step.requires_transition_form,
|
'requires_transition_form': step.requires_transition_form,
|
||||||
@@ -150,6 +152,8 @@ class SimpleRecipeController(http.Controller):
|
|||||||
'code': t.code,
|
'code': t.code,
|
||||||
'icon': t.icon,
|
'icon': t.icon,
|
||||||
'default_kind': t.default_kind,
|
'default_kind': t.default_kind,
|
||||||
|
'kind_id': t.kind_id.id if t.kind_id else False,
|
||||||
|
'kind_name': t.kind_id.name if t.kind_id else '',
|
||||||
'station_count': len(t.tank_ids),
|
'station_count': len(t.tank_ids),
|
||||||
}
|
}
|
||||||
for t in records
|
for t in records
|
||||||
@@ -201,6 +205,8 @@ class SimpleRecipeController(http.Controller):
|
|||||||
'code': tpl.code or '',
|
'code': tpl.code or '',
|
||||||
'icon': tpl.icon or 'fa-cog',
|
'icon': tpl.icon or 'fa-cog',
|
||||||
'default_kind': tpl.default_kind or '',
|
'default_kind': tpl.default_kind or '',
|
||||||
|
'kind_id': tpl.kind_id.id if tpl.kind_id else False,
|
||||||
|
'kind_name': tpl.kind_id.name if tpl.kind_id else '',
|
||||||
'description': tpl.description or '',
|
'description': tpl.description or '',
|
||||||
'requires_signoff': tpl.requires_signoff,
|
'requires_signoff': tpl.requires_signoff,
|
||||||
'requires_predecessor_done': tpl.requires_predecessor_done,
|
'requires_predecessor_done': tpl.requires_predecessor_done,
|
||||||
@@ -245,8 +251,11 @@ class SimpleRecipeController(http.Controller):
|
|||||||
"""
|
"""
|
||||||
Tpl = request.env['fp.step.template']
|
Tpl = request.env['fp.step.template']
|
||||||
# Whitelist — never trust client-provided write_uid / id / etc.
|
# Whitelist — never trust client-provided write_uid / id / etc.
|
||||||
|
# Sub 14b: `default_kind` is now a related read-only Char. The
|
||||||
|
# client may still send it as a string code for back-compat — we
|
||||||
|
# translate it to kind_id below.
|
||||||
allowed = {
|
allowed = {
|
||||||
'name', 'code', 'icon', 'default_kind', 'description',
|
'name', 'code', 'icon', 'kind_id', 'description',
|
||||||
'requires_signoff', 'requires_predecessor_done',
|
'requires_signoff', 'requires_predecessor_done',
|
||||||
'parallel_start',
|
'parallel_start',
|
||||||
'triggers_workflow_state_id', # Sub 14
|
'triggers_workflow_state_id', # Sub 14
|
||||||
@@ -254,6 +263,11 @@ class SimpleRecipeController(http.Controller):
|
|||||||
'tank_ids',
|
'tank_ids',
|
||||||
}
|
}
|
||||||
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
|
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
|
||||||
|
# Back-compat: accept default_kind (string code) and resolve to kind_id.
|
||||||
|
if 'kind_id' not in clean and (vals or {}).get('default_kind'):
|
||||||
|
clean['kind_id'] = self._resolve_kind_id_from_code(
|
||||||
|
vals['default_kind'],
|
||||||
|
)
|
||||||
# tank_ids comes in as a plain list of ids from the OWL form;
|
# tank_ids comes in as a plain list of ids from the OWL form;
|
||||||
# translate into the Odoo (6, 0, ids) command form.
|
# translate into the Odoo (6, 0, ids) command form.
|
||||||
if 'tank_ids' in clean:
|
if 'tank_ids' in clean:
|
||||||
@@ -266,6 +280,15 @@ class SimpleRecipeController(http.Controller):
|
|||||||
tpl = Tpl.create(clean)
|
tpl = Tpl.create(clean)
|
||||||
return {'ok': True, 'template': self._library_payload(tpl)}
|
return {'ok': True, 'template': self._library_payload(tpl)}
|
||||||
|
|
||||||
|
def _resolve_kind_id_from_code(self, code):
|
||||||
|
"""Look up fp.step.kind id by code. Empty string → False."""
|
||||||
|
if not code:
|
||||||
|
return False
|
||||||
|
rec = request.env['fp.step.kind'].search(
|
||||||
|
[('code', '=', code)], limit=1,
|
||||||
|
)
|
||||||
|
return rec.id or False
|
||||||
|
|
||||||
@http.route('/fp/simple_recipe/library/seed_defaults', type='jsonrpc', auth='user')
|
@http.route('/fp/simple_recipe/library/seed_defaults', type='jsonrpc', auth='user')
|
||||||
def library_seed_defaults(self, template_id):
|
def library_seed_defaults(self, template_id):
|
||||||
"""Run action_seed_default_inputs on this template. Idempotent —
|
"""Run action_seed_default_inputs on this template. Idempotent —
|
||||||
@@ -340,6 +363,55 @@ class SimpleRecipeController(http.Controller):
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@http.route('/fp/simple_recipe/kinds/list',
|
||||||
|
type='jsonrpc', auth='user')
|
||||||
|
def kinds_list(self):
|
||||||
|
"""Sub 14b — Step Kind dropdown options for the inline library
|
||||||
|
form. User-extensible via /fp/simple_recipe/kinds/create."""
|
||||||
|
Kind = request.env['fp.step.kind']
|
||||||
|
return {
|
||||||
|
'kinds': [
|
||||||
|
{
|
||||||
|
'id': k.id,
|
||||||
|
'code': k.code or '',
|
||||||
|
'name': k.name or '',
|
||||||
|
'icon': k.icon or '',
|
||||||
|
'sequence': k.sequence,
|
||||||
|
}
|
||||||
|
for k in Kind.search(
|
||||||
|
[('active', '=', True)], order='sequence, name',
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/fp/simple_recipe/kinds/create',
|
||||||
|
type='jsonrpc', auth='user')
|
||||||
|
def kinds_create(self, name, code=''):
|
||||||
|
"""Sub 14b — Inline create for "+ New kind…" in the library
|
||||||
|
form. Auto-derives a code from the name if blank."""
|
||||||
|
Kind = request.env['fp.step.kind']
|
||||||
|
if not name or not name.strip():
|
||||||
|
return {'ok': False, 'error': 'name_required'}
|
||||||
|
# check_access via create attempt — supervisors+ allowed (ACL).
|
||||||
|
if not code:
|
||||||
|
code = name.strip().lower().replace(' ', '_').replace('/', '_')
|
||||||
|
existing = Kind.search([('code', '=', code)], limit=1)
|
||||||
|
if existing:
|
||||||
|
return {
|
||||||
|
'ok': True, 'id': existing.id,
|
||||||
|
'name': existing.name, 'code': existing.code,
|
||||||
|
'duplicate': True,
|
||||||
|
}
|
||||||
|
rec = Kind.create({
|
||||||
|
'name': name.strip(),
|
||||||
|
'code': code,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'ok': True, 'id': rec.id,
|
||||||
|
'name': rec.name, 'code': rec.code,
|
||||||
|
'duplicate': False,
|
||||||
|
}
|
||||||
|
|
||||||
@http.route('/fp/simple_recipe/workflow_states/list',
|
@http.route('/fp/simple_recipe/workflow_states/list',
|
||||||
type='jsonrpc', auth='user')
|
type='jsonrpc', auth='user')
|
||||||
def workflow_states_list(self):
|
def workflow_states_list(self):
|
||||||
@@ -457,7 +529,7 @@ class SimpleRecipeController(http.Controller):
|
|||||||
node.check_access('write')
|
node.check_access('write')
|
||||||
allowed = {
|
allowed = {
|
||||||
'name', 'description', 'icon',
|
'name', 'description', 'icon',
|
||||||
'default_kind',
|
'kind_id', # Sub 14b — replaces default_kind
|
||||||
'requires_signoff', 'requires_predecessor_done',
|
'requires_signoff', 'requires_predecessor_done',
|
||||||
'parallel_start', # Sub 13
|
'parallel_start', # Sub 13
|
||||||
'triggers_workflow_state_id', # Sub 14
|
'triggers_workflow_state_id', # Sub 14
|
||||||
@@ -467,6 +539,11 @@ class SimpleRecipeController(http.Controller):
|
|||||||
'collect_measurements',
|
'collect_measurements',
|
||||||
}
|
}
|
||||||
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
|
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
|
||||||
|
# Back-compat: accept default_kind (string code) and resolve.
|
||||||
|
if 'kind_id' not in clean and (vals or {}).get('default_kind'):
|
||||||
|
clean['kind_id'] = self._resolve_kind_id_from_code(
|
||||||
|
vals['default_kind'],
|
||||||
|
)
|
||||||
if clean:
|
if clean:
|
||||||
node.write(clean)
|
node.write(clean)
|
||||||
return {'ok': True}
|
return {'ok': True}
|
||||||
|
|||||||
928
fusion_plating/fusion_plating/data/fp_step_kind_data.xml
Normal file
928
fusion_plating/fusion_plating/data/fp_step_kind_data.xml
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- 24 seeded Step Kinds — XML IDs use the original Selection
|
||||||
|
keys so post-migrate can map old default_kind = 'cleaning'
|
||||||
|
to env.ref('fusion_plating.step_kind_cleaning').
|
||||||
|
|
||||||
|
noupdate=1 so user edits to defaults survive `-u`. -->
|
||||||
|
|
||||||
|
<record id="step_kind_receiving" model="fp.step.kind">
|
||||||
|
<field name="code">receiving</field>
|
||||||
|
<field name="name">Receiving / Incoming Inspection</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="icon">fa-truck</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_contract_review" model="fp.step.kind">
|
||||||
|
<field name="code">contract_review</field>
|
||||||
|
<field name="name">Contract Review (QA-005)</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="icon">fa-file-text-o</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_racking" model="fp.step.kind">
|
||||||
|
<field name="code">racking</field>
|
||||||
|
<field name="name">Racking</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="icon">fa-server</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_mask" model="fp.step.kind">
|
||||||
|
<field name="code">mask</field>
|
||||||
|
<field name="name">Masking</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="icon">fa-eye-slash</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_cleaning" model="fp.step.kind">
|
||||||
|
<field name="code">cleaning</field>
|
||||||
|
<field name="name">Cleaning</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="icon">fa-tint</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_electroclean" model="fp.step.kind">
|
||||||
|
<field name="code">electroclean</field>
|
||||||
|
<field name="name">Electroclean</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="icon">fa-bolt</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_etch" model="fp.step.kind">
|
||||||
|
<field name="code">etch</field>
|
||||||
|
<field name="name">Etch / Activation</field>
|
||||||
|
<field name="sequence">70</field>
|
||||||
|
<field name="icon">fa-flask</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_rinse" model="fp.step.kind">
|
||||||
|
<field name="code">rinse</field>
|
||||||
|
<field name="name">Rinse</field>
|
||||||
|
<field name="sequence">80</field>
|
||||||
|
<field name="icon">fa-tint</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_strike" model="fp.step.kind">
|
||||||
|
<field name="code">strike</field>
|
||||||
|
<field name="name">Strike (Wood's Nickel / Activation)</field>
|
||||||
|
<field name="sequence">90</field>
|
||||||
|
<field name="icon">fa-bolt</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_plate" model="fp.step.kind">
|
||||||
|
<field name="code">plate</field>
|
||||||
|
<field name="name">Plating</field>
|
||||||
|
<field name="sequence">100</field>
|
||||||
|
<field name="icon">fa-shield</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_replenishment" model="fp.step.kind">
|
||||||
|
<field name="code">replenishment</field>
|
||||||
|
<field name="name">Tank Replenishment</field>
|
||||||
|
<field name="sequence">110</field>
|
||||||
|
<field name="icon">fa-plus-circle</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_wbf_test" model="fp.step.kind">
|
||||||
|
<field name="code">wbf_test</field>
|
||||||
|
<field name="name">Water Break Free Test</field>
|
||||||
|
<field name="sequence">120</field>
|
||||||
|
<field name="icon">fa-check-square-o</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_dry" model="fp.step.kind">
|
||||||
|
<field name="code">dry</field>
|
||||||
|
<field name="name">Drying</field>
|
||||||
|
<field name="sequence">130</field>
|
||||||
|
<field name="icon">fa-sun-o</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_bake" model="fp.step.kind">
|
||||||
|
<field name="code">bake</field>
|
||||||
|
<field name="name">Bake (HE Relief / Stress Relief)</field>
|
||||||
|
<field name="sequence">140</field>
|
||||||
|
<field name="icon">fa-fire</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_demask" model="fp.step.kind">
|
||||||
|
<field name="code">demask</field>
|
||||||
|
<field name="name">De-Masking</field>
|
||||||
|
<field name="sequence">150</field>
|
||||||
|
<field name="icon">fa-eye</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_derack" model="fp.step.kind">
|
||||||
|
<field name="code">derack</field>
|
||||||
|
<field name="name">De-Racking</field>
|
||||||
|
<field name="sequence">160</field>
|
||||||
|
<field name="icon">fa-server</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_inspect" model="fp.step.kind">
|
||||||
|
<field name="code">inspect</field>
|
||||||
|
<field name="name">Inspection</field>
|
||||||
|
<field name="sequence">170</field>
|
||||||
|
<field name="icon">fa-search</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_hardness_test" model="fp.step.kind">
|
||||||
|
<field name="code">hardness_test</field>
|
||||||
|
<field name="name">Hardness Test (HV / HK / HRC)</field>
|
||||||
|
<field name="sequence">180</field>
|
||||||
|
<field name="icon">fa-tachometer</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_adhesion_test" model="fp.step.kind">
|
||||||
|
<field name="code">adhesion_test</field>
|
||||||
|
<field name="name">Adhesion Test</field>
|
||||||
|
<field name="sequence">190</field>
|
||||||
|
<field name="icon">fa-link</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_salt_spray" model="fp.step.kind">
|
||||||
|
<field name="code">salt_spray</field>
|
||||||
|
<field name="name">Salt Spray / Corrosion Test</field>
|
||||||
|
<field name="sequence">200</field>
|
||||||
|
<field name="icon">fa-cloud</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_final_inspect" model="fp.step.kind">
|
||||||
|
<field name="code">final_inspect</field>
|
||||||
|
<field name="name">Final Inspection</field>
|
||||||
|
<field name="sequence">210</field>
|
||||||
|
<field name="icon">fa-check-circle</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_packaging" model="fp.step.kind">
|
||||||
|
<field name="code">packaging</field>
|
||||||
|
<field name="name">Packaging / Pre-Ship</field>
|
||||||
|
<field name="sequence">220</field>
|
||||||
|
<field name="icon">fa-archive</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_ship" model="fp.step.kind">
|
||||||
|
<field name="code">ship</field>
|
||||||
|
<field name="name">Shipping</field>
|
||||||
|
<field name="sequence">230</field>
|
||||||
|
<field name="icon">fa-paper-plane</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_gating" model="fp.step.kind">
|
||||||
|
<field name="code">gating</field>
|
||||||
|
<field name="name">Gating</field>
|
||||||
|
<field name="sequence">240</field>
|
||||||
|
<field name="icon">fa-pause-circle</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
Default inputs per kind — 1:1 port of DEFAULT_INPUTS_BY_KIND
|
||||||
|
============================================================ -->
|
||||||
|
|
||||||
|
<!-- receiving -->
|
||||||
|
<record id="step_kind_input_receiving_qty_received" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_receiving"/>
|
||||||
|
<field name="name">Qty Received</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="target_unit">each</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_receiving_qty_rejected" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_receiving"/>
|
||||||
|
<field name="name">Qty Rejected</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="target_unit">each</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_receiving_po_verified" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_receiving"/>
|
||||||
|
<field name="name">Customer PO# Verified</field>
|
||||||
|
<field name="input_type">boolean</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_receiving_packing_slip" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_receiving"/>
|
||||||
|
<field name="name">Packing Slip #</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_receiving_condition" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_receiving"/>
|
||||||
|
<field name="name">Condition Notes</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_receiving_damage_photo" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_receiving"/>
|
||||||
|
<field name="name">Damage Photo</field>
|
||||||
|
<field name="input_type">photo</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_receiving_inspector_init" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_receiving"/>
|
||||||
|
<field name="name">Inspector Initials</field>
|
||||||
|
<field name="input_type">signature</field>
|
||||||
|
<field name="sequence">70</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- cleaning -->
|
||||||
|
<record id="step_kind_input_cleaning_actual_time" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_cleaning"/>
|
||||||
|
<field name="name">Actual Time</field>
|
||||||
|
<field name="input_type">time_seconds</field>
|
||||||
|
<field name="target_unit">s</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_cleaning_actual_temp" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_cleaning"/>
|
||||||
|
<field name="name">Actual Temperature</field>
|
||||||
|
<field name="input_type">temperature</field>
|
||||||
|
<field name="target_unit">f</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_cleaning_bath_id" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_cleaning"/>
|
||||||
|
<field name="name">Bath ID</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_cleaning_us_on" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_cleaning"/>
|
||||||
|
<field name="name">Ultrasonic On</field>
|
||||||
|
<field name="input_type">boolean</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_cleaning_titration" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_cleaning"/>
|
||||||
|
<field name="name">Titration Done</field>
|
||||||
|
<field name="input_type">boolean</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- electroclean -->
|
||||||
|
<record id="step_kind_input_eclean_time" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_electroclean"/>
|
||||||
|
<field name="name">Actual Time</field>
|
||||||
|
<field name="input_type">time_seconds</field>
|
||||||
|
<field name="target_unit">s</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_eclean_temp" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_electroclean"/>
|
||||||
|
<field name="name">Actual Temperature</field>
|
||||||
|
<field name="input_type">temperature</field>
|
||||||
|
<field name="target_unit">f</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_eclean_amp" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_electroclean"/>
|
||||||
|
<field name="name">Amperage</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">A</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_eclean_volt" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_electroclean"/>
|
||||||
|
<field name="name">Voltage</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">V</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_eclean_cd" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_electroclean"/>
|
||||||
|
<field name="name">Current Density</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">ASF (A per sq ft)</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_eclean_pol" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_electroclean"/>
|
||||||
|
<field name="name">Polarity</field>
|
||||||
|
<field name="input_type">selection</field>
|
||||||
|
<field name="selection_options">anodic,cathodic,periodic</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_eclean_bath" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_electroclean"/>
|
||||||
|
<field name="name">Bath ID</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">70</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- etch -->
|
||||||
|
<record id="step_kind_input_etch_time" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_etch"/>
|
||||||
|
<field name="name">Actual Time</field>
|
||||||
|
<field name="input_type">time_seconds</field>
|
||||||
|
<field name="target_unit">s</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_etch_temp" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_etch"/>
|
||||||
|
<field name="name">Actual Temperature</field>
|
||||||
|
<field name="input_type">temperature</field>
|
||||||
|
<field name="target_unit">f</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_etch_acid" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_etch"/>
|
||||||
|
<field name="name">Acid Concentration</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">% or g/L</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_etch_bath" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_etch"/>
|
||||||
|
<field name="name">Bath ID</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_etch_he" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_etch"/>
|
||||||
|
<field name="name">HE Risk Flag</field>
|
||||||
|
<field name="input_type">boolean</field>
|
||||||
|
<field name="hint">Hydrogen Embrittlement risk for high-strength steel</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- rinse -->
|
||||||
|
<record id="step_kind_input_rinse_type" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_rinse"/>
|
||||||
|
<field name="name">Rinse Type</field>
|
||||||
|
<field name="input_type">selection</field>
|
||||||
|
<field name="selection_options">cascade,spray,DI,city</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_rinse_cond" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_rinse"/>
|
||||||
|
<field name="name">Conductivity</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">µS/cm — required for DI rinses</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_rinse_time" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_rinse"/>
|
||||||
|
<field name="name">Actual Time</field>
|
||||||
|
<field name="input_type">time_seconds</field>
|
||||||
|
<field name="target_unit">s</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- strike -->
|
||||||
|
<record id="step_kind_input_strike_time" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_strike"/>
|
||||||
|
<field name="name">Actual Time</field>
|
||||||
|
<field name="input_type">time_seconds</field>
|
||||||
|
<field name="target_unit">s</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_strike_temp" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_strike"/>
|
||||||
|
<field name="name">Actual Temperature</field>
|
||||||
|
<field name="input_type">temperature</field>
|
||||||
|
<field name="target_unit">f</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_strike_amp" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_strike"/>
|
||||||
|
<field name="name">Amperage</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">A</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_strike_volt" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_strike"/>
|
||||||
|
<field name="name">Voltage</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">V</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_strike_cd" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_strike"/>
|
||||||
|
<field name="name">Current Density</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">ASF</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_strike_bath" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_strike"/>
|
||||||
|
<field name="name">Bath ID</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- plate -->
|
||||||
|
<record id="step_kind_input_plate_time" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_plate"/>
|
||||||
|
<field name="name">Actual Time</field>
|
||||||
|
<field name="input_type">time_hms</field>
|
||||||
|
<field name="target_unit">min</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_plate_temp" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_plate"/>
|
||||||
|
<field name="name">Actual Temperature</field>
|
||||||
|
<field name="input_type">temperature</field>
|
||||||
|
<field name="target_unit">f</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_plate_bath" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_plate"/>
|
||||||
|
<field name="name">Bath ID</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_plate_ph" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_plate"/>
|
||||||
|
<field name="name">pH</field>
|
||||||
|
<field name="input_type">ph</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_plate_conc" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_plate"/>
|
||||||
|
<field name="name">Bath Concentration</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">g/L</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_plate_cd" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_plate"/>
|
||||||
|
<field name="name">Current Density</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">ASF — electroplate only</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_plate_thick" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_plate"/>
|
||||||
|
<field name="name">Plating Thickness</field>
|
||||||
|
<field name="input_type">multi_point_thickness</field>
|
||||||
|
<field name="target_unit">in</field>
|
||||||
|
<field name="sequence">70</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- replenishment -->
|
||||||
|
<record id="step_kind_input_replen_bath" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_replenishment"/>
|
||||||
|
<field name="name">Bath ID</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_replen_chem" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_replenishment"/>
|
||||||
|
<field name="name">Chemistry Added</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="hint">name + amount, e.g. "Nickel sulfamate 500mL"</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_replen_ph_b" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_replenishment"/>
|
||||||
|
<field name="name">pH Before</field>
|
||||||
|
<field name="input_type">ph</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_replen_ph_a" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_replenishment"/>
|
||||||
|
<field name="name">pH After</field>
|
||||||
|
<field name="input_type">ph</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_replen_conc_b" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_replenishment"/>
|
||||||
|
<field name="name">Concentration Before</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_replen_conc_a" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_replenishment"/>
|
||||||
|
<field name="name">Concentration After</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_replen_op" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_replenishment"/>
|
||||||
|
<field name="name">Operator Initials</field>
|
||||||
|
<field name="input_type">signature</field>
|
||||||
|
<field name="sequence">70</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- wbf_test -->
|
||||||
|
<record id="step_kind_input_wbf_result" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_wbf_test"/>
|
||||||
|
<field name="name">Result</field>
|
||||||
|
<field name="input_type">pass_fail</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_wbf_retest" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_wbf_test"/>
|
||||||
|
<field name="name">Retest Count</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_wbf_photo" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_wbf_test"/>
|
||||||
|
<field name="name">Photo on FAIL</field>
|
||||||
|
<field name="input_type">photo</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- dry -->
|
||||||
|
<record id="step_kind_input_dry_method" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_dry"/>
|
||||||
|
<field name="name">Dry Method</field>
|
||||||
|
<field name="input_type">selection</field>
|
||||||
|
<field name="selection_options">hot air,oven,spin</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_dry_time" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_dry"/>
|
||||||
|
<field name="name">Actual Time</field>
|
||||||
|
<field name="input_type">time_seconds</field>
|
||||||
|
<field name="target_unit">s</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_dry_temp" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_dry"/>
|
||||||
|
<field name="name">Actual Temperature</field>
|
||||||
|
<field name="input_type">temperature</field>
|
||||||
|
<field name="target_unit">f</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- bake -->
|
||||||
|
<record id="step_kind_input_bake_in" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_bake"/>
|
||||||
|
<field name="name">Time In</field>
|
||||||
|
<field name="input_type">date</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_bake_out" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_bake"/>
|
||||||
|
<field name="name">Time Out</field>
|
||||||
|
<field name="input_type">date</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_bake_temp" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_bake"/>
|
||||||
|
<field name="name">Actual Temperature</field>
|
||||||
|
<field name="input_type">temperature</field>
|
||||||
|
<field name="target_unit">f</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_bake_oven" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_bake"/>
|
||||||
|
<field name="name">Oven ID</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_bake_chart" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_bake"/>
|
||||||
|
<field name="name">Chart Recorder File</field>
|
||||||
|
<field name="input_type">photo</field>
|
||||||
|
<field name="hint">Attach AMS-2759 chart-recorder file</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- racking -->
|
||||||
|
<record id="step_kind_input_racking_qty" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_racking"/>
|
||||||
|
<field name="name">Actual Qty</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="target_unit">each</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_racking_rack" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_racking"/>
|
||||||
|
<field name="name">Rack ID</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_racking_mask" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_racking"/>
|
||||||
|
<field name="name">Masking Applied</field>
|
||||||
|
<field name="input_type">boolean</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_racking_photo" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_racking"/>
|
||||||
|
<field name="name">Photo of Racked Load</field>
|
||||||
|
<field name="input_type">photo</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- derack -->
|
||||||
|
<record id="step_kind_input_derack_qty" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_derack"/>
|
||||||
|
<field name="name">Actual Qty</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="target_unit">each</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_derack_method" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_derack"/>
|
||||||
|
<field name="name">Mask Removal Method</field>
|
||||||
|
<field name="input_type">selection</field>
|
||||||
|
<field name="selection_options">mechanical,solvent,thermal,not applicable</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_derack_residue" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_derack"/>
|
||||||
|
<field name="name">Residue Check</field>
|
||||||
|
<field name="input_type">pass_fail</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- mask -->
|
||||||
|
<record id="step_kind_input_mask_qty" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_mask"/>
|
||||||
|
<field name="name">Actual Qty</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="target_unit">each</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_mask_material" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_mask"/>
|
||||||
|
<field name="name">Mask Material</field>
|
||||||
|
<field name="input_type">selection</field>
|
||||||
|
<field name="selection_options">Microshield,latex tape,vinyl plugs,wax,other</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_mask_photo" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_mask"/>
|
||||||
|
<field name="name">Photo of Masked Parts</field>
|
||||||
|
<field name="input_type">photo</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- demask -->
|
||||||
|
<record id="step_kind_input_demask_residue" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_demask"/>
|
||||||
|
<field name="name">Residue Check</field>
|
||||||
|
<field name="input_type">pass_fail</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_demask_surface" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_demask"/>
|
||||||
|
<field name="name">Surface Condition</field>
|
||||||
|
<field name="input_type">selection</field>
|
||||||
|
<field name="selection_options">clean,marks,needs rework</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- inspect -->
|
||||||
|
<record id="step_kind_input_inspect_result" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_inspect"/>
|
||||||
|
<field name="name">Result</field>
|
||||||
|
<field name="input_type">pass_fail</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_inspect_defect" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_inspect"/>
|
||||||
|
<field name="name">Defect Type</field>
|
||||||
|
<field name="input_type">selection</field>
|
||||||
|
<field name="selection_options">pitting,burn,blister,peel,missing coverage,none</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_inspect_thick" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_inspect"/>
|
||||||
|
<field name="name">Thickness Sample</field>
|
||||||
|
<field name="input_type">thickness</field>
|
||||||
|
<field name="target_unit">in</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_inspect_photo" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_inspect"/>
|
||||||
|
<field name="name">Photo</field>
|
||||||
|
<field name="input_type">photo</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_inspect_sig" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_inspect"/>
|
||||||
|
<field name="name">Inspector Signature</field>
|
||||||
|
<field name="input_type">signature</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- hardness_test -->
|
||||||
|
<record id="step_kind_input_hard_load" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_hardness_test"/>
|
||||||
|
<field name="name">Test Load</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">gf</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_hard_readings" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_hardness_test"/>
|
||||||
|
<field name="name">Readings (HV/HK/HRC)</field>
|
||||||
|
<field name="input_type">multi_point_thickness</field>
|
||||||
|
<field name="hint">Three indents minimum</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_hard_eq" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_hardness_test"/>
|
||||||
|
<field name="name">Equipment ID</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_hard_cal" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_hardness_test"/>
|
||||||
|
<field name="name">Last Calibration Date</field>
|
||||||
|
<field name="input_type">date</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- adhesion_test -->
|
||||||
|
<record id="step_kind_input_adh_method" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_adhesion_test"/>
|
||||||
|
<field name="name">Test Method</field>
|
||||||
|
<field name="input_type">selection</field>
|
||||||
|
<field name="selection_options">bend,tape,burnish,file</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_adh_result" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_adhesion_test"/>
|
||||||
|
<field name="name">Result</field>
|
||||||
|
<field name="input_type">pass_fail</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_adh_photo" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_adhesion_test"/>
|
||||||
|
<field name="name">Photo of Coupon</field>
|
||||||
|
<field name="input_type">photo</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- salt_spray -->
|
||||||
|
<record id="step_kind_input_salt_dur" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_salt_spray"/>
|
||||||
|
<field name="name">Test Duration</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">hours</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_salt_result" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_salt_spray"/>
|
||||||
|
<field name="name">Result</field>
|
||||||
|
<field name="input_type">pass_fail</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_salt_red" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_salt_spray"/>
|
||||||
|
<field name="name">Red Rust %</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_salt_white" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_salt_spray"/>
|
||||||
|
<field name="name">White Corrosion %</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_salt_lab" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_salt_spray"/>
|
||||||
|
<field name="name">Lab Report</field>
|
||||||
|
<field name="input_type">photo</field>
|
||||||
|
<field name="hint">Attach scanned lab report</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- final_inspect -->
|
||||||
|
<record id="step_kind_input_fin_count" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_final_inspect"/>
|
||||||
|
<field name="name">Outgoing Part Count Verified</field>
|
||||||
|
<field name="input_type">boolean</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_fin_qty_acc" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_final_inspect"/>
|
||||||
|
<field name="name">Qty Accepted</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="target_unit">each</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_fin_qty_rej" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_final_inspect"/>
|
||||||
|
<field name="name">Qty Rejected</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="target_unit">each</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_fin_defect" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_final_inspect"/>
|
||||||
|
<field name="name">Defect Categorization</field>
|
||||||
|
<field name="input_type">selection</field>
|
||||||
|
<field name="selection_options">pitting,burn,blister,peel,missing coverage,dimensional,none</field>
|
||||||
|
<field name="sequence">35</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_fin_thick" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_final_inspect"/>
|
||||||
|
<field name="name">Actual Coating Thickness</field>
|
||||||
|
<field name="input_type">multi_point_thickness</field>
|
||||||
|
<field name="target_unit">in</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_fin_dim" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_final_inspect"/>
|
||||||
|
<field name="name">Dimensional Verification</field>
|
||||||
|
<field name="input_type">pass_fail</field>
|
||||||
|
<field name="sequence">45</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_fin_ra" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_final_inspect"/>
|
||||||
|
<field name="name">Surface Finish (Ra)</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="hint">µin</field>
|
||||||
|
<field name="sequence">47</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_fin_pass" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_final_inspect"/>
|
||||||
|
<field name="name">Pass/Fail</field>
|
||||||
|
<field name="input_type">pass_fail</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_fin_sig" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_final_inspect"/>
|
||||||
|
<field name="name">Inspector Signature</field>
|
||||||
|
<field name="input_type">signature</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- packaging -->
|
||||||
|
<record id="step_kind_input_pkg_type" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_packaging"/>
|
||||||
|
<field name="name">Packaging Type</field>
|
||||||
|
<field name="input_type">selection</field>
|
||||||
|
<field name="selection_options">VCI bag,bubble wrap,separator paper,custom crate,other</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_pkg_qty" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_packaging"/>
|
||||||
|
<field name="name">Qty Per Package</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="target_unit">each</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_pkg_count" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_packaging"/>
|
||||||
|
<field name="name">Package Count</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_pkg_cert" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_packaging"/>
|
||||||
|
<field name="name">Cert Package Included</field>
|
||||||
|
<field name="input_type">boolean</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_pkg_cust" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_packaging"/>
|
||||||
|
<field name="name">Customer-Supplied Packaging</field>
|
||||||
|
<field name="input_type">boolean</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ship -->
|
||||||
|
<record id="step_kind_input_ship_qty" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_ship"/>
|
||||||
|
<field name="name">Outgoing Qty</field>
|
||||||
|
<field name="input_type">number</field>
|
||||||
|
<field name="target_unit">each</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_ship_carrier" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_ship"/>
|
||||||
|
<field name="name">Carrier</field>
|
||||||
|
<field name="input_type">selection</field>
|
||||||
|
<field name="selection_options">UPS,FedEx,Purolator,Customer Pickup,Other</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_ship_track" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_ship"/>
|
||||||
|
<field name="name">Tracking #</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_ship_bol" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_ship"/>
|
||||||
|
<field name="name">BoL #</field>
|
||||||
|
<field name="input_type">text</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_ship_photo" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_ship"/>
|
||||||
|
<field name="name">Photo of Sealed Shipment</field>
|
||||||
|
<field name="input_type">photo</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- contract_review -->
|
||||||
|
<record id="step_kind_input_cr_init" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_contract_review"/>
|
||||||
|
<field name="name">Reviewer Initials</field>
|
||||||
|
<field name="input_type">signature</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_cr_date" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_contract_review"/>
|
||||||
|
<field name="name">Date Reviewed</field>
|
||||||
|
<field name="input_type">date</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_input_cr_qa" model="fp.step.kind.default.input">
|
||||||
|
<field name="kind_id" ref="step_kind_contract_review"/>
|
||||||
|
<field name="name">QA-005 Approved</field>
|
||||||
|
<field name="input_type">pass_fail</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- gating: intentionally no default inputs -->
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<record id="fp_step_template_receiving_std" model="fp.step.template">
|
<record id="fp_step_template_receiving_std" model="fp.step.template">
|
||||||
<field name="name">Incoming Inspection (Standard)</field>
|
<field name="name">Incoming Inspection (Standard)</field>
|
||||||
<field name="code">RECV_STD</field>
|
<field name="code">RECV_STD</field>
|
||||||
<field name="default_kind">receiving</field>
|
<field name="kind_id" ref="step_kind_receiving"/>
|
||||||
<field name="icon">fa-inbox</field>
|
<field name="icon">fa-inbox</field>
|
||||||
<field name="description"><![CDATA[
|
<field name="description"><![CDATA[
|
||||||
<p>Verify quantity received against packing slip. Visually inspect
|
<p>Verify quantity received against packing slip. Visually inspect
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<record id="fp_step_template_electroclean_std" model="fp.step.template">
|
<record id="fp_step_template_electroclean_std" model="fp.step.template">
|
||||||
<field name="name">Electroclean (Standard)</field>
|
<field name="name">Electroclean (Standard)</field>
|
||||||
<field name="code">ELEC_CLEAN_STD</field>
|
<field name="code">ELEC_CLEAN_STD</field>
|
||||||
<field name="default_kind">electroclean</field>
|
<field name="kind_id" ref="step_kind_electroclean"/>
|
||||||
<field name="icon">fa-bolt</field>
|
<field name="icon">fa-bolt</field>
|
||||||
<field name="description"><![CDATA[
|
<field name="description"><![CDATA[
|
||||||
<p>Submerge rack and energise. Record actual amperage, voltage,
|
<p>Submerge rack and energise. Record actual amperage, voltage,
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<record id="fp_step_template_strike_std" model="fp.step.template">
|
<record id="fp_step_template_strike_std" model="fp.step.template">
|
||||||
<field name="name">Wood's Nickel Strike (Standard)</field>
|
<field name="name">Wood's Nickel Strike (Standard)</field>
|
||||||
<field name="code">STRIKE_STD</field>
|
<field name="code">STRIKE_STD</field>
|
||||||
<field name="default_kind">strike</field>
|
<field name="kind_id" ref="step_kind_strike"/>
|
||||||
<field name="icon">fa-flash</field>
|
<field name="icon">fa-flash</field>
|
||||||
<field name="description"><![CDATA[
|
<field name="description"><![CDATA[
|
||||||
<p>Apply thin nickel strike to ensure adhesion before main plate.
|
<p>Apply thin nickel strike to ensure adhesion before main plate.
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<record id="fp_step_template_salt_spray_std" model="fp.step.template">
|
<record id="fp_step_template_salt_spray_std" model="fp.step.template">
|
||||||
<field name="name">Salt Spray Test (ASTM B117)</field>
|
<field name="name">Salt Spray Test (ASTM B117)</field>
|
||||||
<field name="code">SALT_SPRAY_STD</field>
|
<field name="code">SALT_SPRAY_STD</field>
|
||||||
<field name="default_kind">salt_spray</field>
|
<field name="kind_id" ref="step_kind_salt_spray"/>
|
||||||
<field name="icon">fa-tint</field>
|
<field name="icon">fa-tint</field>
|
||||||
<field name="description"><![CDATA[
|
<field name="description"><![CDATA[
|
||||||
<p>Submit test panel to salt spray cabinet for the specified
|
<p>Submit test panel to salt spray cabinet for the specified
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<record id="fp_step_template_adhesion_std" model="fp.step.template">
|
<record id="fp_step_template_adhesion_std" model="fp.step.template">
|
||||||
<field name="name">Adhesion Test (Bend / Tape)</field>
|
<field name="name">Adhesion Test (Bend / Tape)</field>
|
||||||
<field name="code">ADHESION_STD</field>
|
<field name="code">ADHESION_STD</field>
|
||||||
<field name="default_kind">adhesion_test</field>
|
<field name="kind_id" ref="step_kind_adhesion_test"/>
|
||||||
<field name="icon">fa-link</field>
|
<field name="icon">fa-link</field>
|
||||||
<field name="description"><![CDATA[
|
<field name="description"><![CDATA[
|
||||||
<p>Perform adhesion test per spec (bend, tape, burnish, or file).
|
<p>Perform adhesion test per spec (bend, tape, burnish, or file).
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
<record id="fp_step_template_hardness_std" model="fp.step.template">
|
<record id="fp_step_template_hardness_std" model="fp.step.template">
|
||||||
<field name="name">Microhardness Test</field>
|
<field name="name">Microhardness Test</field>
|
||||||
<field name="code">HARDNESS_STD</field>
|
<field name="code">HARDNESS_STD</field>
|
||||||
<field name="default_kind">hardness_test</field>
|
<field name="kind_id" ref="step_kind_hardness_test"/>
|
||||||
<field name="icon">fa-cube</field>
|
<field name="icon">fa-cube</field>
|
||||||
<field name="description"><![CDATA[
|
<field name="description"><![CDATA[
|
||||||
<p>Take three indentations minimum on the test coupon. Record
|
<p>Take three indentations minimum on the test coupon. Record
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<record id="fp_step_template_packaging_std" model="fp.step.template">
|
<record id="fp_step_template_packaging_std" model="fp.step.template">
|
||||||
<field name="name">Packaging (Standard)</field>
|
<field name="name">Packaging (Standard)</field>
|
||||||
<field name="code">PKG_STD</field>
|
<field name="code">PKG_STD</field>
|
||||||
<field name="default_kind">packaging</field>
|
<field name="kind_id" ref="step_kind_packaging"/>
|
||||||
<field name="icon">fa-archive</field>
|
<field name="icon">fa-archive</field>
|
||||||
<field name="description"><![CDATA[
|
<field name="description"><![CDATA[
|
||||||
<p>Wrap parts per customer spec (VCI bag, bubble wrap, separator
|
<p>Wrap parts per customer spec (VCI bag, bubble wrap, separator
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
<record id="fp_step_template_replenishment_std" model="fp.step.template">
|
<record id="fp_step_template_replenishment_std" model="fp.step.template">
|
||||||
<field name="name">Tank Replenishment</field>
|
<field name="name">Tank Replenishment</field>
|
||||||
<field name="code">REPL_STD</field>
|
<field name="code">REPL_STD</field>
|
||||||
<field name="default_kind">replenishment</field>
|
<field name="kind_id" ref="step_kind_replenishment"/>
|
||||||
<field name="icon">fa-flask</field>
|
<field name="icon">fa-flask</field>
|
||||||
<field name="description"><![CDATA[
|
<field name="description"><![CDATA[
|
||||||
<p>Mid-shift bath top-up. Record bath ID, chemistry added (name
|
<p>Mid-shift bath top-up. Record bath ID, chemistry added (name
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
"""19.0.18.13.0 — Backfill kind_id from legacy default_kind text values.
|
||||||
|
|
||||||
|
Sub 14b — `default_kind` was a Selection on fp.step.template and
|
||||||
|
fusion.plating.process.node. It is now a stored related Char that
|
||||||
|
reads from kind_id.code on a new fp.step.kind catalog.
|
||||||
|
|
||||||
|
When a Selection field is converted into a Many2one in code, Odoo's
|
||||||
|
ORM does NOT auto-migrate the data — the new column starts empty. This
|
||||||
|
script reads the (still-present) text values out of the OLD `default_kind`
|
||||||
|
column and points kind_id at the seeded fp.step.kind record whose code
|
||||||
|
matches.
|
||||||
|
|
||||||
|
Idempotent — running it twice is a no-op.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
"""Run by the Odoo migration framework on -u to 19.0.18.13.0."""
|
||||||
|
# ---- Build code → kind_id map from seeded data --------------------
|
||||||
|
cr.execute("SELECT id, code FROM fp_step_kind")
|
||||||
|
code_to_id = {code: kid for kid, code in cr.fetchall()}
|
||||||
|
if not code_to_id:
|
||||||
|
_logger.warning(
|
||||||
|
'19.0.18.13.0: fp_step_kind is empty — seed data did not '
|
||||||
|
'load before post-migrate. Skipping backfill.',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---- 1. fp.step.template.kind_id ----------------------------------
|
||||||
|
_backfill(
|
||||||
|
cr,
|
||||||
|
table='fp_step_template',
|
||||||
|
text_col='x_default_kind_legacy',
|
||||||
|
m2o_col='kind_id',
|
||||||
|
code_to_id=code_to_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 2. fusion.plating.process.node.kind_id -----------------------
|
||||||
|
_backfill(
|
||||||
|
cr,
|
||||||
|
table='fusion_plating_process_node',
|
||||||
|
text_col='x_default_kind_legacy',
|
||||||
|
m2o_col='kind_id',
|
||||||
|
code_to_id=code_to_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 3. Force recompute of stored related default_kind --------------
|
||||||
|
# Now that kind_id is populated, the related Char will read the right
|
||||||
|
# value on next access. Trigger an explicit recompute via SQL so the
|
||||||
|
# column reflects current state immediately (faster than waiting for
|
||||||
|
# ORM to do it lazily on each row).
|
||||||
|
for table in ('fp_step_template', 'fusion_plating_process_node'):
|
||||||
|
cr.execute(f"""
|
||||||
|
UPDATE {table} t
|
||||||
|
SET default_kind = k.code
|
||||||
|
FROM fp_step_kind k
|
||||||
|
WHERE t.kind_id = k.id
|
||||||
|
""")
|
||||||
|
_logger.info(
|
||||||
|
'19.0.18.13.0: synced default_kind from kind_id.code on '
|
||||||
|
'%s rows of %s', cr.rowcount, table,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill(cr, table, text_col, m2o_col, code_to_id):
|
||||||
|
"""Generic backfill helper. Reads `text_col` text values, updates
|
||||||
|
`m2o_col` per row using the supplied code→id lookup table.
|
||||||
|
"""
|
||||||
|
# Defensive — if either column is missing (fresh install, never had
|
||||||
|
# default_kind data) skip silently.
|
||||||
|
cr.execute("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = %s AND column_name IN (%s, %s)
|
||||||
|
""", (table, text_col, m2o_col))
|
||||||
|
cols = {row[0] for row in cr.fetchall()}
|
||||||
|
if text_col not in cols or m2o_col not in cols:
|
||||||
|
_logger.info(
|
||||||
|
'19.0.18.13.0: %s missing %s or %s, skipping backfill',
|
||||||
|
table, text_col, m2o_col,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pull every row with a non-null default_kind that doesn't yet have
|
||||||
|
# a kind_id — leaves manually-edited rows alone if migration is
|
||||||
|
# rerun.
|
||||||
|
cr.execute(f"""
|
||||||
|
SELECT id, {text_col} FROM {table}
|
||||||
|
WHERE {text_col} IS NOT NULL AND {m2o_col} IS NULL
|
||||||
|
""")
|
||||||
|
rows = cr.fetchall()
|
||||||
|
if not rows:
|
||||||
|
_logger.info(
|
||||||
|
'19.0.18.13.0: %s has no rows needing backfill', table,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
by_kind = {}
|
||||||
|
for rec_id, code in rows:
|
||||||
|
kid = code_to_id.get(code)
|
||||||
|
if not kid:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
by_kind.setdefault(kid, []).append(rec_id)
|
||||||
|
|
||||||
|
for kid, ids in by_kind.items():
|
||||||
|
cr.execute(
|
||||||
|
f"UPDATE {table} SET {m2o_col} = %s WHERE id = ANY(%s)",
|
||||||
|
(kid, ids),
|
||||||
|
)
|
||||||
|
updated += len(ids)
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
'19.0.18.13.0: backfilled %s.%s on %s rows (%s skipped — code '
|
||||||
|
'not found in fp_step_kind seed data)',
|
||||||
|
table, m2o_col, updated, skipped,
|
||||||
|
)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
"""19.0.18.13.0 — Snapshot legacy default_kind values BEFORE the field
|
||||||
|
type change wipes them.
|
||||||
|
|
||||||
|
Sub 14b — `default_kind` was a Selection on fp.step.template and
|
||||||
|
fusion.plating.process.node. The new model code defines it as a
|
||||||
|
stored related Char (`related='kind_id.code', store=True`). On first
|
||||||
|
ORM access after upgrade, Odoo recomputes the stored related from
|
||||||
|
kind_id (which is NULL → wipes the column). We must snapshot the
|
||||||
|
original text values now, so post-migrate can read them and resolve
|
||||||
|
kind_id.
|
||||||
|
|
||||||
|
Idempotent — running it twice is a no-op (snapshot column gets
|
||||||
|
re-written with the same data).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
for table in ('fp_step_template', 'fusion_plating_process_node'):
|
||||||
|
_snapshot(cr, table)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(cr, table):
|
||||||
|
cr.execute("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = %s AND column_name = 'default_kind'
|
||||||
|
""", (table,))
|
||||||
|
if not cr.fetchone():
|
||||||
|
_logger.info(
|
||||||
|
'19.0.18.13.0/pre: %s has no default_kind column, skip',
|
||||||
|
table,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add the snapshot column if missing.
|
||||||
|
cr.execute(f"""
|
||||||
|
ALTER TABLE {table}
|
||||||
|
ADD COLUMN IF NOT EXISTS x_default_kind_legacy VARCHAR
|
||||||
|
""")
|
||||||
|
# Copy the live data.
|
||||||
|
cr.execute(f"""
|
||||||
|
UPDATE {table}
|
||||||
|
SET x_default_kind_legacy = default_kind
|
||||||
|
WHERE default_kind IS NOT NULL
|
||||||
|
""")
|
||||||
|
_logger.info(
|
||||||
|
'19.0.18.13.0/pre: snapshotted %s rows from %s.default_kind '
|
||||||
|
'to x_default_kind_legacy',
|
||||||
|
cr.rowcount, table,
|
||||||
|
)
|
||||||
@@ -36,6 +36,7 @@ from . import hr_employee
|
|||||||
from . import fp_process_node_inherit
|
from . import fp_process_node_inherit
|
||||||
|
|
||||||
# Sub 12a — Simple Recipe Editor + Step Library
|
# Sub 12a — Simple Recipe Editor + Step Library
|
||||||
|
from . import fp_step_kind # MUST load before fp_step_template (dependency)
|
||||||
from . import fp_step_template
|
from . import fp_step_template
|
||||||
from . import fp_step_template_input
|
from . import fp_step_template_input
|
||||||
from . import fp_step_template_transition_input
|
from . import fp_step_template_transition_input
|
||||||
|
|||||||
@@ -373,34 +373,16 @@ class FpProcessNode(models.Model):
|
|||||||
string='Requires Transition Form',
|
string='Requires Transition Form',
|
||||||
help='Sub 12b — opens the transition form before Mark Done.',
|
help='Sub 12b — opens the transition form before Mark Done.',
|
||||||
)
|
)
|
||||||
default_kind = fields.Selection(
|
# Sub 14b — User-extensible Step Kinds (was Selection of 24).
|
||||||
[
|
kind_id = fields.Many2one(
|
||||||
('receiving', 'Receiving / Incoming Inspection'),
|
'fp.step.kind', string='Step Kind', ondelete='set null', index=True,
|
||||||
('contract_review', 'Contract Review (QA-005)'),
|
help='Pick from the catalog or create a new kind.',
|
||||||
('racking', 'Racking'),
|
)
|
||||||
('mask', 'Masking'),
|
# Back-compat: code-string accessor that all legacy
|
||||||
('cleaning', 'Cleaning'),
|
# `node.default_kind == "cleaning"` comparisons keep using.
|
||||||
('electroclean', 'Electroclean'),
|
default_kind = fields.Char(
|
||||||
('etch', 'Etch / Activation'),
|
related='kind_id.code', store=True, readonly=True, index=True,
|
||||||
('rinse', 'Rinse'),
|
string='Step Kind Code',
|
||||||
('strike', 'Strike (Wood\'s Nickel / Activation)'),
|
|
||||||
('plate', 'Plating'),
|
|
||||||
('replenishment', 'Tank Replenishment'),
|
|
||||||
('wbf_test', 'Water Break Free Test'),
|
|
||||||
('dry', 'Drying'),
|
|
||||||
('bake', 'Bake (HE Relief / Stress Relief)'),
|
|
||||||
('demask', 'De-Masking'),
|
|
||||||
('derack', 'De-Racking'),
|
|
||||||
('inspect', 'Inspection'),
|
|
||||||
('hardness_test', 'Hardness Test (HV / HK / HRC)'),
|
|
||||||
('adhesion_test', 'Adhesion Test'),
|
|
||||||
('salt_spray', 'Salt Spray / Corrosion Test'),
|
|
||||||
('final_inspect', 'Final Inspection'),
|
|
||||||
('packaging', 'Packaging / Pre-Ship'),
|
|
||||||
('ship', 'Shipping'),
|
|
||||||
('gating', 'Gating'),
|
|
||||||
],
|
|
||||||
string='Step Kind',
|
|
||||||
)
|
)
|
||||||
preferred_editor = fields.Selection(
|
preferred_editor = fields.Selection(
|
||||||
[
|
[
|
||||||
|
|||||||
282
fusion_plating/fusion_plating/models/fp_step_kind.py
Normal file
282
fusion_plating/fusion_plating/models/fp_step_kind.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
# -*- 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 _, api, fields, models
|
||||||
|
|
||||||
|
from ._fp_uom_selection import FP_UOM_SELECTION
|
||||||
|
|
||||||
|
|
||||||
|
class FpStepKind(models.Model):
|
||||||
|
"""User-extensible Step Kind catalog.
|
||||||
|
|
||||||
|
Replaces the hardcoded `default_kind` Selection on fp.step.template
|
||||||
|
and fusion.plating.process.node. Each kind carries a list of default
|
||||||
|
inputs that get seeded onto a step template when the kind is picked.
|
||||||
|
"""
|
||||||
|
_name = 'fp.step.kind'
|
||||||
|
_description = 'Fusion Plating — Step Kind'
|
||||||
|
_order = 'sequence, name'
|
||||||
|
|
||||||
|
code = fields.Char(
|
||||||
|
string='Code', required=True, index=True,
|
||||||
|
help='Stable lowercase technical key. Used by automation rules and '
|
||||||
|
'workflow-state triggers (e.g. "cleaning", "plate"). Lower-case'
|
||||||
|
' enforced; underscores allowed.',
|
||||||
|
)
|
||||||
|
name = fields.Char(string='Name', required=True, translate=True)
|
||||||
|
sequence = fields.Integer(string='Sequence', default=10)
|
||||||
|
active = fields.Boolean(string='Active', default=True)
|
||||||
|
description = fields.Html(string='Description')
|
||||||
|
icon = fields.Selection(
|
||||||
|
selection='_get_icon_selection',
|
||||||
|
string='Icon',
|
||||||
|
default='fa-cog',
|
||||||
|
)
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company', string='Company',
|
||||||
|
default=lambda self: self.env.company,
|
||||||
|
)
|
||||||
|
default_input_ids = fields.One2many(
|
||||||
|
'fp.step.kind.default.input', 'kind_id',
|
||||||
|
string='Default Inputs', copy=True,
|
||||||
|
help='Auto-seeded onto a step template when this kind is picked '
|
||||||
|
'(via the "Seed Defaults" action).',
|
||||||
|
)
|
||||||
|
template_count = fields.Integer(
|
||||||
|
string='Templates', compute='_compute_template_count')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('fp_step_kind_code_company_uniq',
|
||||||
|
'unique(code, company_id)',
|
||||||
|
'Step kind code must be unique within a company.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Curated FontAwesome 4 icon catalog for the visual icon picker.
|
||||||
|
# Bigger than the 24 historical fp.process.node list — covers
|
||||||
|
# manufacturing, lab, quality, shipping, safety, time, status etc.
|
||||||
|
# FA4 ships with Odoo (no extra deps). Key = CSS class, Value = label.
|
||||||
|
_ICON_SELECTION = [
|
||||||
|
# Process / chemistry
|
||||||
|
('fa-flask', 'Flask / Chemistry'),
|
||||||
|
('fa-tint', 'Drop / Liquid'),
|
||||||
|
('fa-tachometer', 'Gauge / Measure'),
|
||||||
|
('fa-thermometer-half', 'Temp / Heat'),
|
||||||
|
('fa-fire', 'Fire / Bake'),
|
||||||
|
('fa-snowflake-o', 'Cold / Freeze'),
|
||||||
|
('fa-bolt', 'Bolt / Electric'),
|
||||||
|
('fa-plug', 'Plug / Power'),
|
||||||
|
('fa-magnet', 'Magnet'),
|
||||||
|
('fa-bullseye', 'Target / Blast'),
|
||||||
|
('fa-shower', 'Shower / Clean'),
|
||||||
|
('fa-bathtub', 'Bathtub / Soak'),
|
||||||
|
('fa-tachometer', 'Tachometer'),
|
||||||
|
|
||||||
|
# Equipment / shop
|
||||||
|
('fa-industry', 'Industry / Line'),
|
||||||
|
('fa-wrench', 'Wrench / Operation'),
|
||||||
|
('fa-cog', 'Gear / General'),
|
||||||
|
('fa-cogs', 'Gears / System'),
|
||||||
|
('fa-sitemap', 'Sitemap / Process'),
|
||||||
|
('fa-cubes', 'Cubes / Batch'),
|
||||||
|
('fa-cube', 'Cube / Part'),
|
||||||
|
('fa-th', 'Grid / Racking'),
|
||||||
|
('fa-th-large', 'Large Grid'),
|
||||||
|
('fa-server', 'Server / Rack'),
|
||||||
|
('fa-database', 'Database / Tank'),
|
||||||
|
('fa-archive', 'Archive / Box'),
|
||||||
|
('fa-recycle', 'Recycle / Reuse'),
|
||||||
|
('fa-balance-scale', 'Scale / Balance'),
|
||||||
|
|
||||||
|
# Masking / surface
|
||||||
|
('fa-paint-brush', 'Paint / Masking'),
|
||||||
|
('fa-eraser', 'Eraser / De-Masking'),
|
||||||
|
('fa-shield', 'Shield / Protect'),
|
||||||
|
('fa-diamond', 'Diamond / Plating'),
|
||||||
|
('fa-circle-o-notch', 'Coating Layer'),
|
||||||
|
|
||||||
|
# Inspection / quality
|
||||||
|
('fa-search', 'Search / Inspect'),
|
||||||
|
('fa-search-plus', 'Search Plus'),
|
||||||
|
('fa-eye', 'Eye / Visual'),
|
||||||
|
('fa-eye-slash', 'Eye Slash / Hidden'),
|
||||||
|
('fa-camera', 'Camera / Photo'),
|
||||||
|
('fa-check', 'Check'),
|
||||||
|
('fa-check-circle', 'Check / Approve'),
|
||||||
|
('fa-check-square-o', 'Checkbox'),
|
||||||
|
('fa-times', 'Reject / Cancel'),
|
||||||
|
('fa-times-circle', 'Reject Circle'),
|
||||||
|
('fa-exclamation-triangle', 'Warning'),
|
||||||
|
('fa-exclamation-circle', 'Alert'),
|
||||||
|
('fa-info-circle', 'Info'),
|
||||||
|
('fa-question-circle', 'Question'),
|
||||||
|
('fa-bug', 'Bug / Defect'),
|
||||||
|
('fa-flag', 'Flag'),
|
||||||
|
('fa-flag-checkered', 'Finish / Done'),
|
||||||
|
('fa-trophy', 'Trophy / Pass'),
|
||||||
|
('fa-thumbs-up', 'Thumbs Up'),
|
||||||
|
('fa-thumbs-down', 'Thumbs Down'),
|
||||||
|
('fa-star', 'Star'),
|
||||||
|
('fa-bookmark', 'Bookmark'),
|
||||||
|
('fa-certificate', 'Certificate'),
|
||||||
|
|
||||||
|
# Time
|
||||||
|
('fa-clock-o', 'Clock / Wait'),
|
||||||
|
('fa-hourglass-half', 'Hourglass'),
|
||||||
|
('fa-hourglass-end', 'Hourglass End'),
|
||||||
|
('fa-calendar', 'Calendar'),
|
||||||
|
('fa-calendar-check-o', 'Scheduled'),
|
||||||
|
('fa-history', 'History'),
|
||||||
|
|
||||||
|
# Safety / handling
|
||||||
|
('fa-hand-paper-o', 'Hand / Manual'),
|
||||||
|
('fa-hand-stop-o', 'Stop Hand'),
|
||||||
|
('fa-life-ring', 'Safety / Life Ring'),
|
||||||
|
('fa-medkit', 'First Aid'),
|
||||||
|
('fa-user-md', 'Inspector'),
|
||||||
|
('fa-lock', 'Lock / Hold'),
|
||||||
|
('fa-unlock', 'Unlock / Release'),
|
||||||
|
('fa-key', 'Key'),
|
||||||
|
|
||||||
|
# Documentation / certs
|
||||||
|
('fa-file-text-o', 'Document'),
|
||||||
|
('fa-file-pdf-o', 'PDF'),
|
||||||
|
('fa-file-image-o', 'Image File'),
|
||||||
|
('fa-clipboard', 'Clipboard'),
|
||||||
|
('fa-list-alt', 'Checklist'),
|
||||||
|
('fa-list-ul', 'List'),
|
||||||
|
('fa-tags', 'Tags'),
|
||||||
|
('fa-tag', 'Tag'),
|
||||||
|
('fa-barcode', 'Barcode'),
|
||||||
|
('fa-qrcode', 'QR Code'),
|
||||||
|
('fa-pencil', 'Pencil'),
|
||||||
|
('fa-edit', 'Edit'),
|
||||||
|
('fa-print', 'Print'),
|
||||||
|
('fa-paperclip', 'Attach'),
|
||||||
|
|
||||||
|
# Shipping / logistics
|
||||||
|
('fa-truck', 'Truck / Receiving'),
|
||||||
|
('fa-paper-plane', 'Ship / Send'),
|
||||||
|
('fa-plane', 'Plane / Airfreight'),
|
||||||
|
('fa-ship', 'Ship'),
|
||||||
|
('fa-shopping-cart', 'Cart'),
|
||||||
|
('fa-shopping-bag', 'Bag / Pack'),
|
||||||
|
('fa-gift', 'Gift / Package'),
|
||||||
|
('fa-suitcase', 'Suitcase'),
|
||||||
|
('fa-globe', 'Global'),
|
||||||
|
('fa-map-marker', 'Location'),
|
||||||
|
('fa-road', 'In Transit'),
|
||||||
|
|
||||||
|
# Status / process flow
|
||||||
|
('fa-play-circle', 'Start'),
|
||||||
|
('fa-pause-circle', 'Pause / Hold'),
|
||||||
|
('fa-stop-circle', 'Stop'),
|
||||||
|
('fa-step-forward', 'Step Forward'),
|
||||||
|
('fa-fast-forward', 'Fast Forward'),
|
||||||
|
('fa-refresh', 'Refresh / Repeat'),
|
||||||
|
('fa-undo', 'Undo / Rework'),
|
||||||
|
('fa-share', 'Hand-off'),
|
||||||
|
('fa-arrow-right', 'Arrow Right'),
|
||||||
|
('fa-arrow-down', 'Arrow Down'),
|
||||||
|
('fa-long-arrow-right', 'Long Arrow Right'),
|
||||||
|
('fa-random', 'Random / Mix'),
|
||||||
|
('fa-exchange', 'Exchange'),
|
||||||
|
('fa-sort-amount-asc', 'Sort Asc'),
|
||||||
|
('fa-sort-amount-desc', 'Sort Desc'),
|
||||||
|
('fa-tasks', 'Tasks'),
|
||||||
|
|
||||||
|
# Misc useful
|
||||||
|
('fa-sun-o', 'Sun / Dry'),
|
||||||
|
('fa-moon-o', 'Moon / Night'),
|
||||||
|
('fa-cloud', 'Cloud'),
|
||||||
|
('fa-leaf', 'Leaf / Eco'),
|
||||||
|
('fa-tree', 'Tree'),
|
||||||
|
('fa-bell', 'Bell / Alert'),
|
||||||
|
('fa-bullhorn', 'Announce'),
|
||||||
|
('fa-trash', 'Trash / Discard'),
|
||||||
|
('fa-plus-circle', 'Add'),
|
||||||
|
('fa-minus-circle', 'Remove'),
|
||||||
|
('fa-circle', 'Circle'),
|
||||||
|
('fa-square', 'Square'),
|
||||||
|
('fa-asterisk', 'Asterisk'),
|
||||||
|
('fa-cutlery', 'Cutlery / Bend'),
|
||||||
|
('fa-link', 'Link / Adhesion'),
|
||||||
|
('fa-chain-broken', 'Broken Chain'),
|
||||||
|
('fa-anchor', 'Anchor'),
|
||||||
|
('fa-ban', 'Ban / Forbidden'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_icon_selection(self):
|
||||||
|
return self._ICON_SELECTION
|
||||||
|
|
||||||
|
@api.depends()
|
||||||
|
def _compute_template_count(self):
|
||||||
|
Tpl = self.env['fp.step.template']
|
||||||
|
for k in self:
|
||||||
|
k.template_count = Tpl.search_count([('kind_id', '=', k.id)])
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
for v in vals_list:
|
||||||
|
if v.get('code'):
|
||||||
|
v['code'] = v['code'].lower().strip().replace(' ', '_')
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if vals.get('code'):
|
||||||
|
vals['code'] = vals['code'].lower().strip().replace(' ', '_')
|
||||||
|
return super().write(vals)
|
||||||
|
|
||||||
|
def action_open_templates(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Step Templates — %s') % self.name,
|
||||||
|
'res_model': 'fp.step.template',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('kind_id', '=', self.id)],
|
||||||
|
'context': {'default_kind_id': self.id},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FpStepKindDefaultInput(models.Model):
|
||||||
|
"""Default input prototype attached to a step kind.
|
||||||
|
|
||||||
|
When a recipe author picks a kind on a step template and clicks
|
||||||
|
'Seed Defaults', these get copied into the template's input list
|
||||||
|
(idempotent — skips by name).
|
||||||
|
"""
|
||||||
|
_name = 'fp.step.kind.default.input'
|
||||||
|
_description = 'Fusion Plating — Step Kind Default Input'
|
||||||
|
_order = 'sequence, name'
|
||||||
|
|
||||||
|
name = fields.Char(string='Name', required=True, translate=True)
|
||||||
|
kind_id = fields.Many2one(
|
||||||
|
'fp.step.kind', string='Kind',
|
||||||
|
required=True, ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
input_type = fields.Selection([
|
||||||
|
('text', 'Text'),
|
||||||
|
('number', 'Number'),
|
||||||
|
('boolean', 'Yes/No'),
|
||||||
|
('selection', 'Selection'),
|
||||||
|
('date', 'Date / Time'),
|
||||||
|
('signature', 'Signature'),
|
||||||
|
('time_hms', 'Time (HH:MM:SS)'),
|
||||||
|
('time_seconds', 'Time (seconds)'),
|
||||||
|
('temperature', 'Temperature'),
|
||||||
|
('thickness', 'Thickness'),
|
||||||
|
('pass_fail', 'Pass / Fail'),
|
||||||
|
('photo', 'Photo'),
|
||||||
|
('multi_point_thickness', 'Multi-Point Thickness (avg)'),
|
||||||
|
('bath_chemistry_panel', 'Bath Chemistry Panel'),
|
||||||
|
('ph', 'pH'),
|
||||||
|
], string='Input Type', required=True, default='text')
|
||||||
|
target_unit = fields.Selection(FP_UOM_SELECTION, string='Target Unit')
|
||||||
|
required = fields.Boolean(string='Required', default=False)
|
||||||
|
hint = fields.Char(string='Hint')
|
||||||
|
selection_options = fields.Text(string='Selection Options',
|
||||||
|
help='Comma-separated when input_type is "selection".')
|
||||||
|
sequence = fields.Integer(string='Sequence', default=10)
|
||||||
@@ -88,32 +88,21 @@ class FpStepTemplate(models.Model):
|
|||||||
requires_transition_form = fields.Boolean(string='Requires Transition Form',
|
requires_transition_form = fields.Boolean(string='Requires Transition Form',
|
||||||
help='Opens the transition form before Mark Done (Sub 12b).')
|
help='Opens the transition form before Mark Done (Sub 12b).')
|
||||||
|
|
||||||
default_kind = fields.Selection([
|
# Sub 14b — User-extensible Step Kinds (was Selection of 24).
|
||||||
('receiving', 'Receiving / Incoming Inspection'),
|
kind_id = fields.Many2one(
|
||||||
('contract_review', 'Contract Review (QA-005)'),
|
'fp.step.kind', string='Step Kind', ondelete='restrict',
|
||||||
('racking', 'Racking'),
|
index=True, tracking=True,
|
||||||
('mask', 'Masking'),
|
help='Pick from the catalog or create a new kind. Drives sane-'
|
||||||
('cleaning', 'Cleaning'),
|
'default input seeding.',
|
||||||
('electroclean', 'Electroclean'),
|
)
|
||||||
('etch', 'Etch / Activation'),
|
# Back-compat shim — every legacy `tpl.default_kind == "cleaning"`
|
||||||
('rinse', 'Rinse'),
|
# call site keeps working without a refactor. Stored=True so existing
|
||||||
('strike', 'Strike (Wood\'s Nickel / Activation)'),
|
# search domains [('default_kind', '=', 'cleaning')] still hit an
|
||||||
('plate', 'Plating'),
|
# indexed column.
|
||||||
('replenishment', 'Tank Replenishment'),
|
default_kind = fields.Char(
|
||||||
('wbf_test', 'Water Break Free Test'),
|
related='kind_id.code', store=True, readonly=True, index=True,
|
||||||
('dry', 'Drying'),
|
string='Step Kind Code',
|
||||||
('bake', 'Bake (HE Relief / Stress Relief)'),
|
)
|
||||||
('demask', 'De-Masking'),
|
|
||||||
('derack', 'De-Racking'),
|
|
||||||
('inspect', 'Inspection'),
|
|
||||||
('hardness_test', 'Hardness Test (HV / HK / HRC)'),
|
|
||||||
('adhesion_test', 'Adhesion Test'),
|
|
||||||
('salt_spray', 'Salt Spray / Corrosion Test'),
|
|
||||||
('final_inspect', 'Final Inspection'),
|
|
||||||
('packaging', 'Packaging / Pre-Ship'),
|
|
||||||
('ship', 'Shipping'),
|
|
||||||
('gating', 'Gating'),
|
|
||||||
], string='Step Kind', help='Drives sane-default input seeding.')
|
|
||||||
|
|
||||||
input_template_ids = fields.One2many(
|
input_template_ids = fields.One2many(
|
||||||
'fp.step.template.input', 'template_id',
|
'fp.step.template.input', 'template_id',
|
||||||
@@ -152,13 +141,11 @@ class FpStepTemplate(models.Model):
|
|||||||
return super().write(vals)
|
return super().write(vals)
|
||||||
|
|
||||||
# ----- Sane defaults seeding ---------------------------------------------
|
# ----- Sane defaults seeding ---------------------------------------------
|
||||||
|
# Sub 14b — moved from a Python dict into seeded fp.step.kind records
|
||||||
# NB target_unit must be a valid FP_UOM_SELECTION key — it became a
|
# so users can add new kinds + their default inputs through the
|
||||||
# Selection in 19.0.12.1.0 (uom cleanup). Free-text values like
|
# standard UI. The dict below is preserved as a fallback only for
|
||||||
# 'HH:MM', '°F', 'sec', 'in', 'each' raise ValueError on create.
|
# codes that don't have a matching kind_id record (legacy data after
|
||||||
# Mapping cheatsheet: sec → 's', °F → 'f', °C → 'c', in → 'in',
|
# migration). It will be removed in a future version.
|
||||||
# each → 'each', min → 'min'. Format-only strings ('HH:MM') get
|
|
||||||
# left blank since they're not units.
|
|
||||||
DEFAULT_INPUTS_BY_KIND = {
|
DEFAULT_INPUTS_BY_KIND = {
|
||||||
'receiving': [
|
'receiving': [
|
||||||
{'name': 'Qty Received', 'input_type': 'number',
|
{'name': 'Qty Received', 'input_type': 'number',
|
||||||
@@ -419,19 +406,37 @@ class FpStepTemplate(models.Model):
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Mapping from fp.step.kind.default.input fields → fp.step.template.input
|
||||||
|
# spec dict. Keep narrow — copy only the columns both models share.
|
||||||
|
_KIND_DEFAULT_INPUT_FIELDS = (
|
||||||
|
'name', 'input_type', 'target_unit', 'required',
|
||||||
|
'hint', 'selection_options', 'sequence',
|
||||||
|
)
|
||||||
|
|
||||||
def action_seed_default_inputs(self):
|
def action_seed_default_inputs(self):
|
||||||
"""Seed input_template_ids based on default_kind. Idempotent —
|
"""Seed input_template_ids from kind_id.default_input_ids.
|
||||||
only adds inputs whose names don't already exist on this template.
|
Idempotent — only adds inputs whose names don't already exist on
|
||||||
|
this template.
|
||||||
|
|
||||||
|
Falls back to the legacy DEFAULT_INPUTS_BY_KIND dict if the
|
||||||
|
template has no kind_id but still carries a default_kind code
|
||||||
|
(defensive — shouldn't happen post-migration).
|
||||||
|
|
||||||
Public method (Odoo 19 requires non-underscore-prefixed names
|
Public method (Odoo 19 requires non-underscore-prefixed names
|
||||||
for methods called from a view button).
|
for methods called from a view button).
|
||||||
"""
|
"""
|
||||||
Input = self.env['fp.step.template.input']
|
Input = self.env['fp.step.template.input']
|
||||||
for tpl in self:
|
for tpl in self:
|
||||||
if not tpl.default_kind:
|
|
||||||
continue
|
|
||||||
existing_names = set(tpl.input_template_ids.mapped('name'))
|
existing_names = set(tpl.input_template_ids.mapped('name'))
|
||||||
for spec in self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, []):
|
specs = []
|
||||||
|
if tpl.kind_id:
|
||||||
|
for d in tpl.kind_id.default_input_ids:
|
||||||
|
spec = {f: d[f] for f in self._KIND_DEFAULT_INPUT_FIELDS}
|
||||||
|
specs.append(spec)
|
||||||
|
elif tpl.default_kind:
|
||||||
|
# Legacy fallback — kind_id never got linked.
|
||||||
|
specs = self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, [])
|
||||||
|
for spec in specs:
|
||||||
if spec['name'] in existing_names:
|
if spec['name'] in existing_names:
|
||||||
continue
|
continue
|
||||||
Input.create({
|
Input.create({
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,group_fusion
|
|||||||
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,group_fusion_plating_operator,1,0,0,0
|
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,group_fusion_plating_operator,1,0,0,0
|
||||||
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,group_fusion_plating_supervisor,1,1,1,0
|
||||||
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,group_fusion_plating_manager,1,1,1,1
|
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_step_kind_operator,fp.step.kind.operator,model_fp_step_kind,group_fusion_plating_operator,1,0,0,0
|
||||||
|
access_fp_step_kind_supervisor,fp.step.kind.supervisor,model_fp_step_kind,group_fusion_plating_supervisor,1,1,1,0
|
||||||
|
access_fp_step_kind_manager,fp.step.kind.manager,model_fp_step_kind,group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_step_kind_default_input_operator,fp.step.kind.default.input.operator,model_fp_step_kind_default_input,group_fusion_plating_operator,1,0,0,0
|
||||||
|
access_fp_step_kind_default_input_supervisor,fp.step.kind.default.input.supervisor,model_fp_step_kind_default_input,group_fusion_plating_supervisor,1,1,1,1
|
||||||
|
access_fp_step_kind_default_input_manager,fp.step.kind.default.input.manager,model_fp_step_kind_default_input,group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_step_template_operator,fp.step.template.operator,model_fp_step_template,group_fusion_plating_operator,1,0,0,0
|
access_fp_step_template_operator,fp.step.template.operator,model_fp_step_template,group_fusion_plating_operator,1,0,0,0
|
||||||
access_fp_step_template_supervisor,fp.step.template.supervisor,model_fp_step_template,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_step_template_supervisor,fp.step.template.supervisor,model_fp_step_template,group_fusion_plating_supervisor,1,1,1,0
|
||||||
access_fp_step_template_manager,fp.step.template.manager,model_fp_step_template,group_fusion_plating_manager,1,1,1,1
|
access_fp_step_template_manager,fp.step.template.manager,model_fp_step_template,group_fusion_plating_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -0,0 +1,58 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
// Sub 14b — visual FontAwesome icon picker for fp.step.kind.icon and any
|
||||||
|
// other Selection field whose values are FA classes (e.g. 'fa-flask').
|
||||||
|
//
|
||||||
|
// Always-visible compact grid with a Search box. Glyph-only tiles
|
||||||
|
// (label appears on hover via title attribute). Designed for the form
|
||||||
|
// view; the list shows raw text and opens the form for editing.
|
||||||
|
|
||||||
|
import { Component, useState } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||||
|
|
||||||
|
class FpIconPickerField extends Component {
|
||||||
|
static template = "fusion_plating.FpIconPicker";
|
||||||
|
static props = { ...standardFieldProps };
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.state = useState({ filter: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
get options() {
|
||||||
|
const field = this.props.record.fields[this.props.name];
|
||||||
|
return (field && field.selection) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get filteredOptions() {
|
||||||
|
const q = (this.state.filter || "").trim().toLowerCase();
|
||||||
|
if (!q) return this.options;
|
||||||
|
return this.options.filter(([code, label]) =>
|
||||||
|
code.toLowerCase().includes(q) ||
|
||||||
|
(label || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentValue() {
|
||||||
|
return this.props.record.data[this.props.name] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentLabel() {
|
||||||
|
const opt = this.options.find((o) => o[0] === this.currentValue);
|
||||||
|
return opt ? opt[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async onPick(value, ev) {
|
||||||
|
if (this.props.readonly) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
await this.props.record.update({ [this.props.name]: value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fpIconPickerField = {
|
||||||
|
component: FpIconPickerField,
|
||||||
|
displayName: "Icon Picker",
|
||||||
|
supportedTypes: ["selection"],
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("fields").add("fp_icon_picker", fpIconPickerField);
|
||||||
@@ -243,6 +243,7 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
*/
|
*/
|
||||||
async onOpenLibraryCreate() {
|
async onOpenLibraryCreate() {
|
||||||
await this._fpEnsureWorkflowStatesLoaded();
|
await this._fpEnsureWorkflowStatesLoaded();
|
||||||
|
await this._fpEnsureKindOptionsLoaded();
|
||||||
this.state.libraryEditor = {
|
this.state.libraryEditor = {
|
||||||
id: null, // null = create
|
id: null, // null = create
|
||||||
name: "",
|
name: "",
|
||||||
@@ -282,9 +283,67 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub 14b — fetch the user-extensible Step Kind catalog once per
|
||||||
|
* editor session, cache on this.state.kindOptions. Used by both
|
||||||
|
* create + edit flows to populate the "Step Kind" dropdown so
|
||||||
|
* user-added kinds appear without a page reload.
|
||||||
|
*/
|
||||||
|
async _fpEnsureKindOptionsLoaded() {
|
||||||
|
if (this.state.kindOptions && this.state.kindOptions.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await rpc("/fp/simple_recipe/kinds/list", {});
|
||||||
|
this.state.kindOptions = data.kinds || [];
|
||||||
|
} catch (err) {
|
||||||
|
this.state.kindOptions = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub 14b — handler for Step Kind dropdown change. Special-cases
|
||||||
|
* the "+ Add a new kind…" sentinel: prompt the user for a name,
|
||||||
|
* round-trip to /kinds/create, refresh the cached options, then
|
||||||
|
* select the newly-created kind.
|
||||||
|
*/
|
||||||
|
async onKindChange(ev) {
|
||||||
|
const code = ev.target.value;
|
||||||
|
if (code !== "__new__") {
|
||||||
|
this.state.libraryEditor.default_kind = code || "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reset the dropdown so it doesn't stay on the sentinel if the
|
||||||
|
// user cancels the prompt.
|
||||||
|
ev.target.value = this.state.libraryEditor.default_kind || "";
|
||||||
|
const name = window.prompt(
|
||||||
|
"Name your new Step Kind (e.g. 'Passivation', 'Shot Peen')",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
if (!name || !name.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await rpc("/fp/simple_recipe/kinds/create", {
|
||||||
|
name: name.trim(),
|
||||||
|
});
|
||||||
|
if (!data.ok) {
|
||||||
|
alert(data.error || "Could not create Step Kind.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Drop the cached list so the next ensure() refetches it.
|
||||||
|
this.state.kindOptions = null;
|
||||||
|
await this._fpEnsureKindOptionsLoaded();
|
||||||
|
this.state.libraryEditor.default_kind = data.code;
|
||||||
|
} catch (err) {
|
||||||
|
alert("Could not create Step Kind: " + (err.message || err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onOpenLibraryEdit(templateId) {
|
async onOpenLibraryEdit(templateId) {
|
||||||
this.state.libraryEditorBusy = true;
|
this.state.libraryEditorBusy = true;
|
||||||
await this._fpEnsureWorkflowStatesLoaded();
|
await this._fpEnsureWorkflowStatesLoaded();
|
||||||
|
await this._fpEnsureKindOptionsLoaded();
|
||||||
const data = await rpc("/fp/simple_recipe/library/load", {
|
const data = await rpc("/fp/simple_recipe/library/load", {
|
||||||
template_id: templateId,
|
template_id: templateId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Sub 14b — Visual icon picker for fp.step.kind.icon and similar
|
||||||
|
// Selection fields whose values are FontAwesome class names.
|
||||||
|
//
|
||||||
|
// Compact 12-column grid with Search filter. Glyph-only tiles
|
||||||
|
// (label appears on hover via the browser tooltip). Capped at ~280px
|
||||||
|
// scrollable height so it doesn't dominate the form.
|
||||||
|
//
|
||||||
|
// Dark-mode aware via $o-webclient-color-scheme branch (see CLAUDE.md).
|
||||||
|
|
||||||
|
$o-webclient-color-scheme: bright !default;
|
||||||
|
|
||||||
|
$_fp-icon-picker-bg-hex: #ffffff;
|
||||||
|
$_fp-icon-picker-border-hex: #d8dadd;
|
||||||
|
$_fp-icon-picker-hover-hex: #f3f4f6;
|
||||||
|
$_fp-icon-picker-active-hex: #2c89e9;
|
||||||
|
$_fp-icon-picker-text-hex: #21252b;
|
||||||
|
$_fp-icon-picker-muted-hex: #6c757d;
|
||||||
|
|
||||||
|
@if $o-webclient-color-scheme == dark {
|
||||||
|
$_fp-icon-picker-bg-hex: #22262d !global;
|
||||||
|
$_fp-icon-picker-border-hex: #3a3f47 !global;
|
||||||
|
$_fp-icon-picker-hover-hex: #2c313a !global;
|
||||||
|
$_fp-icon-picker-active-hex: #4ea3ff !global;
|
||||||
|
$_fp-icon-picker-text-hex: #e6e9ef !global;
|
||||||
|
$_fp-icon-picker-muted-hex: #9aa3ad !global;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fp-icon-picker-bg: var(--fp-icon-picker-bg, $_fp-icon-picker-bg-hex);
|
||||||
|
$fp-icon-picker-border: var(--fp-icon-picker-border, $_fp-icon-picker-border-hex);
|
||||||
|
$fp-icon-picker-hover: var(--fp-icon-picker-hover, $_fp-icon-picker-hover-hex);
|
||||||
|
$fp-icon-picker-active: var(--fp-icon-picker-active, $_fp-icon-picker-active-hex);
|
||||||
|
$fp-icon-picker-text: var(--fp-icon-picker-text, $_fp-icon-picker-text-hex);
|
||||||
|
$fp-icon-picker-muted: var(--fp-icon-picker-muted, $_fp-icon-picker-muted-hex);
|
||||||
|
|
||||||
|
// Force full sheet width even when Odoo wraps the field in a fixed-width
|
||||||
|
// .o_field_widget cell. Selecting both the wrapper and the inline root
|
||||||
|
// belt-and-suspenders any group container that tries to clip us.
|
||||||
|
.o_field_widget[name="icon"]:has(.o_fp_icon_picker_inline) {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
flex: 1 1 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_icon_picker_inline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.o_fp_icon_picker_top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_icon_picker_current {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
background-color: $fp-icon-picker-bg;
|
||||||
|
border: 1px solid $fp-icon-picker-border;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: $fp-icon-picker-text;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_icon_picker_current_glyph {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: $fp-icon-picker-active;
|
||||||
|
width: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_icon_picker_current_empty {
|
||||||
|
color: $fp-icon-picker-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_icon_picker_current_label {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_icon_picker_filter {
|
||||||
|
flex: 0 0 140px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_icon_picker_inline_grid {
|
||||||
|
// Auto-fill tracks of ~36px each → grid expands to fill
|
||||||
|
// whatever width the form column gives it.
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0.4rem;
|
||||||
|
background-color: $fp-icon-picker-bg;
|
||||||
|
border: 1px solid $fp-icon-picker-border;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_icon_picker_tile {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $fp-icon-picker-text;
|
||||||
|
height: 30px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: $fp-icon-picker-hover;
|
||||||
|
border-color: $fp-icon-picker-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.o_fp_icon_picker_active {
|
||||||
|
border-color: $fp-icon-picker-active;
|
||||||
|
color: $fp-icon-picker-active;
|
||||||
|
background-color: rgba(44, 137, 233, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_icon_picker_tile_glyph {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_icon_picker_empty_results {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: $fp-icon-picker-muted;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_plating.FpIconPicker">
|
||||||
|
<div class="o_fp_icon_picker_inline">
|
||||||
|
<div class="o_fp_icon_picker_top">
|
||||||
|
<div class="o_fp_icon_picker_current">
|
||||||
|
<i t-if="currentValue" t-att-class="'fa ' + currentValue + ' o_fp_icon_picker_current_glyph'"/>
|
||||||
|
<i t-if="!currentValue" class="fa fa-square-o o_fp_icon_picker_current_glyph o_fp_icon_picker_current_empty"/>
|
||||||
|
<span class="o_fp_icon_picker_current_label" t-esc="currentLabel || 'Pick an icon'"/>
|
||||||
|
</div>
|
||||||
|
<input type="text" class="o_fp_icon_picker_filter form-control"
|
||||||
|
placeholder="Search…"
|
||||||
|
t-att-value="state.filter"
|
||||||
|
t-on-input="(ev) => state.filter = ev.target.value"
|
||||||
|
t-att-disabled="props.readonly"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_icon_picker_inline_grid">
|
||||||
|
<button type="button"
|
||||||
|
t-foreach="filteredOptions" t-as="opt" t-key="opt[0]"
|
||||||
|
class="o_fp_icon_picker_tile"
|
||||||
|
t-att-class="{ 'o_fp_icon_picker_active': opt[0] === currentValue }"
|
||||||
|
t-att-title="opt[1]"
|
||||||
|
t-att-disabled="props.readonly"
|
||||||
|
t-on-click="(ev) => this.onPick(opt[0], ev)">
|
||||||
|
<i t-att-class="'fa ' + opt[0] + ' o_fp_icon_picker_tile_glyph'"/>
|
||||||
|
</button>
|
||||||
|
<div t-if="filteredOptions.length === 0" class="o_fp_icon_picker_empty_results">
|
||||||
|
No icons match "<t t-esc="state.filter"/>"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -414,32 +414,15 @@
|
|||||||
title="Picking a kind auto-seeds prompts and turns on workflow gates (Contract Review, Racking, Bake). Leave blank for plain generic steps."/>
|
title="Picking a kind auto-seeds prompts and turns on workflow gates (Contract Review, Racking, Bake). Leave blank for plain generic steps."/>
|
||||||
</label>
|
</label>
|
||||||
<select class="form-select"
|
<select class="form-select"
|
||||||
t-model="state.libraryEditor.default_kind">
|
t-on-change="(ev) => this.onKindChange(ev)"
|
||||||
|
t-att-value="state.libraryEditor.default_kind">
|
||||||
<option value="">Generic — no automatic behaviour</option>
|
<option value="">Generic — no automatic behaviour</option>
|
||||||
<option value="receiving">Receiving / Incoming Inspection</option>
|
<t t-foreach="state.kindOptions || []" t-as="k" t-key="k.id">
|
||||||
<option value="contract_review">Contract Review (QA-005)</option>
|
<option t-att-value="k.code" t-att-selected="k.code === state.libraryEditor.default_kind">
|
||||||
<option value="racking">Racking</option>
|
<t t-esc="k.name"/>
|
||||||
<option value="mask">Masking</option>
|
</option>
|
||||||
<option value="cleaning">Cleaning</option>
|
</t>
|
||||||
<option value="electroclean">Electroclean</option>
|
<option value="__new__">+ Add a new kind…</option>
|
||||||
<option value="etch">Etch / Activation</option>
|
|
||||||
<option value="rinse">Rinse</option>
|
|
||||||
<option value="strike">Strike (Wood's Nickel / Activation)</option>
|
|
||||||
<option value="plate">Plating</option>
|
|
||||||
<option value="replenishment">Tank Replenishment</option>
|
|
||||||
<option value="wbf_test">Water Break Free Test</option>
|
|
||||||
<option value="dry">Drying</option>
|
|
||||||
<option value="bake">Bake (HE Relief / Stress Relief)</option>
|
|
||||||
<option value="demask">De-Masking</option>
|
|
||||||
<option value="derack">De-Racking</option>
|
|
||||||
<option value="inspect">Inspection</option>
|
|
||||||
<option value="hardness_test">Hardness Test</option>
|
|
||||||
<option value="adhesion_test">Adhesion Test</option>
|
|
||||||
<option value="salt_spray">Salt Spray / Corrosion Test</option>
|
|
||||||
<option value="final_inspect">Final Inspection</option>
|
|
||||||
<option value="packaging">Packaging / Pre-Ship</option>
|
|
||||||
<option value="ship">Shipping</option>
|
|
||||||
<option value="gating">Gating</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_le_field">
|
<div class="o_fp_le_field">
|
||||||
|
|||||||
@@ -179,7 +179,8 @@
|
|||||||
<group>
|
<group>
|
||||||
<group string="Stations">
|
<group string="Stations">
|
||||||
<field name="tank_ids" widget="many2many_tags"/>
|
<field name="tank_ids" widget="many2many_tags"/>
|
||||||
<field name="default_kind"/>
|
<field name="kind_id"
|
||||||
|
options="{'no_create_edit': false, 'no_quick_create': false}"/>
|
||||||
<field name="material_callout"/>
|
<field name="material_callout"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="Flags">
|
<group string="Flags">
|
||||||
|
|||||||
127
fusion_plating/fusion_plating/views/fp_step_kind_views.xml
Normal file
127
fusion_plating/fusion_plating/views/fp_step_kind_views.xml
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Sub 14b — User-extensible Step Kind catalog. Replaces the hardcoded
|
||||||
|
`default_kind` Selection on fp.step.template / fusion.plating.process.node.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_step_kind_list" model="ir.ui.view">
|
||||||
|
<field name="name">fp.step.kind.list</field>
|
||||||
|
<field name="model">fp.step.kind</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Step Kinds">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="icon"/>
|
||||||
|
<field name="template_count"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fp_step_kind_form" model="ir.ui.view">
|
||||||
|
<field name="name">fp.step.kind.form</field>
|
||||||
|
<field name="model">fp.step.kind</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Step Kind">
|
||||||
|
<header>
|
||||||
|
<button name="action_open_templates" type="object"
|
||||||
|
string="View Templates" class="btn-secondary"
|
||||||
|
invisible="template_count == 0"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<widget name="web_ribbon" title="Archived"
|
||||||
|
bg_color="bg-danger"
|
||||||
|
invisible="active"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name"/>
|
||||||
|
<h1><field name="name" placeholder="e.g. Passivation"/></h1>
|
||||||
|
<div class="text-muted">
|
||||||
|
<field name="code" placeholder="passivation"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="sequence"/>
|
||||||
|
<field name="company_id"
|
||||||
|
groups="base.group_multi_company"/>
|
||||||
|
<field name="active" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="template_count"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<h3 class="mt-3 mb-2">Icon</h3>
|
||||||
|
<field name="icon" widget="fp_icon_picker" nolabel="1"/>
|
||||||
|
<notebook>
|
||||||
|
<page string="Default Inputs" name="defaults">
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
These inputs auto-seed onto a Step Template when an
|
||||||
|
author picks this kind and clicks "Seed Default Inputs".
|
||||||
|
Idempotent — won't duplicate prompts that already exist.
|
||||||
|
</div>
|
||||||
|
<field name="default_input_ids">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="input_type"/>
|
||||||
|
<field name="target_unit"/>
|
||||||
|
<field name="required" widget="boolean_toggle"/>
|
||||||
|
<field name="hint"/>
|
||||||
|
<field name="selection_options"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Description" name="description">
|
||||||
|
<field name="description"
|
||||||
|
placeholder="Optional ops note shown to recipe authors when they pick this kind."/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fp_step_kind_search" model="ir.ui.view">
|
||||||
|
<field name="name">fp.step.kind.search</field>
|
||||||
|
<field name="model">fp.step.kind</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_step_kind" model="ir.actions.act_window">
|
||||||
|
<field name="name">Step Kinds</field>
|
||||||
|
<field name="res_model">fp.step.kind</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="view_fp_step_kind_search"/>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Add a new Step Kind
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Step Kinds drive the dropdown shown when a recipe author
|
||||||
|
picks the type of a step (Cleaning, Plating, Bake…). Each
|
||||||
|
kind can carry default inputs that get auto-seeded onto
|
||||||
|
templates.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_fp_step_kind"
|
||||||
|
name="Step Kinds"
|
||||||
|
parent="menu_fp_config_recipes_steps"
|
||||||
|
action="action_fp_step_kind"
|
||||||
|
sequence="50"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<field name="sequence" widget="handle"/>
|
<field name="sequence" widget="handle"/>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="code"/>
|
<field name="code"/>
|
||||||
<field name="default_kind"/>
|
<field name="kind_id"/>
|
||||||
<field name="tank_ids" widget="many2many_tags" optional="show"/>
|
<field name="tank_ids" widget="many2many_tags" optional="show"/>
|
||||||
<field name="requires_signoff" optional="hide"/>
|
<field name="requires_signoff" optional="hide"/>
|
||||||
<field name="requires_rack_assignment" optional="hide"/>
|
<field name="requires_rack_assignment" optional="hide"/>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<button name="action_seed_default_inputs" type="object"
|
<button name="action_seed_default_inputs" type="object"
|
||||||
string="Seed Default Inputs" class="btn-secondary"
|
string="Seed Default Inputs" class="btn-secondary"
|
||||||
invisible="not default_kind"/>
|
invisible="not kind_id"/>
|
||||||
<button name="action_add_common_audit_fields" type="object"
|
<button name="action_add_common_audit_fields" type="object"
|
||||||
string="+ Common Audit Fields"
|
string="+ Common Audit Fields"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
@@ -48,7 +48,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<group>
|
<group>
|
||||||
<group string="Classification">
|
<group string="Classification">
|
||||||
<field name="default_kind"/>
|
<field name="kind_id"
|
||||||
|
options="{'no_create_edit': false, 'no_quick_create': false}"/>
|
||||||
<field name="icon"/>
|
<field name="icon"/>
|
||||||
<field name="process_type_id"/>
|
<field name="process_type_id"/>
|
||||||
<field name="material_callout"/>
|
<field name="material_callout"/>
|
||||||
@@ -133,13 +134,13 @@
|
|||||||
<search>
|
<search>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="code"/>
|
<field name="code"/>
|
||||||
<field name="default_kind"/>
|
<field name="kind_id"/>
|
||||||
<field name="tank_ids"/>
|
<field name="tank_ids"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||||
<group>
|
<group>
|
||||||
<filter string="Step Kind" name="group_kind"
|
<filter string="Step Kind" name="group_kind"
|
||||||
context="{'group_by':'default_kind'}"/>
|
context="{'group_by':'kind_id'}"/>
|
||||||
<filter string="Process Type" name="group_proc"
|
<filter string="Process Type" name="group_proc"
|
||||||
context="{'group_by':'process_type_id'}"/>
|
context="{'group_by':'process_type_id'}"/>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
Reference in New Issue
Block a user