diff --git a/CLAUDE.md b/CLAUDE.md
index f5fce209..6f5c331a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,6 +14,7 @@
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
6. **res.groups**: NO `users` field, NO `category_id` field.
**res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`.
+ **`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like ` ` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like ``, ``, `` is unrelated and still works.)
**`ir.rule` `groups` field is additive, not restrictive.** A rule with `groups=[some_group]` applies ONLY to users in that group — it does NOT restrict non-members. So `domain_force=[(1,'=',1)]` + `groups=[base.group_system]` does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (`ir.model.access.csv`), not the rule. To truly restrict by group at the rule layer, pair a global rule (`groups=[]`, `domain_force=[(0,'=',1)]` = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.
7. **Search views**: NO `group expand="0"` syntax.
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
@@ -24,6 +25,7 @@
```
The attribute name after the leading underscore becomes the SQL object name suffix (`{table}_{suffix}`). `models.Index` accepts `DESC`, `WHERE` predicates, and `USING btree (...)`. Sources: `odoo/orm/model_classes.py` (warns at registry build), `odoo/orm/table_objects.py` (Constraint + Index classes).
10. **`res.users._login` is an instance method in Odoo 19**, not a classmethod as in earlier versions. Signature is `def _login(self, credential, user_agent_env)` — there is no `db` parameter. Override it like any normal instance method (`super()._login(credential, user_agent_env)`). When called via `authenticate()` on an empty recordset, `self` carries the right env. Older recipes that build a separate `api.Environment` from `odoo.modules.registry.Registry(db)` no longer apply. Source: `odoo/addons/base/models/res_users.py:760`.
+11. **Inherited `ir.ui.view` records cannot have `groups`/`group_ids` on the record itself.** Odoo 19 raises `ParseError: Inherited view cannot have 'groups' defined on the record. Use 'groups' attributes inside the view definition` at install time. Move the gate to the inner XML nodes — every ``, ``, ``, ``, `` etc. supports a `groups="base.group_system"` attribute. For an inherited form with a smart button + admin tab, put `groups=` on the button and the page individually; leave the `` clean.
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
diff --git a/fusion_login_audit/__manifest__.py b/fusion_login_audit/__manifest__.py
index b92415b6..154bce29 100644
--- a/fusion_login_audit/__manifest__.py
+++ b/fusion_login_audit/__manifest__.py
@@ -26,6 +26,7 @@ bursts. Daily retention cron honours a configurable horizon.
'data': [
'security/ir.model.access.csv',
'security/security.xml',
+ 'views/res_users_views.xml',
],
'installable': True,
'application': False,
diff --git a/fusion_login_audit/models/res_users.py b/fusion_login_audit/models/res_users.py
index 39e31cce..b7abca7a 100644
--- a/fusion_login_audit/models/res_users.py
+++ b/fusion_login_audit/models/res_users.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
import logging
-from odoo import api, fields, models
+from odoo import _, api, fields, models
from odoo.exceptions import AccessDenied
# Top-level import (vs lazy inside the method): if the dep is missing — most
@@ -193,3 +193,66 @@ class ResUsers(models.Model):
_credential=credential,
)
raise
+
+ # ──────────────────────────────────────────────────────────────────
+ # Per-user surface — fields + action method backing the smart button
+ # and "Login Activity" tab on the user form view.
+ # ──────────────────────────────────────────────────────────────────
+
+ x_fc_login_audit_ids = fields.One2many(
+ 'fusion.login.audit', 'user_id',
+ string='Login Activity',
+ )
+ x_fc_login_audit_count = fields.Integer(
+ string='Login Audit Count',
+ compute='_compute_x_fc_login_audit_count',
+ )
+ x_fc_last_successful_login = fields.Datetime(
+ string='Last Successful Login',
+ compute='_compute_x_fc_last_successful_login',
+ store=True,
+ )
+ x_fc_last_login_ip = fields.Char(
+ string='Last Login IP', size=45,
+ compute='_compute_x_fc_last_successful_login',
+ store=True,
+ )
+
+ @api.depends('x_fc_login_audit_ids')
+ def _compute_x_fc_login_audit_count(self):
+ # Odoo 19: read_group → _read_group, returns list of tuples
+ # (group_key, aggregate_value) when given groupby + aggregates.
+ Audit = self.env['fusion.login.audit'].sudo()
+ rows = Audit._read_group(
+ domain=[('user_id', 'in', self.ids)],
+ groupby=['user_id'],
+ aggregates=['__count'],
+ )
+ counts = {user.id: count for user, count in rows}
+ for user in self:
+ user.x_fc_login_audit_count = counts.get(user.id, 0)
+
+ @api.depends('x_fc_login_audit_ids.event_time',
+ 'x_fc_login_audit_ids.result',
+ 'x_fc_login_audit_ids.ip_address')
+ def _compute_x_fc_last_successful_login(self):
+ Audit = self.env['fusion.login.audit'].sudo()
+ for user in self:
+ row = Audit.search(
+ [('user_id', '=', user.id), ('result', '=', 'success')],
+ order='event_time desc', limit=1,
+ )
+ user.x_fc_last_successful_login = row.event_time or False
+ user.x_fc_last_login_ip = row.ip_address or False
+
+ def action_fc_view_login_audit(self):
+ self.ensure_one()
+ return {
+ 'name': _('Login Activity'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.login.audit',
+ 'view_mode': 'list,form',
+ 'domain': [('user_id', '=', self.id)],
+ 'context': {'create': False, 'edit': False, 'delete': False,
+ 'default_user_id': self.id},
+ }
diff --git a/fusion_login_audit/tests/test_login_audit.py b/fusion_login_audit/tests/test_login_audit.py
index 339f8388..efb62572 100644
--- a/fusion_login_audit/tests/test_login_audit.py
+++ b/fusion_login_audit/tests/test_login_audit.py
@@ -227,3 +227,42 @@ class TestFusionLoginAuditModel(TransactionCase):
self.assertFalse(row.user_id)
self.assertEqual(row.failure_reason, 'unknown_user')
self.assertEqual(row.result, 'failure')
+
+ def test_computed_last_successful_login(self):
+ """x_fc_last_successful_login reflects the latest success row."""
+ user = self.env['res.users'].sudo().create({
+ 'name': 'Compute Tester',
+ 'login': 'compute-tester@example.com',
+ 'password': 'compute-tester-pw-1',
+ })
+ # Use registry cursor so the audit row survives the transactional
+ # boundary the way the auth-time path does.
+ with self.env.registry.cursor() as audit_cr:
+ from odoo import api
+ audit_env = api.Environment(audit_cr, self.env.uid, self.env.context)
+ audit_env['fusion.login.audit'].sudo().create({
+ 'user_id': user.id,
+ 'attempted_login': user.login,
+ 'result': 'success',
+ 'database': self.env.cr.dbname,
+ 'ip_address': '198.51.100.42',
+ })
+ user.invalidate_recordset(['x_fc_last_successful_login',
+ 'x_fc_login_audit_count',
+ 'x_fc_last_login_ip'])
+ self.assertTrue(user.x_fc_last_successful_login)
+ self.assertGreaterEqual(user.x_fc_login_audit_count, 1)
+ self.assertEqual(user.x_fc_last_login_ip, '198.51.100.42')
+
+ def test_action_view_login_audit_returns_window_action(self):
+ """The smart-button action returns an act_window scoped to this user."""
+ user = self.env['res.users'].sudo().create({
+ 'name': 'Action Tester',
+ 'login': 'action-tester@example.com',
+ 'password': 'action-tester-pw-1',
+ })
+ action = user.action_fc_view_login_audit()
+ self.assertEqual(action['res_model'], 'fusion.login.audit')
+ self.assertEqual(action['type'], 'ir.actions.act_window')
+ # Domain must filter to this user
+ self.assertIn(('user_id', '=', user.id), action['domain'])
diff --git a/fusion_login_audit/views/res_users_views.xml b/fusion_login_audit/views/res_users_views.xml
new file mode 100644
index 00000000..5cb0f641
--- /dev/null
+++ b/fusion_login_audit/views/res_users_views.xml
@@ -0,0 +1,56 @@
+
+
+
+
+ res.users.form.inherit.fusion_login_audit
+ res.users
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+