This commit is contained in:
gsinghpal
2026-05-30 20:59:59 -04:00
parent 55da42e91f
commit 5c1f60b3b8
17 changed files with 147 additions and 56 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Inventory',
'version': '19.0.1.0.0',
'version': '19.0.1.1.0',
'category': 'Inventory',
'summary': 'Advanced inventory management with margin tracking, sync, portal sheet, and inter-company transfers',
'description': """

View File

@@ -72,15 +72,24 @@ class FusionSyncConfig(models.Model):
# ── XML-RPC Connection ──
def _get_xmlrpc_connection(self):
def _get_xmlrpc_connection(self, force=False):
self.ensure_one()
url = self.url.rstrip('/')
try:
models_proxy = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object', allow_none=True)
# Reuse the cached uid to skip the authenticate() handshake — each
# authenticate writes a login-audit row on the remote. execute_kw
# still re-checks the API key on every call, so this stays secure.
# force=True forces a fresh authenticate (Test Connection / manual
# inter-company ops, which must work even if the cache went stale).
if self.remote_uid and not force:
return self.remote_uid, models_proxy
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common', allow_none=True)
uid = common.authenticate(self.db_name, self.username, self.api_key, {})
if not uid:
raise UserError('Authentication failed. Check username/API key.')
models_proxy = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object', allow_none=True)
if uid != self.remote_uid:
self.sudo().write({'remote_uid': uid})
return uid, models_proxy
except xmlrpc.client.Fault as e:
raise UserError(f'XML-RPC error: {e.faultString}')
@@ -92,7 +101,7 @@ class FusionSyncConfig(models.Model):
def action_test_connection(self):
self.ensure_one()
try:
uid, models_proxy = self._get_xmlrpc_connection()
uid, models_proxy = self._get_xmlrpc_connection(force=True)
version_info = xmlrpc.client.ServerProxy(
f'{self.url.rstrip("/")}/xmlrpc/2/common', allow_none=True
).version()
@@ -174,6 +183,9 @@ class FusionSyncConfig(models.Model):
'state': 'error',
'last_sync': fields.Datetime.now(),
'last_sync_status': error_msg,
# Drop the cached uid so the next run re-authenticates fresh
# (the failure may have been a stale/invalid cached uid).
'remote_uid': False,
})
self.env['fusion.sync.log'].create({
'config_id': self.id,
@@ -544,7 +556,7 @@ class FusionSyncConfig(models.Model):
def _create_remote_sale_order(self, product_mapping, qty, partner_name):
"""Create a sale order on the remote instance for inter-company transfers."""
self.ensure_one()
uid, models_proxy = self._get_xmlrpc_connection()
uid, models_proxy = self._get_xmlrpc_connection(force=True)
partners = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
@@ -593,7 +605,7 @@ class FusionSyncConfig(models.Model):
def _create_remote_invoice(self, remote_so_id):
"""Create and post an invoice for a remote sale order."""
self.ensure_one()
uid, models_proxy = self._get_xmlrpc_connection()
uid, models_proxy = self._get_xmlrpc_connection(force=True)
models_proxy.execute_kw(
self.db_name, uid, self.api_key,

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Login Audit',
'version': '19.0.1.0.0',
'version': '19.0.1.1.0',
'category': 'Tools',
'summary': 'Durable login audit log with geo-enrichment, retention, and failure alerts.',
'description': """

View File

@@ -72,6 +72,17 @@ class FusionLoginAudit(models.Model):
string='Device Type', default='unknown',
)
database = fields.Char(string='Database', size=64)
login_kind = fields.Selection(
[
('interactive', 'Interactive'),
('service', 'Service / Automation'),
],
string='Login Kind', default='interactive', index=True,
help="Interactive = a real browser/HTTP login. Service = server-to-server "
"auth (XML-RPC/JSON-RPC, cron, inter-instance sync) with no HTTP "
"request. Service logins are hidden from the default Login Events "
"view to keep it focused on real user activity.",
)
# Odoo 19 replaces the legacy `_sql_constraints = [...]` list with
# declarative `models.Constraint` attributes. The plan template used the

View File

