diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py index 5439b79..9725b5c 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { "name": "Active Blue Family Law", - "version": "18.0.11.0.0", + "version": "18.0.12.0.0", "category": "Services/Legal", "summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)", "description": """ @@ -38,6 +38,7 @@ Each step adds one vertical, independently testable slice. See BUILD_PLAN.md. "views/familylaw_modification_views.xml", "views/familylaw_emergency_views.xml", "views/familylaw_proceeding_views.xml", + "views/familylaw_archive_views.xml", "views/familylaw_intake_views.xml", "views/familylaw_case_views.xml", "views/familylaw_menus.xml", diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/data/familylaw_cron.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/data/familylaw_cron.xml index 296672b..b8c04bd 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/data/familylaw_cron.xml +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/data/familylaw_cron.xml @@ -10,4 +10,15 @@ days + + + + Family Law: Flag Destruction-Eligible Archives + + code + model._cron_flag_eligible() + 1 + days + + diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py index df24b22..6bed1b1 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py @@ -16,6 +16,7 @@ from . import familylaw_discovery from . import familylaw_modification from . import familylaw_emergency from . import familylaw_docuseal +from . import familylaw_archive from . import familylaw_ai_wizard from . import familylaw_modification_wizard from . import familylaw_emergency_wizard diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_archive.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_archive.py new file mode 100644 index 0000000..22d9e7e --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_archive.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""STEP 12 — File archive + retention lifecycle + calendar layers. + +Three concerns: + * ARCHIVE — metadata over ir.attachment with a SHA-256 checksum and a retention + class. Content lives behind ir.attachment (MinIO/Synology in production). + * RETENTION — attorney-configured retention classes drive a closed-matter + destruction lifecycle: retained -> eligible -> destroyed. The audit trail records + THAT destruction happened (and the checksum as proof) WITHOUT retaining the + destroyed content. Plus client-file return at matter close. + * CALENDAR LAYERS — a layer tag on deadlines so the calendar can be filtered by + category (statutory / hearing / internal). + +VERIFY current rule — Florida file-retention obligations are volatile; retention +periods must be confirmed and configured by the attorney, not hardcoded. +""" + +import base64 +import hashlib + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +ATTORNEY_GROUP = "activeblue_familylaw.group_familylaw_attorney" + + +class FamilyLawRetentionClass(models.Model): + _name = "familylaw.retention.class" + _description = "Retention Class" + _order = "name" + + name = fields.Char(required=True) + code = fields.Char() + retention_years = fields.Integer( + string="Retain (years)", required=True, default=6, + help="Years to retain after archiving / matter close. Attorney-configured; " + "confirm the current Florida obligation.", + ) + description = fields.Text() + active = fields.Boolean(default=True) + + +class FamilyLawArchive(models.Model): + _name = "familylaw.archive" + _description = "Archived File (with retention)" + _inherit = ["mail.thread"] + _order = "date_archived desc" + + name = fields.Char(required=True, tracking=True) + case_id = fields.Many2one("familylaw.case", index=True, tracking=True) + proceeding_id = fields.Many2one("familylaw.proceeding", index=True) + document_id = fields.Many2one("familylaw.document", index=True) + attachment_id = fields.Many2one("ir.attachment", string="File", ondelete="set null") + + checksum = fields.Char( + compute="_compute_checksum", store=True, string="SHA-256", + help="SHA-256 of the file content; kept as proof even after destruction.", + ) + retention_class_id = fields.Many2one("familylaw.retention.class", + string="Retention Class", tracking=True) + date_archived = fields.Date(default=fields.Date.context_today, tracking=True) + destruction_eligible_date = fields.Date( + compute="_compute_eligible_date", store=True, + ) + is_destruction_eligible = fields.Boolean( + compute="_compute_is_eligible", string="Eligible Now?", + ) + retention_state = fields.Selection( + selection=[ + ("retained", "Retained"), + ("eligible", "Eligible for Destruction"), + ("destroyed", "Destroyed"), + ], + default="retained", + required=True, + tracking=True, + ) + destroyed_date = fields.Datetime(readonly=True, copy=False) + destroyed_by_id = fields.Many2one("res.users", readonly=True, copy=False) + + @api.depends("attachment_id", "attachment_id.datas") + def _compute_checksum(self): + for a in self: + if a.attachment_id and a.attachment_id.datas: + raw = base64.b64decode(a.attachment_id.datas) + a.checksum = hashlib.sha256(raw).hexdigest() + elif not a.checksum: # preserve checksum-as-proof after destruction + a.checksum = False + + @api.depends("date_archived", "retention_class_id.retention_years") + def _compute_eligible_date(self): + for a in self: + if a.date_archived and a.retention_class_id: + a.destruction_eligible_date = a.date_archived + relativedelta( + years=a.retention_class_id.retention_years) + else: + a.destruction_eligible_date = False + + @api.depends("destruction_eligible_date", "retention_state") + def _compute_is_eligible(self): + today = fields.Date.context_today(self) + for a in self: + a.is_destruction_eligible = bool( + a.retention_state == "retained" + and a.destruction_eligible_date + and a.destruction_eligible_date <= today + ) + + def _ensure_attorney(self): + if not self.env.user.has_group(ATTORNEY_GROUP): + raise UserError(_("Only an attorney may authorize destruction.")) + + def action_destroy(self): + """Destroy the archived content. Records THAT destruction happened (with the + checksum as proof) but does NOT retain the destroyed content.""" + self._ensure_attorney() + for a in self: + if a.retention_state == "destroyed": + continue + a.message_post(body=_( + "Archived content DESTROYED per retention class '%(rc)s'. " + "Checksum retained as proof: %(cs)s. Content not retained.", + rc=a.retention_class_id.name or _("(none)"), + cs=a.checksum or _("(n/a)"))) + if a.attachment_id: + a.attachment_id.unlink() + a.write({ + "retention_state": "destroyed", + "destroyed_date": fields.Datetime.now(), + "destroyed_by_id": self.env.user.id, + }) + return True + + @api.model + def _cron_flag_eligible(self): + today = fields.Date.context_today(self) + eligible = self.search([ + ("retention_state", "=", "retained"), + ("destruction_eligible_date", "<=", today), + ("destruction_eligible_date", "!=", False), + ]) + for a in eligible: + a.retention_state = "eligible" + a.message_post(body=_("Now eligible for destruction (retention period elapsed).")) + return len(eligible) + + +class FamilyLawCaseRetention(models.Model): + _inherit = "familylaw.case" + + archive_ids = fields.One2many("familylaw.archive", "case_id", string="Archive") + client_file_returned = fields.Boolean( + string="Client File Returned", readonly=True, copy=False, tracking=True, + ) + client_file_returned_date = fields.Date(readonly=True, copy=False) + + def action_return_client_file(self): + """Record return of the client file at matter close (attorney-only).""" + self._ensure_attorney() + for case in self: + if case.state != "closed": + raise UserError( + _("The client file is returned at matter close. Close the matter " + "first.")) + case.client_file_returned = True + case.client_file_returned_date = fields.Date.context_today(case) + case.message_post(body=_("Client file returned to the client.")) + return True + + +class FamilyLawDeadlineLayer(models.Model): + _inherit = "familylaw.deadline" + + layer = fields.Selection( + selection=[ + ("statutory", "Statutory / Procedural"), + ("hearing", "Hearing"), + ("internal", "Internal / Office"), + ], + default="statutory", + string="Calendar Layer", + help="Categorises the deadline so the calendar can be filtered by layer.", + ) diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv index 43329c0..d2d8afc 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv @@ -37,3 +37,7 @@ access_familylaw_emergency_attorney,familylaw.emergency.motion attorney,model_fa access_familylaw_emergency_att_user,familylaw.emergency.attachment staff,model_familylaw_emergency_attachment,group_familylaw_user,1,1,1,1 access_familylaw_emergency_att_attorney,familylaw.emergency.attachment attorney,model_familylaw_emergency_attachment,group_familylaw_attorney,1,1,1,1 access_familylaw_emergency_wizard_user,familylaw.emergency.wizard user,model_familylaw_emergency_wizard,group_familylaw_user,1,1,1,1 +access_familylaw_retention_class_user,familylaw.retention.class staff,model_familylaw_retention_class,group_familylaw_user,1,0,0,0 +access_familylaw_retention_class_attorney,familylaw.retention.class attorney,model_familylaw_retention_class,group_familylaw_attorney,1,1,1,1 +access_familylaw_archive_user,familylaw.archive staff,model_familylaw_archive,group_familylaw_user,1,1,1,0 +access_familylaw_archive_attorney,familylaw.archive attorney,model_familylaw_archive,group_familylaw_attorney,1,1,1,1 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py index c269f6f..04cef93 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py @@ -9,3 +9,4 @@ from . import test_step8 from . import test_step9 from . import test_step10 from . import test_step11 +from . import test_step12 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step12.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step12.py new file mode 100644 index 0000000..102b82e --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step12.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +"""STEP 12 tests — file archive + retention lifecycle + calendar layers. + + odoo -d -u activeblue_familylaw --test-enable \ + --test-tags familylaw_step12 --stop-after-init + +Proves: + * SHA-256 checksum computed from the attachment bytes; + * retention class drives the destruction-eligibility date; + * eligibility detection + cron flagging; + * destroy logs that destruction happened, removes content, keeps checksum proof; + * client-file return only at matter close; + * deadline calendar layer. +""" + +import base64 +import hashlib +from datetime import date + +from dateutil.relativedelta import relativedelta + +from odoo.tests.common import TransactionCase, new_test_user, tagged +from odoo.exceptions import UserError + + +@tagged("post_install", "-at_install", "familylaw", "familylaw_step12") +class TestStep12Archive(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.attorney = new_test_user( + cls.env, login="fl_atty12", name="Attorney 12", + email="atty12@example.com", + groups="base.group_user,activeblue_familylaw.group_familylaw_attorney", + ) + cls.partner = cls.env["res.partner"].create({"name": "Arch Client"}) + cls.case = cls.env["familylaw.case"].create({ + "name": "Archive Matter", "client_id": cls.partner.id, + "case_type": "dissolution_children", + }) + cls.rc = cls.env["familylaw.retention.class"].create({ + "name": "Standard", "code": "STD", "retention_years": 6, + }) + cls.Arch = cls.env["familylaw.archive"] + + def _attachment(self, content=b"hello world"): + return self.env["ir.attachment"].create({ + "name": "file.pdf", + "datas": base64.b64encode(content), + }) + + # --- checksum ----------------------------------------------------------- + def test_01_sha256_checksum(self): + content = b"some file bytes" + att = self._attachment(content) + arch = self.Arch.create({ + "name": "Doc", "case_id": self.case.id, "attachment_id": att.id, + }) + self.assertEqual(arch.checksum, hashlib.sha256(content).hexdigest()) + + # --- eligibility date --------------------------------------------------- + def test_02_eligible_date_from_retention(self): + arch = self.Arch.create({ + "name": "Doc", "case_id": self.case.id, + "retention_class_id": self.rc.id, "date_archived": date(2020, 1, 1), + }) + self.assertEqual(arch.destruction_eligible_date, + date(2020, 1, 1) + relativedelta(years=6)) + + def test_03_past_eligible_is_eligible(self): + arch = self.Arch.create({ + "name": "Old", "case_id": self.case.id, + "retention_class_id": self.rc.id, "date_archived": date(2000, 1, 1), + }) + self.assertTrue(arch.is_destruction_eligible) + + def test_04_future_not_eligible(self): + rc = self.env["familylaw.retention.class"].create({ + "name": "Long", "retention_years": 100}) + arch = self.Arch.create({ + "name": "New", "case_id": self.case.id, + "retention_class_id": rc.id, "date_archived": date(2020, 1, 1), + }) + self.assertFalse(arch.is_destruction_eligible) + + def test_05_cron_flags_eligible(self): + arch = self.Arch.create({ + "name": "Old", "case_id": self.case.id, + "retention_class_id": self.rc.id, "date_archived": date(2000, 1, 1), + }) + self.assertEqual(arch.retention_state, "retained") + self.Arch._cron_flag_eligible() + self.assertEqual(arch.retention_state, "eligible") + + # --- destruction -------------------------------------------------------- + def test_06_destroy_removes_content_keeps_checksum(self): + content = b"confidential" + att = self._attachment(content) + arch = self.Arch.create({ + "name": "Doc", "case_id": self.case.id, "attachment_id": att.id, + "retention_class_id": self.rc.id, + }) + cs = arch.checksum + self.assertTrue(cs) + arch.with_user(self.attorney).action_destroy() + self.assertEqual(arch.retention_state, "destroyed") + self.assertTrue(arch.destroyed_date) + self.assertFalse(arch.attachment_id) # content gone + self.assertEqual(arch.checksum, cs) # proof retained + + def test_07_destroy_requires_attorney(self): + arch = self.Arch.create({ + "name": "Doc", "case_id": self.case.id, + "retention_class_id": self.rc.id, "date_archived": date(2000, 1, 1), + }) + para = new_test_user( + self.env, login="fl_para12", name="Para 12", email="p12@example.com", + groups="base.group_user,activeblue_familylaw.group_familylaw_user", + ) + with self.assertRaises(UserError): + arch.with_user(para).action_destroy() + + def test_08_destroy_is_audited(self): + arch = self.Arch.create({ + "name": "Doc", "case_id": self.case.id, + "retention_class_id": self.rc.id, "date_archived": date(2000, 1, 1), + }) + before = len(arch.message_ids) + arch.with_user(self.attorney).action_destroy() + self.assertGreater(len(arch.message_ids), before) + + # --- client-file return ------------------------------------------------- + def test_09_return_blocked_when_open(self): + with self.assertRaises(UserError): + self.case.with_user(self.attorney).action_return_client_file() + + def test_10_return_on_closed(self): + self.case.with_user(self.attorney).action_mark_conflict_cleared() + self.case.with_user(self.attorney).action_close() + self.case.with_user(self.attorney).action_return_client_file() + self.assertTrue(self.case.client_file_returned) + self.assertTrue(self.case.client_file_returned_date) + + # --- calendar layer ----------------------------------------------------- + def test_11_deadline_layer(self): + proc = self.case.proceeding_ids[0] + dl = self.env["familylaw.deadline"].create({ + "proceeding_id": proc.id, "name": "Hearing", "deadline_type": "hearing", + "trigger_date": date(2025, 1, 1), "days": 1, "layer": "hearing", + }) + self.assertEqual(dl.layer, "hearing") + + def test_12_archive_listed_on_case(self): + arch = self.Arch.create({"name": "Doc", "case_id": self.case.id}) + self.assertIn(arch, self.case.archive_ids) diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_archive_views.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_archive_views.xml new file mode 100644 index 0000000..42a2e5c --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_archive_views.xml @@ -0,0 +1,117 @@ + + + + + + familylaw.retention.class.list + familylaw.retention.class + + + + + + + + + + + + Retention Classes + familylaw.retention.class + list + + + + + familylaw.archive.list + familylaw.archive + + + + + + + + + + + + + + + familylaw.archive.form + familylaw.archive + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+ + + familylaw.archive.search + familylaw.archive + + + + + + + + + + + + + + + + Archive + familylaw.archive + list,form + + + +
diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml index f5ed775..75452ff 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml @@ -67,6 +67,10 @@