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 @@
+
diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_deadline_views.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_deadline_views.xml
index 8fcaa8b..05d347f 100644
--- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_deadline_views.xml
+++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_deadline_views.xml
@@ -86,6 +86,14 @@
+
+
+
+
+
diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml
index 2449109..829acb5 100644
--- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml
+++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml
@@ -77,11 +77,26 @@
sequence="60"
groups="activeblue_familylaw.group_familylaw_attorney"/>
-
+
+
+
+
+
+
+