@@ -86,11 +86,16 @@ class ResUsers(models.Model):
else:
vals['device_type'] = 'unknown'
vals['geo_lookup_state'] = 'pending'
# A live HTTP request means a real browser/interactive login.
vals['login_kind'] = 'interactive'
else:
vals['ip_address'] = 'internal'
vals['user_agent_raw'] = '<no-request>'
vals['device_type'] = 'unknown'
vals['geo_lookup_state'] = 'internal'
# No HTTP request = server-to-server auth (XML-RPC/JSON-RPC, cron,
# inter-instance sync). Tagged so the default view can hide it.
vals['login_kind'] = 'service'
# _credential is accepted in the signature so callers (T6 _check_credentials,
# T7 _login) can hand the dict in without filtering. The helper deliberately
@@ -388,5 +393,6 @@ class ResUsers(models.Model):
'view_mode': 'list,form',
'domain': [('user_id', '=', self.id)],
'context': {'create': False, 'edit': False, 'delete': False,
'default_user_id': self.id},
'default_user_id': self.id,
'search_default_filter_interactive': 1},
}

View File

@@ -74,6 +74,7 @@ class TestFusionLoginAuditModel(TransactionCase):
self.assertEqual(vals['ip_address'], 'internal')
self.assertEqual(vals['user_agent_raw'], '<no-request>')
self.assertEqual(vals['geo_lookup_state'], 'internal')
self.assertEqual(vals['login_kind'], 'service')
self.assertEqual(vals['database'], self.env.cr.dbname)
def test_build_event_vals_parses_user_agent(self):
@@ -91,6 +92,7 @@ class TestFusionLoginAuditModel(TransactionCase):
self.assertIn('Windows', vals['os'])
self.assertEqual(vals['device_type'], 'desktop')
self.assertEqual(vals['geo_lookup_state'], 'pending')
self.assertEqual(vals['login_kind'], 'interactive')
def test_build_event_vals_strips_password(self):
"""If a credential dict sneaks in, no password leaks into vals."""

View File

