Step 12: file archive (SHA-256) + retention lifecycle + calendar layers

familylaw.retention.class — attorney-configured retention periods (years).
familylaw.archive — metadata over ir.attachment:
- SHA-256 checksum computed from the attachment bytes (kept as PROOF after destruction)
- destruction_eligible_date = archived + retention years; is_destruction_eligible
- lifecycle retained -> eligible -> destroyed; _cron_flag_eligible daily
- action_destroy (attorney-only): logs THAT destruction happened + checksum proof,
  unlinks the content (not retained), stamps destroyed_by/date

familylaw.case (_inherit): archive_ids; action_return_client_file (attorney-only,
only at matter close) records client-file return.
familylaw.deadline (_inherit): calendar `layer` (statutory/hearing/internal) with
per-layer search filter toggles.

Archive views + Retention Classes config menu; Return Client File button on closed
cases; retention cron; ACL (staff read-only on retention classes).

Tests (familylaw_step12): SHA-256 from bytes; eligible-date math; eligible now/not;
cron flags eligible; destroy removes content + keeps checksum + audited + attorney-
only; client-file return blocked when open / works when closed; deadline layer;
archive on case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tocmo0nlord
2026-06-02 05:01:12 +00:00
parent 53285ed5a6
commit ec09f96943
11 changed files with 505 additions and 2 deletions

View File

@@ -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",

View File

@@ -10,4 +10,15 @@
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
<!-- Daily: flag archives whose retention period has elapsed. -->
<record id="cron_flag_eligible_archives" model="ir.cron">
<field name="name">Family Law: Flag Destruction-Eligible Archives</field>
<field name="model_id" ref="model_familylaw_archive"/>
<field name="state">code</field>
<field name="code">model._cron_flag_eligible()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -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

View File

@@ -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.",
)

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
37 access_familylaw_emergency_att_user familylaw.emergency.attachment staff model_familylaw_emergency_attachment group_familylaw_user 1 1 1 1
38 access_familylaw_emergency_att_attorney familylaw.emergency.attachment attorney model_familylaw_emergency_attachment group_familylaw_attorney 1 1 1 1
39 access_familylaw_emergency_wizard_user familylaw.emergency.wizard user model_familylaw_emergency_wizard group_familylaw_user 1 1 1 1
40 access_familylaw_retention_class_user familylaw.retention.class staff model_familylaw_retention_class group_familylaw_user 1 0 0 0
41 access_familylaw_retention_class_attorney familylaw.retention.class attorney model_familylaw_retention_class group_familylaw_attorney 1 1 1 1
42 access_familylaw_archive_user familylaw.archive staff model_familylaw_archive group_familylaw_user 1 1 1 0
43 access_familylaw_archive_attorney familylaw.archive attorney model_familylaw_archive group_familylaw_attorney 1 1 1 1

View File

@@ -9,3 +9,4 @@ from . import test_step8
from . import test_step9
from . import test_step10
from . import test_step11
from . import test_step12

View File

@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""STEP 12 tests — file archive + retention lifecycle + calendar layers.
odoo -d <db> -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)

