changes
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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': """
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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','>=', (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">
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user