@@ -14,6 +14,7 @@
<field name="user_id"/>
<field name="attempted_login"/>
<field name="result" widget="badge"/>
<field name="login_kind" optional="show"/>
<field name="failure_reason"/>
<field name="ip_address"/>
<field name="country_code"/>
@@ -36,6 +37,7 @@
<group string="Event">
<field name="event_time" readonly="1"/>
<field name="result" readonly="1" widget="badge"/>
<field name="login_kind" readonly="1"/>
<field name="failure_reason" readonly="1"/>
<field name="user_id" readonly="1"/>
<field name="attempted_login" readonly="1"/>
@@ -77,6 +79,11 @@
<filter name="filter_failure" string="Failures"
domain="[('result','=','failure')]"/>
<separator/>
<filter name="filter_interactive" string="Interactive"
domain="[('login_kind','=','interactive')]"/>
<filter name="filter_service" string="Service / Automation"
domain="[('login_kind','=','service')]"/>
<separator/>
<filter name="filter_24h" string="Last 24 hours"
domain="[('event_time','&gt;=', (context_today() - relativedelta(days=1)).strftime('%Y-%m-%d 00:00:00'))]"/>
<filter name="filter_7d" string="Last 7 days"
@@ -93,6 +100,8 @@
context="{'group_by': 'country_code'}"/>
<filter name="group_ip" string="IP"
context="{'group_by': 'ip_address'}"/>
<filter name="group_kind" string="Login Kind"
context="{'group_by': 'login_kind'}"/>
</group>
</search>
</field>
@@ -104,7 +113,10 @@
<field name="res_model">fusion.login.audit</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_login_audit_search"/>
<field name="context">{}</field>
<!-- Default to interactive logins only; service/automation auth
(inter-instance sync, cron, XML-RPC) is hidden but reachable via
the "Service / Automation" filter. -->
<field name="context">{'search_default_filter_interactive': 1}</field>
</record>
<record id="action_fusion_login_audit_failures_24h" model="ir.actions.act_window">

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.23.6.0',
'version': '19.0.22.10.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -44,7 +44,6 @@ Provides:
'views/fp_part_catalog_views.xml',
'views/fp_process_node_part_scoped_views.xml',
'views/fp_pricing_rule_views.xml',
'views/fp_additional_charge_type_views.xml',
'views/fp_quote_configurator_views.xml',
'views/sale_order_views.xml',
'views/res_partner_views.xml',
@@ -60,7 +59,6 @@ Provides:
'views/fp_configurator_menu.xml',
'views/fp_so_job_sort_views.xml',
'data/fp_sale_description_template_data.xml',
'data/fp_additional_charge_type_data.xml',
],
'assets': {
'web.assets_backend': [

View File

@@ -233,10 +233,6 @@
<span><strong>OPEN</strong> open the part record</span>
</div>
<div class="mb-2 d-flex align-items-center gap-2">
<field name="is_lot_order" widget="boolean_toggle"/>
<span><strong>Lot Order</strong> — price each line as a flat lot total (qty preserved for production)</span>
</div>
<div class="mb-2 d-flex gap-2">
<button name="action_add_from_prior_so"
type="object"
@@ -290,17 +286,10 @@
width="120px"/>
<field name="internal_description" string="Internal Notes" optional="show"/>
<field name="quantity" string="Qty" width="55px"/>
<field name="lot_total"
string="Lot Total"
widget="monetary"
options="{'currency_field': 'currency_id'}"
column_invisible="not parent.is_lot_order"
width="90px"/>
<field name="unit_price"
string="Price"
widget="monetary"
options="{'currency_field': 'currency_id'}"
readonly="parent.is_lot_order"
width="80px"/>
<field name="line_subtotal"
string="Subtotal"
@@ -318,6 +307,12 @@
options="{'no_quick_create': True}"
invisible="not part_catalog_id"
optional="hide"/>
<field name="tax_ids"
string="Tax"
widget="many2many_tags"
options="{'no_create': True}"
optional="hide"
width="110px"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
@@ -346,7 +341,6 @@
<div class="o_fp_xpr_footer_right">
<div class="o_fp_xpr_card o_fp_xpr_totals">
<div class="o_fp_xpr_totals_head">Order Summary</div>
<div class="o_fp_xpr_total_row">
<span class="o_fp_xpr_total_label">Subtotal</span>
<field name="total_subtotal"
@@ -355,29 +349,19 @@
readonly="1" nolabel="1"/>
</div>
<div class="o_fp_xpr_total_row">
<div class="o_fp_xpr_total_label">
<span>Additional Charge</span>
<field name="charge_type_id" nolabel="1"
placeholder="Type..."
options="{'no_open': True}"/>
</div>
<field name="charge_amount"
widget="monetary"
options="{'currency_field': 'currency_id'}"
nolabel="1"/>
</div>
<div class="o_fp_xpr_total_row">
<div class="o_fp_xpr_total_label">
<span>Tax</span>
<field name="tax_id" nolabel="1"
placeholder="Tax type..."
options="{'no_create': True}"/>
</div>
<span class="o_fp_xpr_total_label">Tax</span>
<field name="total_tax"
widget="monetary"
options="{'currency_field': 'currency_id'}"
readonly="1" nolabel="1"/>
</div>
<div class="o_fp_xpr_total_row">
<span class="o_fp_xpr_total_label">Tooling Charge</span>
<field name="tooling_charge"
widget="monetary"
options="{'currency_field': 'currency_id'}"
nolabel="1"/>
</div>
<div class="o_fp_xpr_total_row">
<span class="o_fp_xpr_total_label">Total Lines</span>
<field name="total_line_count" readonly="1" nolabel="1"/>

View File

@@ -165,6 +165,40 @@ class FpDirectOrderWizard(models.Model):
"""Default pricelist = company's default. Re-resolved on partner pick."""
return self.env.company.partner_id.property_product_pricelist.id or False
@api.model
def _fp_default_terms_and_conditions(self):
"""Seed Terms & Conditions from the Accounting default — same source
as the standard sale.order.note field.
Respects the `account.use_invoice_terms` system parameter (toggled
from Settings → Invoicing → Default Terms & Conditions). Strips HTML
tags defensively since Odoo's rich-text editor sometimes leaks
markup into the plain `invoice_terms` field; the wizard field is
Text and must not store raw markup.
"""
ICP = self.env['ir.config_parameter'].sudo()
if not ICP.get_param('account.use_invoice_terms'):
return False
company = self.env.company
raw = (
(company.invoice_terms or '').strip()
or (getattr(company, 'invoice_terms_html', False) or '').strip()
)
if not raw:
return False
# Defensive HTML strip — works whether the source was clean plain
# text, a real html field, or a "plain" field polluted by the
# rich-text editor (entech case 2026-05-27).
if '<' in raw and '>' in raw:
try:
from lxml import html as lxml_html
raw = lxml_html.fromstring(raw).text_content().strip()
except Exception:
# Last-ditch regex fallback — no lxml, malformed html, etc.
import re
raw = re.sub(r'<[^>]+>', '', raw).strip()
return raw or False
# ---- Express Orders: auto-apply order recipe to all lines ----
@api.onchange('material_process')
def _onchange_material_process_apply_to_lines(self):
@@ -273,9 +307,10 @@ class FpDirectOrderWizard(models.Model):
# so the existing data preserves its customer-facing semantic.
terms_and_conditions = fields.Text(
string='Terms & Conditions',
default=lambda self: self._fp_default_terms_and_conditions(),
help='Customer-facing terms — prints on quote / SO / invoice. '
'Seeded from res.company.invoice_terms_html with partner-level '
'override via res.partner.invoice_terms.',
'Seeded from the Accounting default terms '
'(Settings → Invoicing → Default Terms & Conditions).',
)
internal_notes = fields.Text(
string='Order-Level Internal Notes',

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Tasks',
'version': '19.0.1.1.0',
'version': '19.0.1.2.0',
'category': 'Services/Field Service',
'summary': 'Technician scheduling, route planning, GPS tracking, and cross-instance sync.',
'author': 'Nexa Systems Inc.',

View File

@@ -58,6 +58,12 @@ class FusionTaskSyncConfig(models.Model):
active = fields.Boolean(default=True)
last_sync = fields.Datetime('Last Successful Sync', readonly=True)
last_sync_error = fields.Text('Last Error', readonly=True)
remote_uid = fields.Integer(
'Remote User ID', readonly=True, copy=False,
help='Cached uid from the last successful authenticate(). Reused for '
'execute_kw so we do not re-authenticate on every RPC (each '
'authenticate writes a login-audit row on the remote). Cleared on '
'auth failure or when the credentials change.')
# ------------------------------------------------------------------
# JSON-RPC helpers (uses /jsonrpc dispatch, muted on receiving side)
@@ -92,25 +98,50 @@ class FusionTaskSyncConfig(models.Model):
_logger.warning("Task sync: timeout connecting to %s", self.url)
return None
def _authenticate(self):
"""Authenticate with the remote instance and return the uid."""
def _authenticate(self, force=False):
"""Return the remote uid.
Reuses the cached ``remote_uid`` so we do NOT call ``authenticate()``
on every RPC — each authenticate triggers ``_login`` →
``_update_last_login`` on the remote, which writes a login-audit row.
``execute_kw`` re-checks the API key on every call, so reusing the uid
is safe; it just skips the audit-row-producing handshake. Pass
``force=True`` to bypass the cache (e.g. the Test Connection button).
"""
self.ensure_one()
if self.remote_uid and not force:
return self.remote_uid
uid = self._jsonrpc('common', 'authenticate',
[self.database, self.username, self.api_key, {}])
if not uid:
_logger.error("Task sync: authentication failed for %s", self.name)
return uid
if uid != self.remote_uid:
self.sudo().write({'remote_uid': uid})
return uid
def _rpc(self, model, method, args, kwargs=None):
"""Execute a method on the remote instance via execute_kw."""
"""Execute a method on the remote instance via execute_kw.
Uses the cached uid; on a remote error (e.g. the cached uid went
stale) it clears the cache, re-authenticates once, and retries.
"""
self.ensure_one()
uid = self._authenticate()
if not uid:
return None
call_args = [self.database, uid, self.api_key, model, method, args]
if kwargs:
call_args.append(kwargs)
return self._jsonrpc('object', 'execute_kw', call_args)
for attempt in (1, 2):
uid = self._authenticate(force=(attempt == 2))
if not uid:
return None
call_args = [self.database, uid, self.api_key, model, method, args]
if kwargs:
call_args.append(kwargs)
try:
return self._jsonrpc('object', 'execute_kw', call_args)
except UserError:
if attempt == 2:
raise
# uid may be stale — drop the cache and retry with a fresh auth
self.sudo().write({'remote_uid': False})
return None
# ------------------------------------------------------------------
# Tech sync ID helpers
@@ -173,7 +204,7 @@ class FusionTaskSyncConfig(models.Model):
def action_test_connection(self):
"""Test the connection to the remote instance."""
self.ensure_one()
uid = self._authenticate()
uid = self._authenticate(force=True)
if uid:
remote_map = self._get_remote_tech_map()
local_map = self._get_local_tech_map()