View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Retention class -->
<record id="view_familylaw_retention_class_list" model="ir.ui.view">
<field name="name">familylaw.retention.class.list</field>
<field name="model">familylaw.retention.class</field>
<field name="arch" type="xml">
<list string="Retention Classes" editable="bottom">
<field name="name"/>
<field name="code"/>
<field name="retention_years"/>
<field name="description"/>
</list>
</field>
</record>
<record id="action_familylaw_retention_class" model="ir.actions.act_window">
<field name="name">Retention Classes</field>
<field name="res_model">familylaw.retention.class</field>
<field name="view_mode">list</field>
</record>
<!-- Archive -->
<record id="view_familylaw_archive_list" model="ir.ui.view">
<field name="name">familylaw.archive.list</field>
<field name="model">familylaw.archive</field>
<field name="arch" type="xml">
<list string="Archive"
decoration-warning="is_destruction_eligible"
decoration-muted="retention_state == 'destroyed'">
<field name="name"/>
<field name="case_id"/>
<field name="retention_class_id"/>
<field name="date_archived"/>
<field name="destruction_eligible_date"/>
<field name="is_destruction_eligible" column_invisible="1"/>
<field name="retention_state" widget="badge"/>
</list>
</field>
</record>
<record id="view_familylaw_archive_form" model="ir.ui.view">
<field name="name">familylaw.archive.form</field>
<field name="model">familylaw.archive</field>
<field name="arch" type="xml">
<form string="Archived File">
<header>
<button name="action_destroy" type="object"
string="Destroy (logged)" class="btn-danger"
confirm="Destroy this archived content? The audit log will
record that destruction occurred; the content
itself will not be retained."
invisible="retention_state == 'destroyed'"
groups="activeblue_familylaw.group_familylaw_attorney"/>
<field name="retention_state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name"/></h1>
</div>
<group>
<group string="File">
<field name="attachment_id"/>
<field name="checksum" readonly="1"/>
<field name="document_id"/>
</group>
<group string="Retention">
<field name="case_id"/>
<field name="proceeding_id"/>
<field name="retention_class_id"/>
<field name="date_archived"/>
<field name="destruction_eligible_date" readonly="1"/>
<field name="is_destruction_eligible" readonly="1"/>
<field name="destroyed_date" readonly="1"/>
<field name="destroyed_by_id" readonly="1"/>
</group>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="view_familylaw_archive_search" model="ir.ui.view">
<field name="name">familylaw.archive.search</field>
<field name="model">familylaw.archive</field>
<field name="arch" type="xml">
<search string="Archive">
<field name="name"/>
<field name="case_id"/>
<filter name="eligible" string="Eligible for Destruction"
domain="[('is_destruction_eligible','=',True)]"/>
<filter name="destroyed" string="Destroyed"
domain="[('retention_state','=','destroyed')]"/>
<group expand="0" string="Group By">
<filter name="group_class" string="Retention Class"
context="{'group_by':'retention_class_id'}"/>
<filter name="group_state" string="State"
context="{'group_by':'retention_state'}"/>
</group>
</search>
</field>
</record>
<record id="action_familylaw_archive" model="ir.actions.act_window">
<field name="name">Archive</field>
<field name="res_model">familylaw.archive</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_familylaw_archive_search"/>
</record>
</odoo>

View File

@@ -67,6 +67,10 @@
<button name="action_reopen" type="object" string="Reopen"
invisible="state != 'closed'"
groups="activeblue_familylaw.group_familylaw_attorney"/>
<button name="action_return_client_file" type="object"
string="Return Client File"
invisible="state != 'closed' or client_file_returned"
groups="activeblue_familylaw.group_familylaw_attorney"/>
<field name="state" widget="statusbar"
statusbar_visible="intake,engaged,disclosure,discovery,mediation,hearing,closed"/>
</header>

View File

@@ -86,6 +86,14 @@
<filter name="pending" string="Pending"
domain="[('state','=','pending')]"/>
<separator/>
<!-- Calendar layer toggles (Step 12) -->
<filter name="layer_statutory" string="Statutory layer"
domain="[('layer','=','statutory')]"/>
<filter name="layer_hearing" string="Hearing layer"
domain="[('layer','=','hearing')]"/>
<filter name="layer_internal" string="Internal layer"
domain="[('layer','=','internal')]"/>
<separator/>
<filter name="this_week" string="Due Within 7 Days"
domain="[('due_date','&gt;=', context_today()),
('due_date','&lt;=', context_today() + relativedelta(days=7))]"/>

View File

@@ -77,11 +77,26 @@
sequence="60"
groups="activeblue_familylaw.group_familylaw_attorney"/>
<!-- Configuration placeholder (populated in later steps) -->
<!-- Archive -->
<menuitem id="menu_familylaw_archive"
name="Archive"
parent="menu_familylaw_root"
action="action_familylaw_archive"
sequence="70"/>
<!-- Configuration -->
<menuitem id="menu_familylaw_config"
name="Configuration"
parent="menu_familylaw_root"
sequence="90"
groups="activeblue_familylaw.group_familylaw_attorney"/>
<!-- Configuration > Retention Classes -->
<menuitem id="menu_familylaw_retention_class"
name="Retention Classes"
parent="menu_familylaw_config"
action="action_familylaw_retention_class"
sequence="10"
groups="activeblue_familylaw.group_familylaw_attorney"/>
</odoo>