diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a23253dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python bytecode +__pycache__/ +*.py[cod] +*$py.class + +# Editor / OS noise +.DS_Store +*.swp +*.swo +.vscode/ +.idea/ + +# Odoo runtime +*.pyc-tmp + +# Local-only diagnostic logs from test runs +_test_*.log diff --git a/CLAUDE.md b/CLAUDE.md index c5033b04..08aff26a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,26 @@ 3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`. 4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated). 5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields. + **`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`. 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 ``, ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +Add to `__manifest__.py` `data`: + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'views/res_users_views.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 17 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 17 tests`, `OK`. Also confirm the inherited view loaded: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT name FROM ir_ui_view WHERE name='res.users.form.inherit.fusion_login_audit';" +``` + +Expected: 1 row. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): smart button + Login Activity tab on res.users + +Adds four x_fc_* fields on res.users: login_audit_ids (One2many), +login_audit_count (compute), last_successful_login (compute, stored), +last_login_ip (compute, stored). action_fc_view_login_audit returns +a window action scoped to the current user. View inheritance adds a +smart button to the button box and a "Login Activity" page to the +notebook, both gated by base.group_system. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 9: Standalone views + menus for `fusion.login.audit` + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\views\fusion_login_audit_views.xml` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\views\menus.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` + +This task has no Python logic — just XML. The "test" is install-time view validation: a broken view will fail the install. + +- [ ] **Step 1: Write the views** + +`K:\Github\Odoo-Modules\fusion_login_audit\views\fusion_login_audit_views.xml`: + +```xml + + + + + + fusion.login.audit.list + fusion.login.audit + + + + + + + + + + + + + + + + + + + + fusion.login.audit.form + fusion.login.audit + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + fusion.login.audit.search + fusion.login.audit + + + + + + + + + + + + + + + + + + + + + + + + + + Login Events + fusion.login.audit + list,form + + {} + + + + Failed Logins (24h) + fusion.login.audit + list,form + + {'search_default_filter_failure': 1, 'search_default_filter_24h': 1} + + +
+``` + +`K:\Github\Odoo-Modules\fusion_login_audit\views\menus.xml`: + +```xml + + + + + + + + + + +``` + +Add to `__manifest__.py` `data` list (order matters — views before menus): + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 2: Update install + test — expect all 17 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran|ParseError)" | tail -10 +``` + +Expected: `Ran 17 tests`, `OK`. If any `ParseError` appears, the XPath or field reference is wrong — fix and re-run. + +Sanity check the menu got registered: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT m.id, m.name FROM ir_ui_menu m JOIN ir_model_data d ON d.res_id=m.id AND d.model='ir.ui.menu' WHERE d.module='fusion_login_audit';" +``` + +Expected: 3 menus. + +- [ ] **Step 3: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): standalone views + menus + +List, form, and search views for fusion.login.audit, plus a "Login +Events" full-history action and a "Failed Logins (24h)" pre-filtered +action. Both surface under Settings -> Technical -> Login Audit +(menu items gated by base.group_system). Views are no-create / no-edit +/ no-delete to enforce append-only at the UI layer too. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 10: Settings model + Settings page section + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_config_settings.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\views\res_config_settings_views.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing test** + +```python + def test_settings_round_trip(self): + """Writing settings persists them via ir.config_parameter.""" + Settings = self.env['res.config.settings'].sudo() + Settings.create({ + 'x_fc_login_audit_retention_days': 90, + 'x_fc_login_audit_alert_threshold': 3, + 'x_fc_login_audit_alert_window_min': 5, + 'x_fc_login_audit_alert_enabled': False, + }).execute() + ICP = self.env['ir.config_parameter'].sudo() + self.assertEqual(ICP.get_param('fusion_login_audit.retention_days'), '90') + self.assertEqual(ICP.get_param('fusion_login_audit.alert_threshold'), '3') + self.assertEqual(ICP.get_param('fusion_login_audit.alert_window_min'), '5') + self.assertEqual(ICP.get_param('fusion_login_audit.alert_enabled'), 'False') +``` + +- [ ] **Step 2: Run — expect FAIL ("AttributeError" on x_fc_login_audit_retention_days)** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 1 new failure. + +- [ ] **Step 3: Write the settings model** + +`K:\Github\Odoo-Modules\fusion_login_audit\models\res_config_settings.py`: + +```python +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + x_fc_login_audit_retention_days = fields.Integer( + string='Login Audit Retention (days)', + default=365, + config_parameter='fusion_login_audit.retention_days', + help='Login audit rows older than this are deleted by the nightly ' + 'cron. Set to 0 to keep forever.', + ) + x_fc_login_audit_alert_threshold = fields.Integer( + string='Alert After N Consecutive Failures', + default=5, + config_parameter='fusion_login_audit.alert_threshold', + help='When this many failures for the same attempted login occur ' + 'within the alert window, Settings admins receive one email.', + ) + x_fc_login_audit_alert_window_min = fields.Integer( + string='Alert Window (minutes)', + default=15, + config_parameter='fusion_login_audit.alert_window_min', + ) + x_fc_login_audit_alert_enabled = fields.Boolean( + string='Send Failed-Login Alerts', + default=True, + config_parameter='fusion_login_audit.alert_enabled', + ) +``` + +Wire into `models/__init__.py`: + +```python +# -*- coding: utf-8 -*- +from . import fusion_login_audit +from . import res_users +from . import res_config_settings +``` + +- [ ] **Step 4: Write the settings view** + +`K:\Github\Odoo-Modules\fusion_login_audit\views\res_config_settings_views.xml`: + +```xml + + + + + res.config.settings.form.login.audit + res.config.settings + + + + + + + + + + + + + + + + + + + + + + +``` + +> If `//block[@id='userManagement']` doesn't exist in this Odoo 19 build, fall back to `` and wrap in a `...` directly. Verify by running the failing install once with the original XPath and reading the ParseError. + +Add to `__manifest__.py`: + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/res_config_settings_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 18 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran|ParseError)" | tail -10 +``` + +Expected: `Ran 18 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): settings model + page section + +Four x_fc_* fields on res.config.settings backed by ir.config_parameter: +retention_days (default 365, 0 = forever), alert_threshold (5), +alert_window_min (15), alert_enabled (True). New "Login Audit" block +under userManagement on the General Settings page (gated by +base.group_system). + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 11: Failure-burst alert (template + send logic + cooldown) + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\data\mail_template_data.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_failure_burst_queues_one_email(self): + """5 failures in the alert window queue exactly one mail.mail.""" + from odoo.exceptions import AccessDenied + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.alert_threshold', '3') + ICP.set_param('fusion_login_audit.alert_window_min', '15') + ICP.set_param('fusion_login_audit.alert_enabled', 'True') + # Clear any cooldown left over from earlier tests. + ICP.set_param('fusion_login_audit.last_alert:burst@example.com', '') + + user = self.env['res.users'].sudo().create({ + 'name': 'Burst Tester', + 'login': 'burst@example.com', + 'password': 'burst-tester-pw-1', + }) + Mail = self.env['mail.mail'].sudo() + before = Mail.search_count([('subject', 'ilike', 'burst@example.com')]) + for _i in range(3): + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + after = Mail.search_count([('subject', 'ilike', 'burst@example.com')]) + self.assertEqual(after, before + 1) + + def test_cooldown_suppresses_second_alert(self): + """A 4th and 5th failure within the cooldown queue zero more emails.""" + from odoo.exceptions import AccessDenied + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.alert_threshold', '3') + ICP.set_param('fusion_login_audit.alert_window_min', '15') + ICP.set_param('fusion_login_audit.alert_enabled', 'True') + ICP.set_param('fusion_login_audit.last_alert:cool@example.com', '') + + user = self.env['res.users'].sudo().create({ + 'name': 'Cooldown Tester', + 'login': 'cool@example.com', + 'password': 'cooldown-tester-pw-1', + }) + Mail = self.env['mail.mail'].sudo() + for _i in range(3): + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + count_after_3 = Mail.search_count([('subject', 'ilike', 'cool@example.com')]) + for _i in range(2): + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + count_after_5 = Mail.search_count([('subject', 'ilike', 'cool@example.com')]) + self.assertEqual(count_after_5, count_after_3, + "Cooldown should suppress additional emails") + + def test_alert_disabled_master_switch(self): + """alert_enabled=False suppresses all alerts regardless of threshold.""" + from odoo.exceptions import AccessDenied + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.alert_threshold', '1') + ICP.set_param('fusion_login_audit.alert_window_min', '15') + ICP.set_param('fusion_login_audit.alert_enabled', 'False') + ICP.set_param('fusion_login_audit.last_alert:disabled@example.com', '') + + user = self.env['res.users'].sudo().create({ + 'name': 'Disabled Tester', + 'login': 'disabled@example.com', + 'password': 'disabled-tester-pw-1', + }) + Mail = self.env['mail.mail'].sudo() + before = Mail.search_count([('subject', 'ilike', 'disabled@example.com')]) + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + after = Mail.search_count([('subject', 'ilike', 'disabled@example.com')]) + self.assertEqual(after, before, "Disabled alerts should queue nothing") +``` + +- [ ] **Step 2: Run — expect 3 FAILs** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 3 new `AssertionError`s (no alert email queued yet). + +- [ ] **Step 3: Write the mail template** + +`K:\Github\Odoo-Modules\fusion_login_audit\data\mail_template_data.xml`: + +```xml + + + + + + Fusion Login Audit — Failure Burst Alert + + [Login Audit] Failed login attempts for {{ ctx.get('attempted_login') }} + +
+

The login audit detected + failed login attempt(s) + in the last minute(s) for + .

+

Most recent attempts:

+ + + + + + + + + + + + + + +
TimeIPCountryBrowserOS
+ + + + +
+

+ Sent by Fusion Login Audit. Tune the threshold and window in + Settings → General Settings → Login Audit. +

+
+
+
+ +
+
+``` + +- [ ] **Step 4: Add helpers + wire the call into `_check_credentials`** + +Append to `models/res_users.py` (before the `_check_credentials` override, or as new methods of the same class): + +```python + def _fc_alert_threshold(self): + ICP = self.env['ir.config_parameter'].sudo() + try: + return max(1, int(ICP.get_param( + 'fusion_login_audit.alert_threshold', 5))) + except (TypeError, ValueError): + return 5 + + def _fc_alert_window_min(self): + ICP = self.env['ir.config_parameter'].sudo() + try: + return max(1, int(ICP.get_param( + 'fusion_login_audit.alert_window_min', 15))) + except (TypeError, ValueError): + return 15 + + def _fc_alert_enabled(self): + ICP = self.env['ir.config_parameter'].sudo() + return ICP.get_param('fusion_login_audit.alert_enabled', 'True') == 'True' + + def _fc_recent_failure_count(self, attempted_login): + """Failures for this attempted_login within the alert window.""" + from datetime import timedelta + if not attempted_login: + return 0 + cutoff = fields.Datetime.now() - timedelta( + minutes=self._fc_alert_window_min()) + return self.env['fusion.login.audit'].sudo().search_count([ + ('attempted_login', '=', attempted_login), + ('result', '=', 'failure'), + ('event_time', '>=', cutoff), + ]) + + def _fc_send_failure_alert(self, attempted_login): + """Queue one alert mail unless cooldown is active. Cooldown is + 60 minutes, keyed by attempted_login, stored in ir.config_parameter.""" + from datetime import timedelta + if not self._fc_alert_enabled(): + return + if not attempted_login: + return + ICP = self.env['ir.config_parameter'].sudo() + cd_key = f'fusion_login_audit.last_alert:{attempted_login}' + cd_raw = ICP.get_param(cd_key) + now = fields.Datetime.now() + if cd_raw: + try: + last = fields.Datetime.from_string(cd_raw) + if last and (now - last) < timedelta(minutes=60): + return # cooldown active + except (TypeError, ValueError): + pass + + window = self._fc_alert_window_min() + cutoff = now - timedelta(minutes=window) + Audit = self.env['fusion.login.audit'].sudo() + rows = Audit.search([ + ('attempted_login', '=', attempted_login), + ('result', '=', 'failure'), + ('event_time', '>=', cutoff), + ], order='event_time desc', limit=20) + + admins = self.env.ref('base.group_system').users.filtered( + lambda u: u.email and not u.share) + if not admins: + return + + tmpl = self.env.ref( + 'fusion_login_audit.mail_template_failure_burst', + raise_if_not_found=False) + if not tmpl: + return + + ctx = { + 'attempted_login': attempted_login, + 'failure_count': len(rows), + 'window_min': window, + 'rows': [{ + 'event_time': fields.Datetime.to_string(r.event_time), + 'ip_address': r.ip_address or '', + 'country_code': r.country_code or '', + 'browser': r.browser or '', + 'os': r.os or '', + } for r in rows], + } + for admin in admins: + tmpl.with_context(ctx=ctx).send_mail( + admin.id, + email_values={'email_to': admin.email, + 'auto_delete': True}, + force_send=False, + ) + ICP.set_param(cd_key, fields.Datetime.to_string(now)) +``` + +Modify `_check_credentials` to call the alert after recording the failure: + +```python + def _check_credentials(self, credential, env): + from odoo.exceptions import AccessDenied + try: + return super()._check_credentials(credential, env) + except AccessDenied: + cred_type = (credential or {}).get('type', 'password') + reason = '2fa_failed' if cred_type == 'totp' else 'bad_password' + attempted_login = (credential or {}).get('login') or self.login + self._fc_record_login_event( + result='failure', + failure_reason=reason, + user_id=self.id, + attempted_login=attempted_login, + _credential=credential, + ) + try: + if self._fc_recent_failure_count(attempted_login) \ + >= self._fc_alert_threshold(): + self._fc_send_failure_alert(attempted_login) + except Exception: + _logger.exception( + "fusion_login_audit: failed to send failure alert") + raise +``` + +Add to `__manifest__.py` `data` (after security, before views): + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'data/mail_template_data.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/res_config_settings_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 21 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 21 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): failure-burst alert email + +Adds a mail.template + helpers (_fc_recent_failure_count, +_fc_send_failure_alert) and wires the call into _check_credentials so +that crossing the threshold queues exactly one mail.mail per attempted +login per 60-minute cooldown window. Master kill switch +x_fc_login_audit_alert_enabled honoured. Recipients are all +base.group_system members with a non-empty email. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 12: Retention GC cron + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\fusion_login_audit.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\data\ir_cron_data.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_retention_gc_deletes_old_rows(self): + """The GC method deletes rows older than retention_days.""" + from datetime import timedelta + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.retention_days', '30') + + now = fields.Datetime.now() + Audit = self.env['fusion.login.audit'].sudo() + old = Audit.create({ + 'attempted_login': 'gc-old@example.com', + 'result': 'success', + 'event_time': now - timedelta(days=45), + }) + recent = Audit.create({ + 'attempted_login': 'gc-recent@example.com', + 'result': 'success', + 'event_time': now - timedelta(days=5), + }) + old_id, recent_id = old.id, recent.id + + Audit._fc_retention_gc() + + self.assertFalse(Audit.browse(old_id).exists(), + "Row older than retention_days should be gone") + self.assertTrue(Audit.browse(recent_id).exists(), + "Row inside retention_days should survive") + + def test_retention_zero_keeps_forever(self): + """retention_days=0 keeps all rows.""" + from datetime import timedelta + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.retention_days', '0') + + now = fields.Datetime.now() + Audit = self.env['fusion.login.audit'].sudo() + ancient = Audit.create({ + 'attempted_login': 'forever@example.com', + 'result': 'success', + 'event_time': now - timedelta(days=3650), + }) + ancient_id = ancient.id + + Audit._fc_retention_gc() + + self.assertTrue(Audit.browse(ancient_id).exists(), + "retention_days=0 must keep everything") +``` + +- [ ] **Step 2: Run — expect FAIL ("AttributeError: _fc_retention_gc")** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 2 new failures. + +- [ ] **Step 3: Add the GC method** + +Append to `models/fusion_login_audit.py` (inside the `FusionLoginAudit` class): + +```python + @api.model + def _fc_retention_gc(self): + """Delete rows older than fusion_login_audit.retention_days. Called + daily by ir.cron. retention_days=0 means keep forever.""" + from datetime import timedelta + ICP = self.env['ir.config_parameter'].sudo() + try: + days = int(ICP.get_param( + 'fusion_login_audit.retention_days', 365)) + except (TypeError, ValueError): + days = 365 + if days <= 0: + return 0 + cutoff = fields.Datetime.now() - timedelta(days=days) + old = self.sudo().search([('event_time', '<', cutoff)]) + count = len(old) + if old: + old.unlink() + return count +``` + +- [ ] **Step 4: Write the cron data file** + +`K:\Github\Odoo-Modules\fusion_login_audit\data\ir_cron_data.xml`: + +```xml + + + + + + Fusion Login Audit: Retention GC + + code + model._fc_retention_gc() + 1 + days + -1 + + + + + + +``` + +Add to `__manifest__.py` `data`: + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'data/mail_template_data.xml', + 'data/ir_cron_data.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/res_config_settings_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 23 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 23 tests`, `OK`. Sanity-check the cron is registered: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT cron_name, interval_number, interval_type, active FROM ir_cron c JOIN ir_act_server a ON a.id=c.ir_actions_server_id WHERE a.name LIKE 'Fusion Login Audit%';" +``` + +Expected: 1 row, `interval_number=1`, `interval_type='days'`, `active=t`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): nightly retention GC cron + +Adds _fc_retention_gc() that deletes rows older than the configured +horizon (default 365 days; 0 = keep forever). Registered as a daily +ir.cron firing at 03:00 next-day. Tests verify both the delete path +and the "keep forever" short-circuit. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 13: Async geo-enrichment cron + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\fusion_login_audit.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\data\ir_cron_data.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_geo_private_ip_shortcut(self): + """Private IPs short-circuit to state='private_ip' without HTTP.""" + Audit = self.env['fusion.login.audit'].sudo() + rec = Audit.create({ + 'attempted_login': 'lan@example.com', + 'result': 'success', + 'ip_address': '192.168.1.40', + 'geo_lookup_state': 'pending', + }) + Audit._fc_geo_enrich_pending(limit=10) + rec.invalidate_recordset() + self.assertEqual(rec.geo_lookup_state, 'private_ip') + self.assertEqual(rec.country_code, '--') + + def test_geo_cache_hit_avoids_http(self): + """A second row with the same recent IP copies from cache.""" + from unittest.mock import patch + Audit = self.env['fusion.login.audit'].sudo() + # Seed a "done" row from the same IP. + Audit.create({ + 'attempted_login': 'seed@example.com', + 'result': 'success', + 'ip_address': '203.0.113.99', + 'geo_lookup_state': 'done', + 'country_code': 'CA', + 'country_name': 'Canada', + 'city': 'Toronto', + 'geo_state': 'Ontario', + }) + target = Audit.create({ + 'attempted_login': 'hit@example.com', + 'result': 'success', + 'ip_address': '203.0.113.99', + 'geo_lookup_state': 'pending', + }) + + with patch( + 'odoo.addons.fusion_login_audit.models.fusion_login_audit.requests.get' + ) as mock_get: + Audit._fc_geo_enrich_pending(limit=10) + mock_get.assert_not_called() + + target.invalidate_recordset() + self.assertEqual(target.geo_lookup_state, 'done') + self.assertEqual(target.country_code, 'CA') + self.assertEqual(target.city, 'Toronto') + + def test_geo_internal_skipped(self): + """Rows with geo_lookup_state='internal' are not picked up.""" + Audit = self.env['fusion.login.audit'].sudo() + rec = Audit.create({ + 'attempted_login': 'cron@example.com', + 'result': 'success', + 'ip_address': 'internal', + 'geo_lookup_state': 'internal', + }) + # Should be a no-op. + Audit._fc_geo_enrich_pending(limit=10) + rec.invalidate_recordset() + self.assertEqual(rec.geo_lookup_state, 'internal') +``` + +- [ ] **Step 2: Run — expect 3 FAILs** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 3 new `AttributeError` failures on `_fc_geo_enrich_pending`. + +- [ ] **Step 3: Implement the worker** + +Append to `models/fusion_login_audit.py` (top of file, add imports): + +```python +import ipaddress +import logging +import socket +from datetime import timedelta + +import requests + +_logger = logging.getLogger(__name__) +``` + +And inside the class: + +```python + _FC_PRIVATE_NETWORKS = ( + ipaddress.ip_network('10.0.0.0/8'), + ipaddress.ip_network('172.16.0.0/12'), + ipaddress.ip_network('192.168.0.0/16'), + ipaddress.ip_network('127.0.0.0/8'), + ipaddress.ip_network('::1/128'), + ipaddress.ip_network('fe80::/10'), + ) + + @api.model + def _fc_is_private_ip(self, ip): + if not ip or ip == 'internal': + return False # 'internal' is handled by its own state + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return False + return any(addr in net for net in self._FC_PRIVATE_NETWORKS) + + @api.model + def _fc_geo_cache_hit(self, ip): + """Return a dict of geo fields if we've resolved this IP in the last + 30 days, else None.""" + if not ip: + return None + cutoff = fields.Datetime.now() - timedelta(days=30) + cached = self.sudo().search([ + ('ip_address', '=', ip), + ('geo_lookup_state', '=', 'done'), + ('event_time', '>=', cutoff), + ], limit=1, order='event_time desc') + if not cached: + return None + return { + 'country_code': cached.country_code, + 'country_name': cached.country_name, + 'city': cached.city, + 'geo_state': cached.geo_state, + 'ip_hostname': cached.ip_hostname, + } + + @api.model + def _fc_geo_reverse_dns(self, ip): + try: + socket.setdefaulttimeout(1.5) + host, _aliases, _ips = socket.gethostbyaddr(ip) + return (host or '')[:255] + except (socket.herror, socket.gaierror, OSError): + return '' + finally: + socket.setdefaulttimeout(None) + + @api.model + def _fc_geo_http_lookup(self, ip): + """Call ip-api.com. Returns (vals_dict, rate_limited_bool). + Falls back to ({}, False) on any error.""" + try: + resp = requests.get( + 'http://ip-api.com/json/' + ip, + params={'fields': 'status,country,countryCode,regionName,city'}, + timeout=3, + headers={'User-Agent': 'Odoo-FusionLoginAudit/19.0'}, + ) + rate_limited = resp.headers.get('X-Rl', '') == '0' + if resp.status_code != 200: + return ({}, rate_limited) + data = resp.json() or {} + if data.get('status') != 'success': + return ({}, rate_limited) + return ({ + 'country_code': (data.get('countryCode') or '')[:2], + 'country_name': (data.get('country') or '')[:64], + 'geo_state': (data.get('regionName') or '')[:64], + 'city': (data.get('city') or '')[:128], + }, rate_limited) + except (requests.RequestException, ValueError): + _logger.warning("fusion_login_audit: geo lookup failed for %s", + ip, exc_info=True) + return ({}, False) + + @api.model + def _fc_geo_enrich_pending(self, limit=100): + """Cron worker: process up to `limit` pending rows.""" + pending = self.sudo().search( + [('geo_lookup_state', '=', 'pending')], + order='event_time asc', limit=limit, + ) + if not pending: + return 0 + processed = 0 + for row in pending: + ip = row.ip_address + try: + if self._fc_is_private_ip(ip): + row.write({ + 'geo_lookup_state': 'private_ip', + 'country_code': '--', + 'country_name': 'Private network', + 'city': 'Private network', + }) + self.env.cr.commit() + processed += 1 + continue + + cached = self._fc_geo_cache_hit(ip) + if cached: + cached['geo_lookup_state'] = 'done' + row.write(cached) + self.env.cr.commit() + processed += 1 + continue + + hostname = self._fc_geo_reverse_dns(ip) if ip and ip != 'internal' else '' + vals, rate_limited = self._fc_geo_http_lookup(ip) if ip and ip != 'internal' else ({}, False) + if vals: + vals['ip_hostname'] = hostname + vals['geo_lookup_state'] = 'done' + row.write(vals) + else: + row.write({ + 'geo_lookup_state': 'failed', + 'ip_hostname': hostname, + }) + self.env.cr.commit() + processed += 1 + if rate_limited: + _logger.info("fusion_login_audit: ip-api rate limit " + "hit, stopping batch early") + break + except Exception: + _logger.exception( + "fusion_login_audit: geo enrich failed for row %s", row.id) + self.env.cr.rollback() + return processed +``` + +- [ ] **Step 4: Register the cron in `ir_cron_data.xml`** + +Append inside ``: + +```xml + + Fusion Login Audit: Geo Enrichment + + code + model._fc_geo_enrich_pending(limit=100) + 5 + minutes + -1 + + 10 + +``` + +- [ ] **Step 5: Run — expect all 26 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 26 tests`, `OK`. Confirm both crons are present: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT a.name, c.interval_number, c.interval_type, c.active FROM ir_cron c JOIN ir_act_server a ON a.id=c.ir_actions_server_id WHERE a.name LIKE 'Fusion Login Audit%' ORDER BY a.name;" +``` + +Expected: 2 rows — "Fusion Login Audit: Geo Enrichment" (5 min) and "Fusion Login Audit: Retention GC" (1 day). + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): async geo enrichment cron + +5-min cron processes up to 100 pending rows per pass: private IPs +short-circuit to state=private_ip; same-IP cache (30 days) avoids +duplicate ip-api.com calls; reverse DNS via socket with 1.5s timeout; +HTTP lookup routed through network_logger automatically. Rate-limit +header X-Rl honoured — batch breaks early when ip-api returns 0. +Tests cover the three non-HTTP paths (private, cache hit, internal-skip) +without touching the network. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 14: View-visibility security test (HttpCase) + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_security.py` + +The smart button and Login Activity tab are gated by `groups="base.group_system"` at the view level. Verify a non-admin user does not see them in the rendered view. + +- [ ] **Step 1: Append failing test** + +```python + def test_view_hides_button_and_tab_for_non_admin(self): + """A regular user fields_view_get on res.users does not include the + x_fc_login_audit_* fields (they live behind groups=base.group_system).""" + ResUsers = self.env['res.users'] + view = ResUsers.with_user(self.regular_user).get_view( + view_id=self.env.ref('base.view_users_form').id, + view_type='form', + ) + arch = view['arch'] + self.assertNotIn('x_fc_login_audit_count', arch, + "Smart-button field must not leak into non-admin view") + self.assertNotIn('x_fc_login_audit_ids', arch, + "Login Activity tab must not leak into non-admin view") + + def test_view_shows_button_and_tab_for_admin(self): + """A Settings admin DOES see both nodes.""" + admin = self.env.ref('base.user_admin') + view = self.env['res.users'].with_user(admin).get_view( + view_id=self.env.ref('base.view_users_form').id, + view_type='form', + ) + arch = view['arch'] + self.assertIn('x_fc_login_audit_count', arch) + self.assertIn('x_fc_login_audit_ids', arch) +``` + +- [ ] **Step 2: Run — expect PASS already (the `groups="base.group_system"` attr is what makes this work)** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 28 tests`, `OK`. If either test fails, the `groups=` attribute on the smart-button ` - - -
- - -
- - -
-