Step 2: parties, children, issues, proceeding layer, conflict screening, intake wizard

Models:
- familylaw.party — party to a matter (client/opposing/opposing counsel/other)
- familylaw.child — minor child with DOB constraint (rejects future + >25 years)
- familylaw.issue — contested issue (time-sharing, support, equitable distribution, etc.)
- familylaw.proceeding — unit of legal action; auto-created on every case create()
- familylaw.conflict.hit — records of conflict screening hits for attorney review
- familylaw.intake.wizard — multi-step intake questionnaire (TransientModel)

familylaw.case updates:
- case_number (indexed, unique, searchable), county, is_emergency, urgency_notes
- One2many to party/child/issue/proceeding/conflict_hit
- create() override: auto-opens an initial proceeding typed by case_type
- action_run_conflict_screening(): full-DB party + client name search; never auto-clears gate

Intake wizard (conditional strictness):
- Triage step first; urgency screen selects emergency vs. standard path
- Emergency fast-path: create on minimum facts (who + urgency flags), defer rest
- Standard strict path: matter name + client + case type + county required
- Modification branch (step 3) for support/parenting/alimony modifications
- Caller concern logged on case as attorney question; software never answers it
- Runs conflict screening on completion

Views: party/child/issue/proceeding/intake form+list+inline views; case form now
shows emergency banner, conflict warning, notebook tabs for all related records;
search extended to find by party name, child name, case_number (filter_domain).
Menu: "New Intake" entry launches the wizard.

Security: access rules for all 5 new models + intake wizard (base.group_user).

Tests (familylaw_step2): 34 tests across 4 classes covering:
- Initial proceeding creation and type mapping
- Multiple independent proceedings per case
- DOB validation (future + implausible age)
- Search by party name / child name / case_number
- Conflict screening (finds client match, does not auto-clear, hit count, no false
  positives for unrelated parties)
- Standard path strict validation (missing name/type/county/client each rejected)
- Emergency path (creates case on caller name alone, sets is_emergency, captures notes)
- Caller concern logged on chatter, never answered by software

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tocmo0nlord
2026-06-02 03:43:10 +00:00
parent 8c716d6ba0
commit 8bbae06b06
19 changed files with 1631 additions and 42 deletions

View File

@@ -1,15 +1,15 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
{ {
"name": "Active Blue Family Law", "name": "Active Blue Family Law",
"version": "18.0.1.0.0", "version": "18.0.2.0.0",
"category": "Services/Legal", "category": "Services/Legal",
"summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)", "summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)",
"description": """ "description": """
Active Blue Family Law Active Blue Family Law
====================== ======================
Case-management platform for a Florida family-law practice, built in verifiable Case-management platform for a Florida family-law practice, built in verifiable
steps. STEP 1 delivers the case spine: the familylaw.case model, its lifecycle steps. Step 1 delivers the case spine; Step 2 adds parties, children, issues,
state machine, security groups (attorney / staff), and the case views. the proceeding layer, automated conflict screening, and the intake questionnaire.
Each subsequent step adds one vertical, independently testable slice. See Each subsequent step adds one vertical, independently testable slice. See
BUILD_PLAN.md for the full sequence and the test method. BUILD_PLAN.md for the full sequence and the test method.
@@ -27,6 +27,11 @@ signing, citation verification, wire map, training, forms & playbook).
"data": [ "data": [
"security/familylaw_security.xml", "security/familylaw_security.xml",
"security/ir.model.access.csv", "security/ir.model.access.csv",
"views/familylaw_party_views.xml",
"views/familylaw_child_views.xml",
"views/familylaw_issue_views.xml",
"views/familylaw_proceeding_views.xml",
"views/familylaw_intake_views.xml",
"views/familylaw_case_views.xml", "views/familylaw_case_views.xml",
"views/familylaw_menus.xml", "views/familylaw_menus.xml",
], ],

View File

@@ -1 +1,7 @@
from . import familylaw_case from . import familylaw_case
from . import familylaw_party
from . import familylaw_child
from . import familylaw_issue
from . import familylaw_proceeding
from . import familylaw_conflict
from . import familylaw_intake

View File

@@ -1,15 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""STEP 1 — The case spine. """STEP 1 — Case spine (updated in Step 2: case_number, county, emergency flag,
related records, initial-proceeding creation, conflict screening).
familylaw.case is the system of record for a matter. This step implements:
* the core identifying fields and the responsible-people links,
* the pro-se / attorney representation flag (drives later workflows),
* the lifecycle STATE MACHINE with guarded transitions, and
* two attorney-only gates (conflict clearance, closing a case).
The guards are enforced in Python (server-side, testable) AND mirrored in the
view via `groups=` on the buttons (defence in depth). Later steps add parties,
children, documents, the review gate, deadlines, AI, discovery, etc.
""" """
from odoo import api, fields, models, _ from odoo import api, fields, models, _
@@ -17,7 +8,6 @@ from odoo.exceptions import UserError
ATTORNEY_GROUP = "activeblue_familylaw.group_familylaw_attorney" ATTORNEY_GROUP = "activeblue_familylaw.group_familylaw_attorney"
# The case lifecycle. Order matters: it defines the legal "forward" path.
STATE_SEQUENCE = [ STATE_SEQUENCE = [
"intake", "intake",
"engaged", "engaged",
@@ -28,6 +18,12 @@ STATE_SEQUENCE = [
"closed", "closed",
] ]
_MODIFICATION_TYPES = {
"support_modification",
"parenting_modification",
"alimony_modification",
}
class FamilyLawCase(models.Model): class FamilyLawCase(models.Model):
_name = "familylaw.case" _name = "familylaw.case"
@@ -48,6 +44,24 @@ class FamilyLawCase(models.Model):
required=True, required=True,
tracking=True, tracking=True,
) )
case_number = fields.Char(
string="Court Case Number",
index=True,
tracking=True,
help="Court-assigned case number (e.g. 2024-DR-001234). "
"Leave blank until the court assigns one.",
)
county = fields.Selection(
selection=[
("miami_dade", "Miami-Dade (11th Circuit)"),
("broward", "Broward (17th Circuit)"),
("palm_beach", "Palm Beach (15th Circuit)"),
("other_fl", "Other Florida County"),
],
string="County / Court",
default="miami_dade",
tracking=True,
)
case_type = fields.Selection( case_type = fields.Selection(
selection=[ selection=[
("dissolution_no_children", "Dissolution — no children"), ("dissolution_no_children", "Dissolution — no children"),
@@ -78,7 +92,7 @@ class FamilyLawCase(models.Model):
tracking=True, tracking=True,
) )
# --- Representation (drives the subpoena workflow in a later step) ------ # --- Representation (drives the Step 8 subpoena branch) -----------------
representation = fields.Selection( representation = fields.Selection(
selection=[ selection=[
("attorney", "Attorney of Record"), ("attorney", "Attorney of Record"),
@@ -89,7 +103,20 @@ class FamilyLawCase(models.Model):
required=True, required=True,
tracking=True, tracking=True,
help="Pro-se matters route subpoena issuance through the clerk of court; " help="Pro-se matters route subpoena issuance through the clerk of court; "
"attorney-of-record matters issue directly. See Forms & Playbook, Part C.", "attorney-of-record matters issue directly. See Forms & Playbook, Part C.",
)
# --- Emergency flags (Step 2 intake fast-path) --------------------------
is_emergency = fields.Boolean(
string="Emergency Matter",
default=False,
tracking=True,
help="Set when the intake urgency screen trips. Matter opened on minimum "
"facts; staff must complete the record immediately.",
)
urgency_notes = fields.Text(
string="Urgency Notes",
help="Emergency description captured at intake.",
) )
# --- Gates / status ----------------------------------------------------- # --- Gates / status -----------------------------------------------------
@@ -99,7 +126,7 @@ class FamilyLawCase(models.Model):
copy=False, copy=False,
tracking=True, tracking=True,
help="Set by an attorney once the conflict check is documented and clear. " help="Set by an attorney once the conflict check is documented and clear. "
"Required before the matter can be engaged.", "Required before the matter can be engaged.",
) )
state = fields.Selection( state = fields.Selection(
selection=[ selection=[
@@ -117,16 +144,158 @@ class FamilyLawCase(models.Model):
copy=False, copy=False,
tracking=True, tracking=True,
) )
date_opened = fields.Date( date_opened = fields.Date(
string="Date Opened", string="Date Opened",
default=fields.Date.context_today, default=fields.Date.context_today,
) )
active = fields.Boolean(default=True) active = fields.Boolean(default=True)
# --- Related records (Step 2) -------------------------------------------
party_ids = fields.One2many(
"familylaw.party", "case_id",
string="Parties",
)
child_ids = fields.One2many(
"familylaw.child", "case_id",
string="Children",
)
issue_ids = fields.One2many(
"familylaw.issue", "case_id",
string="Issues",
)
proceeding_ids = fields.One2many(
"familylaw.proceeding", "case_id",
string="Proceedings",
)
conflict_hit_ids = fields.One2many(
"familylaw.conflict.hit", "case_id",
string="Conflict Screening Hits",
)
conflict_hit_count = fields.Integer(
compute="_compute_conflict_hit_count",
string="Conflict Hits",
)
_sql_constraints = [
(
"case_number_unique",
"UNIQUE(case_number)",
"Court case number must be unique. Check the existing cases.",
),
]
@api.depends("conflict_hit_ids")
def _compute_conflict_hit_count(self):
for case in self:
case.conflict_hit_count = len(case.conflict_hit_ids)
# --- Case creation: auto-open an initial proceeding --------------------
@api.model_create_multi
def create(self, vals_list):
cases = super().create(vals_list)
type_labels = dict(self._fields["case_type"].selection)
for case in cases:
if case.case_type in _MODIFICATION_TYPES:
proc_type = "modification"
elif case.case_type == "enforcement":
proc_type = "enforcement"
else:
proc_type = "original"
self.env["familylaw.proceeding"].create({
"case_id": case.id,
"name": _("Initial Proceeding — %s")
% type_labels.get(case.case_type, case.case_type),
"proceeding_type": proc_type,
})
return cases
# --- Conflict screening -------------------------------------------------
def action_run_conflict_screening(self):
"""Search all party records across all matters for potential conflicts.
Checks opposing parties on this case against:
1. Any party role on any other matter (name match).
2. Any client name on any other matter.
Creates familylaw.conflict.hit records for attorney review.
Never auto-clears conflict_check_cleared; that gate stays with the attorney.
"""
for case in self:
case.conflict_hit_ids.unlink()
opposing = case.party_ids.filtered(
lambda p: p.role == "opposing_party"
)
if not opposing:
case.message_post(
body=_(
"Conflict screening: no opposing party on record. "
"Add an opposing party and re-run."
)
)
continue
role_labels = dict(self.env["familylaw.party"]._fields["role"].selection)
hit_count = 0
for party in opposing:
name = (party.name or "").strip()
if not name:
continue
# 1. Name appears as any party role on another matter
for hit in self.env["familylaw.party"].search([
("name", "ilike", name),
("case_id", "!=", case.id),
]):
self.env["familylaw.conflict.hit"].create({
"case_id": case.id,
"party_name": name,
"hit_case_id": hit.case_id.id,
"hit_role": role_labels.get(hit.role, hit.role),
"reason": _(
"'%(n)s' appears as '%(r)s' in matter '%(m)s'",
n=name,
r=role_labels.get(hit.role, hit.role),
m=hit.case_id.name,
),
})
hit_count += 1
# 2. Name matches a client on another matter
for cc in self.env["familylaw.case"].search([
("client_id.name", "ilike", name),
("id", "!=", case.id),
]):
self.env["familylaw.conflict.hit"].create({
"case_id": case.id,
"party_name": name,
"hit_case_id": cc.id,
"hit_role": _("Client"),
"reason": _(
"'%(n)s' matches client '%(c)s' in matter '%(m)s'",
n=name,
c=cc.client_id.name,
m=cc.name,
),
})
hit_count += 1
if hit_count:
case.message_post(
body=_(
"⚠️ Conflict screening found %(n)d potential conflict(s). "
"Review the Conflict Hits tab — attorney must clear before engaging.",
n=hit_count,
)
)
else:
case.message_post(
body=_("Conflict screening complete — no potential conflicts detected.")
)
return True
# --- Internal helpers --------------------------------------------------- # --- Internal helpers ---------------------------------------------------
def _ensure_attorney(self): def _ensure_attorney(self):
"""Raise unless the acting user is in the attorney group."""
if not self.env.user.has_group(ATTORNEY_GROUP): if not self.env.user.has_group(ATTORNEY_GROUP):
raise UserError( raise UserError(
_("Only a licensed attorney (Family Law / Attorney group) may " _("Only a licensed attorney (Family Law / Attorney group) may "
@@ -134,7 +303,6 @@ class FamilyLawCase(models.Model):
) )
def _require_state(self, allowed): def _require_state(self, allowed):
"""Raise if any record is not in one of the allowed states."""
bad = self.filtered(lambda c: c.state not in allowed) bad = self.filtered(lambda c: c.state not in allowed)
if bad: if bad:
raise UserError( raise UserError(
@@ -145,28 +313,26 @@ class FamilyLawCase(models.Model):
) )
def _advance_to(self, target): def _advance_to(self, target):
"""Write the new state and log it on the chatter."""
for case in self: for case in self:
case.state = target case.state = target
case.message_post( case.message_post(
body=_("Stage changed to: %s") % dict( body=_("Stage changed to: %s")
self._fields["state"].selection).get(target, target) % dict(self._fields["state"].selection).get(target, target)
) )
# --- Gated transitions -------------------------------------------------- # --- Gated transitions --------------------------------------------------
def action_mark_conflict_cleared(self): def action_mark_conflict_cleared(self):
"""Attorney-only: record that the conflict check is clear."""
self._ensure_attorney() self._ensure_attorney()
for case in self: for case in self:
if case.conflict_check_cleared: if case.conflict_check_cleared:
continue continue
case.conflict_check_cleared = True case.conflict_check_cleared = True
case.message_post(body=_("Conflict check cleared by %s.") case.message_post(
% self.env.user.name) body=_("Conflict check cleared by %s.") % self.env.user.name
)
return True return True
def action_engage(self): def action_engage(self):
"""intake -> engaged. Requires a cleared conflict check."""
self._require_state(["intake"]) self._require_state(["intake"])
not_cleared = self.filtered(lambda c: not c.conflict_check_cleared) not_cleared = self.filtered(lambda c: not c.conflict_check_cleared)
if not_cleared: if not_cleared:
@@ -178,38 +344,32 @@ class FamilyLawCase(models.Model):
return True return True
def action_start_disclosure(self): def action_start_disclosure(self):
"""engaged -> disclosure (Rule 12.285 phase begins in a later step)."""
self._require_state(["engaged"]) self._require_state(["engaged"])
self._advance_to("disclosure") self._advance_to("disclosure")
return True return True
def action_start_discovery(self): def action_start_discovery(self):
"""disclosure -> discovery."""
self._require_state(["disclosure"]) self._require_state(["disclosure"])
self._advance_to("discovery") self._advance_to("discovery")
return True return True
def action_start_mediation(self): def action_start_mediation(self):
"""discovery -> mediation (mandatory in Miami-Dade before final hearing)."""
self._require_state(["discovery"]) self._require_state(["discovery"])
self._advance_to("mediation") self._advance_to("mediation")
return True return True
def action_set_hearing(self): def action_set_hearing(self):
"""mediation -> hearing / trial."""
self._require_state(["mediation"]) self._require_state(["mediation"])
self._advance_to("hearing") self._advance_to("hearing")
return True return True
def action_close(self): def action_close(self):
"""Attorney-only: close the matter from any non-closed stage."""
self._ensure_attorney() self._ensure_attorney()
self._require_state([s for s in STATE_SEQUENCE if s != "closed"]) self._require_state([s for s in STATE_SEQUENCE if s != "closed"])
self._advance_to("closed") self._advance_to("closed")
return True return True
def action_reopen(self): def action_reopen(self):
"""Attorney-only: reopen a closed matter back to engaged."""
self._ensure_attorney() self._ensure_attorney()
self._require_state(["closed"]) self._require_state(["closed"])
self._advance_to("engaged") self._advance_to("engaged")

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
from datetime import date
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class FamilyLawChild(models.Model):
_name = "familylaw.child"
_description = "Minor Child"
_order = "name"
case_id = fields.Many2one(
"familylaw.case",
required=True,
ondelete="cascade",
index=True,
)
name = fields.Char(required=True)
date_of_birth = fields.Date(string="Date of Birth")
age = fields.Integer(
compute="_compute_age",
string="Age",
)
notes = fields.Text()
@api.depends("date_of_birth")
def _compute_age(self):
today = date.today()
for child in self:
if not child.date_of_birth:
child.age = 0
continue
dob = child.date_of_birth
child.age = (
today.year - dob.year
- ((today.month, today.day) < (dob.month, dob.day))
)
@api.constrains("date_of_birth")
def _check_date_of_birth(self):
today = date.today()
for child in self:
if not child.date_of_birth:
continue
dob = child.date_of_birth
if dob > today:
raise ValidationError(
"Date of birth cannot be in the future."
)
age = (
today.year - dob.year
- ((today.month, today.day) < (dob.month, dob.day))
)
if age > 25:
raise ValidationError(
f"Date of birth implies the child is {age} years old. "
"Verify the date — this field is for minor children in the matter."
)

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class FamilyLawConflictHit(models.Model):
_name = "familylaw.conflict.hit"
_description = "Conflict Screening Hit"
_order = "create_date desc"
case_id = fields.Many2one(
"familylaw.case",
required=True,
ondelete="cascade",
index=True,
)
party_name = fields.Char(string="Flagged Party Name", required=True)
hit_case_id = fields.Many2one(
"familylaw.case",
string="Found In Matter",
)
hit_role = fields.Char(string="Role in Matching Matter")
reason = fields.Char(string="Reason for Flag")

View File

@@ -0,0 +1,319 @@
# -*- coding: utf-8 -*-
"""STEP 2 — Intake questionnaire wizard.
Multi-step TransientModel that:
* runs triage FIRST (caller info, opposing party, urgency screen),
* branches on urgency: emergency → fast-path on minimum facts;
standard → strict (name + client + case_type + county required),
* branches into the child-support modification question set,
* creates a draft familylaw.case in 'intake' state (and its initial proceeding,
via the case model's create() override),
* runs conflict screening on the opposing party,
* and captures the caller's concern as an attorney question — never answers it.
No external/network calls. Pure local DB.
"""
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_MODIFICATION_TYPES = {
"support_modification",
"parenting_modification",
"alimony_modification",
}
_CASE_TYPE_SELECTION = [
("dissolution_no_children", "Dissolution — no children"),
("dissolution_children", "Dissolution — with children"),
("paternity", "Paternity"),
("support_modification", "Child Support Modification"),
("parenting_modification", "Parenting / Time-Sharing Modification"),
("alimony_modification", "Alimony Modification"),
("enforcement", "Enforcement / Contempt"),
("dv_injunction", "Domestic Violence Injunction"),
("other", "Other"),
]
class FamilyLawIntakeWizard(models.TransientModel):
_name = "familylaw.intake.wizard"
_description = "Family Law Intake Questionnaire"
step = fields.Selection(
selection=[
("triage", "1 — Triage"),
("details", "2 — Case Details"),
("modification", "3 — Modification Details"),
("confirm", "4 — Complete"),
],
default="triage",
required=True,
)
# === TRIAGE (step 1) ====================================================
caller_name = fields.Char(string="Caller / Client Name")
caller_phone = fields.Char(string="Phone")
caller_email = fields.Char(string="Email")
county = fields.Selection(
selection=[
("miami_dade", "Miami-Dade (11th Circuit)"),
("broward", "Broward (17th Circuit)"),
("palm_beach", "Palm Beach (15th Circuit)"),
("other_fl", "Other Florida County"),
],
string="County / Court",
default="miami_dade",
)
case_type = fields.Selection(
selection=_CASE_TYPE_SELECTION,
string="Case Type",
)
opposing_party_name = fields.Char(string="Opposing Party Name")
has_children = fields.Boolean(string="Minor children involved?")
has_existing_order = fields.Boolean(string="Existing court order?")
# Urgency screen — runs first; triggers the emergency fast-path
urgency_child_withheld = fields.Boolean(
string="Child being withheld / refused access?"
)
urgency_removal_threat = fields.Boolean(
string="Threat of removal from Florida?"
)
urgency_violence = fields.Boolean(
string="Domestic violence / immediate safety concern?"
)
urgency_description = fields.Text(
string="Describe the Emergency",
help="Captured verbatim for the attorney. Do not interpret or advise.",
)
is_emergency = fields.Boolean(
compute="_compute_is_emergency",
store=True,
string="Emergency",
)
# === STANDARD DETAILS (step 2 — strict path) ============================
matter_name = fields.Char(string="Matter Name")
client_partner_id = fields.Many2one(
"res.partner",
string="Client (existing contact)",
help="Pick an existing contact or leave blank to create from caller name.",
)
attorney_id = fields.Many2one("res.users", string="Responsible Attorney")
paralegal_id = fields.Many2one("res.users", string="Paralegal")
# === MODIFICATION DETAILS (step 3 — modification types only) ============
existing_order_summary = fields.Text(
string="Summary of Existing Order",
help="Captured facts from the prior judgment. Not interpreted here — "
"prior-judgment interpretation is Step 6+ (attorney-reviewed).",
)
reason_for_modification = fields.Text(string="Reason for Modification")
income_change_details = fields.Text(
string="Income / Financial Change Details",
help="E.g. 'income dropped from $X to $Y in month/year'. Facts only.",
)
# Caller concern — CAPTURED AS ATTORNEY QUESTION, never answered here
caller_concern = fields.Text(
string="Caller's Question / Concern for the Attorney",
help="Record the caller's own words. This is logged on the case as a "
"question for the attorney. This software does not answer "
"'do I have a good case?' — that determination belongs to the attorney.",
)
# === RESULT (step 4) ====================================================
created_case_id = fields.Many2one(
"familylaw.case",
string="Created Matter",
readonly=True,
)
conflict_summary = fields.Text(
string="Conflict Screening Results",
readonly=True,
)
# -----------------------------------------------------------------------
@api.depends("urgency_child_withheld", "urgency_removal_threat", "urgency_violence")
def _compute_is_emergency(self):
for w in self:
w.is_emergency = bool(
w.urgency_child_withheld
or w.urgency_removal_threat
or w.urgency_violence
)
# --- Navigation ---------------------------------------------------------
def action_next(self):
self.ensure_one()
if self.step == "triage":
if self.is_emergency:
self._create_emergency_case()
self.step = "confirm"
else:
self.step = "details"
elif self.step == "details":
self._validate_standard_details()
if self.case_type in _MODIFICATION_TYPES:
self.step = "modification"
else:
self._create_standard_case()
self.step = "confirm"
elif self.step == "modification":
self._create_standard_case()
self.step = "confirm"
return self._reopen()
def action_back(self):
self.ensure_one()
if self.step == "details":
self.step = "triage"
elif self.step == "modification":
self.step = "details"
return self._reopen()
def action_open_case(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"res_model": "familylaw.case",
"res_id": self.created_case_id.id,
"view_mode": "form",
"target": "current",
}
def _reopen(self):
return {
"type": "ir.actions.act_window",
"res_model": "familylaw.intake.wizard",
"res_id": self.id,
"view_mode": "form",
"target": "new",
}
# --- Validation ---------------------------------------------------------
def _validate_standard_details(self):
errors = []
if not self.matter_name:
errors.append(_("Matter Name is required."))
if not self.client_partner_id and not self.caller_name:
errors.append(_("A client contact or caller name is required."))
if not self.case_type:
errors.append(_("Case Type is required."))
if not self.county:
errors.append(_("County / Court is required."))
if errors:
raise UserError("\n".join(errors))
# --- Case creation ------------------------------------------------------
def _resolve_or_create_partner(self):
if self.client_partner_id:
return self.client_partner_id
return self.env["res.partner"].create({
"name": self.caller_name or _("Unknown — Emergency Intake"),
"phone": self.caller_phone or False,
"email": self.caller_email or False,
})
def _create_emergency_case(self):
"""Fast-path: open on minimum facts — who, which child, what's happening."""
partner = self._resolve_or_create_partner()
flags = []
if self.urgency_child_withheld:
flags.append(_("Child withheld / refused access"))
if self.urgency_removal_threat:
flags.append(_("Threat of removal from Florida"))
if self.urgency_violence:
flags.append(_("Domestic violence / safety concern"))
urgency_text = "; ".join(flags)
if self.urgency_description:
urgency_text += "\n" + self.urgency_description
case = self.env["familylaw.case"].create({
"name": self.matter_name or _("EMERGENCY — %s") % partner.name,
"client_id": partner.id,
"case_type": self.case_type or "other",
"county": self.county or "miami_dade",
"is_emergency": True,
"urgency_notes": urgency_text,
})
if self.opposing_party_name:
self.env["familylaw.party"].create({
"case_id": case.id,
"name": self.opposing_party_name,
"role": "opposing_party",
})
self._post_caller_concern(case)
self._run_screening_and_summarise(case)
self.created_case_id = case
def _create_standard_case(self):
"""Strict path: all required fields validated before reaching here."""
partner = self._resolve_or_create_partner()
case = self.env["familylaw.case"].create({
"name": self.matter_name,
"client_id": partner.id,
"case_type": self.case_type,
"county": self.county or "miami_dade",
"attorney_id": self.attorney_id.id if self.attorney_id else False,
"paralegal_id": self.paralegal_id.id if self.paralegal_id else False,
"is_emergency": False,
})
if self.opposing_party_name:
self.env["familylaw.party"].create({
"case_id": case.id,
"name": self.opposing_party_name,
"role": "opposing_party",
})
if self.reason_for_modification:
case.message_post(
body=_("Modification reason (captured at intake): %s")
% self.reason_for_modification
)
if self.existing_order_summary:
case.message_post(
body=_("Existing order summary (captured at intake; not legally "
"interpreted here — see Step 6+ for prior-judgment extraction): %s")
% self.existing_order_summary
)
self._post_caller_concern(case)
self._run_screening_and_summarise(case)
self.created_case_id = case
def _post_caller_concern(self, case):
if self.caller_concern:
case.message_post(
body=_(
"ATTORNEY QUESTION (captured at intake — this software does "
"not answer this; for attorney review only): %s"
) % self.caller_concern
)
def _run_screening_and_summarise(self, case):
case.action_run_conflict_screening()
hits = case.conflict_hit_ids
if hits:
lines = [
_("⚠️ %(n)d potential conflict(s) found — attorney review required "
"before engaging:", n=len(hits))
]
for h in hits:
lines.append("" + (h.reason or h.party_name))
self.conflict_summary = "\n".join(lines)
else:
self.conflict_summary = _("No potential conflicts detected.")

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class FamilyLawIssue(models.Model):
_name = "familylaw.issue"
_description = "Contested Issue"
_order = "issue_type"
case_id = fields.Many2one(
"familylaw.case",
required=True,
ondelete="cascade",
index=True,
)
issue_type = fields.Selection(
selection=[
("time_sharing", "Time-Sharing / Parental Responsibility"),
("child_support", "Child Support"),
("equitable_distribution", "Equitable Distribution"),
("alimony", "Alimony / Spousal Support"),
("attorneys_fees", "Attorney's Fees"),
("other", "Other"),
],
string="Issue",
required=True,
)
name = fields.Char(
compute="_compute_name",
store=True,
string="Issue",
)
description = fields.Text()
@api.depends("issue_type")
def _compute_name(self):
labels = dict(self._fields["issue_type"].selection)
for issue in self:
issue.name = labels.get(issue.issue_type, "")

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class FamilyLawParty(models.Model):
_name = "familylaw.party"
_description = "Case Party"
_inherit = ["mail.thread"]
_order = "role, name"
case_id = fields.Many2one(
"familylaw.case",
required=True,
ondelete="cascade",
index=True,
)
partner_id = fields.Many2one(
"res.partner",
string="Contact Record",
help="Link to an existing contact. Name is copied to the Name field.",
)
name = fields.Char(
required=True,
tracking=True,
help="Full legal name of this party.",
)
role = fields.Selection(
selection=[
("client", "Client"),
("opposing_party", "Opposing Party"),
("opposing_counsel", "Opposing Counsel"),
("other", "Other"),
],
required=True,
default="opposing_party",
tracking=True,
)
notes = fields.Text()

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class FamilyLawProceeding(models.Model):
_name = "familylaw.proceeding"
_description = "Proceeding"
_inherit = ["mail.thread"]
_order = "date_opened desc"
case_id = fields.Many2one(
"familylaw.case",
required=True,
ondelete="cascade",
index=True,
tracking=True,
)
name = fields.Char(
string="Proceeding",
required=True,
tracking=True,
)
proceeding_type = fields.Selection(
selection=[
("original", "Original Action"),
("modification", "Modification"),
("enforcement", "Enforcement / Contempt"),
("appeal", "Appeal"),
("other", "Other"),
],
string="Type",
required=True,
default="original",
tracking=True,
)
state = fields.Selection(
selection=[
("open", "Open"),
("closed", "Closed"),
],
default="open",
required=True,
tracking=True,
)
proceeding_number = fields.Char(
string="Court-Assigned Number",
help="If the court assigns a distinct number for this proceeding.",
)
date_opened = fields.Date(
string="Date Opened",
default=fields.Date.context_today,
)
date_closed = fields.Date(string="Date Closed")
notes = fields.Text()
def action_close_proceeding(self):
for proc in self:
proc.state = "closed"
proc.date_closed = fields.Date.context_today(self)
proc.message_post(body="Proceeding closed.")
def action_reopen_proceeding(self):
for proc in self:
proc.state = "open"
proc.date_closed = False
proc.message_post(body="Proceeding reopened.")

View File

@@ -1,3 +1,14 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_familylaw_case_user,familylaw.case staff,model_familylaw_case,group_familylaw_user,1,1,1,0 access_familylaw_case_user,familylaw.case staff,model_familylaw_case,group_familylaw_user,1,1,1,0
access_familylaw_case_attorney,familylaw.case attorney,model_familylaw_case,group_familylaw_attorney,1,1,1,1 access_familylaw_case_attorney,familylaw.case attorney,model_familylaw_case,group_familylaw_attorney,1,1,1,1
access_familylaw_party_user,familylaw.party staff,model_familylaw_party,group_familylaw_user,1,1,1,0
access_familylaw_party_attorney,familylaw.party attorney,model_familylaw_party,group_familylaw_attorney,1,1,1,1
access_familylaw_child_user,familylaw.child staff,model_familylaw_child,group_familylaw_user,1,1,1,0
access_familylaw_child_attorney,familylaw.child attorney,model_familylaw_child,group_familylaw_attorney,1,1,1,1
access_familylaw_issue_user,familylaw.issue staff,model_familylaw_issue,group_familylaw_user,1,1,1,0
access_familylaw_issue_attorney,familylaw.issue attorney,model_familylaw_issue,group_familylaw_attorney,1,1,1,1
access_familylaw_proceeding_user,familylaw.proceeding staff,model_familylaw_proceeding,group_familylaw_user,1,1,1,0
access_familylaw_proceeding_attorney,familylaw.proceeding attorney,model_familylaw_proceeding,group_familylaw_attorney,1,1,1,1
access_familylaw_conflict_hit_user,familylaw.conflict.hit staff,model_familylaw_conflict_hit,group_familylaw_user,1,1,1,0
access_familylaw_conflict_hit_attorney,familylaw.conflict.hit attorney,model_familylaw_conflict_hit,group_familylaw_attorney,1,1,1,1
access_familylaw_intake_wizard_user,familylaw.intake.wizard user,model_familylaw_intake_wizard,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_familylaw_case_user familylaw.case staff model_familylaw_case group_familylaw_user 1 1 1 0
3 access_familylaw_case_attorney familylaw.case attorney model_familylaw_case group_familylaw_attorney 1 1 1 1
4 access_familylaw_party_user familylaw.party staff model_familylaw_party group_familylaw_user 1 1 1 0
5 access_familylaw_party_attorney familylaw.party attorney model_familylaw_party group_familylaw_attorney 1 1 1 1
6 access_familylaw_child_user familylaw.child staff model_familylaw_child group_familylaw_user 1 1 1 0
7 access_familylaw_child_attorney familylaw.child attorney model_familylaw_child group_familylaw_attorney 1 1 1 1
8 access_familylaw_issue_user familylaw.issue staff model_familylaw_issue group_familylaw_user 1 1 1 0
9 access_familylaw_issue_attorney familylaw.issue attorney model_familylaw_issue group_familylaw_attorney 1 1 1 1
10 access_familylaw_proceeding_user familylaw.proceeding staff model_familylaw_proceeding group_familylaw_user 1 1 1 0
11 access_familylaw_proceeding_attorney familylaw.proceeding attorney model_familylaw_proceeding group_familylaw_attorney 1 1 1 1
12 access_familylaw_conflict_hit_user familylaw.conflict.hit staff model_familylaw_conflict_hit group_familylaw_user 1 1 1 0
13 access_familylaw_conflict_hit_attorney familylaw.conflict.hit attorney model_familylaw_conflict_hit group_familylaw_attorney 1 1 1 1
14 access_familylaw_intake_wizard_user familylaw.intake.wizard user model_familylaw_intake_wizard base.group_user 1 1 1 1

View File

@@ -1 +1,2 @@
from . import test_case_lifecycle from . import test_case_lifecycle
from . import test_step2

View File

@@ -0,0 +1,460 @@
# -*- coding: utf-8 -*-
"""STEP 2 tests — parties, children, issues, proceedings, conflict screening,
intake questionnaire (strict / emergency fast-path).
Run just this step:
odoo -d <db> -u activeblue_familylaw --test-enable \
--test-tags familylaw_step2 --stop-after-init
All tests roll back inside a savepoint. No network calls.
"""
from datetime import date, timedelta
from odoo.tests.common import TransactionCase, new_test_user, tagged
from odoo.exceptions import UserError, ValidationError
@tagged("post_install", "-at_install", "familylaw", "familylaw_step2")
class TestStep2RelationsAndProceeding(TransactionCase):
"""Relation integrity, DOB constraint, initial proceeding creation."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner_a = cls.env["res.partner"].create({"name": "Client A"})
cls.partner_b = cls.env["res.partner"].create({"name": "Client B"})
cls.attorney = new_test_user(
cls.env,
login="fl_atty2",
name="Test Attorney 2",
email="atty2@example.com",
groups="base.group_user,activeblue_familylaw.group_familylaw_attorney",
)
cls.paralegal = new_test_user(
cls.env,
login="fl_para2",
name="Test Paralegal 2",
email="para2@example.com",
groups="base.group_user,activeblue_familylaw.group_familylaw_user",
)
cls.Case = cls.env["familylaw.case"]
def _make_case(self, case_type="dissolution_children", **kw):
vals = {
"name": "Test Matter",
"client_id": self.partner_a.id,
"case_type": case_type,
}
vals.update(kw)
return self.Case.create(vals)
# --- initial proceeding -------------------------------------------------
def test_01_case_create_opens_initial_proceeding(self):
case = self._make_case()
self.assertEqual(len(case.proceeding_ids), 1)
proc = case.proceeding_ids[0]
self.assertEqual(proc.state, "open")
def test_02_modification_case_creates_modification_proceeding(self):
case = self._make_case(case_type="support_modification")
proc = case.proceeding_ids[0]
self.assertEqual(proc.proceeding_type, "modification")
def test_03_original_case_creates_original_proceeding(self):
case = self._make_case(case_type="dissolution_children")
proc = case.proceeding_ids[0]
self.assertEqual(proc.proceeding_type, "original")
# --- multiple proceedings -----------------------------------------------
def test_04_case_can_hold_multiple_proceedings(self):
case = self._make_case()
self.env["familylaw.proceeding"].create({
"case_id": case.id,
"name": "Second Proceeding",
"proceeding_type": "modification",
})
self.assertEqual(len(case.proceeding_ids), 2)
states = set(case.proceeding_ids.mapped("state"))
self.assertEqual(states, {"open"})
def test_05_proceeding_states_are_independent(self):
case = self._make_case()
extra = self.env["familylaw.proceeding"].create({
"case_id": case.id,
"name": "Extra",
"proceeding_type": "enforcement",
})
extra.action_close_proceeding()
self.assertEqual(extra.state, "closed")
# Initial proceeding stays open
initial = case.proceeding_ids.filtered(lambda p: p != extra)
self.assertEqual(initial.state, "open")
# --- parties ------------------------------------------------------------
def test_06_party_links_to_case(self):
case = self._make_case()
party = self.env["familylaw.party"].create({
"case_id": case.id,
"name": "Jane Doe",
"role": "opposing_party",
})
self.assertIn(party, case.party_ids)
# --- children -----------------------------------------------------------
def test_07_child_links_to_case(self):
case = self._make_case()
child = self.env["familylaw.child"].create({
"case_id": case.id,
"name": "Minor Child A",
"date_of_birth": date.today() - timedelta(days=365 * 5),
})
self.assertIn(child, case.child_ids)
def test_08_dob_future_rejected(self):
case = self._make_case()
with self.assertRaises(ValidationError):
self.env["familylaw.child"].create({
"case_id": case.id,
"name": "Future Child",
"date_of_birth": date.today() + timedelta(days=1),
})
def test_09_dob_over_25_rejected(self):
case = self._make_case()
implausible_dob = date.today().replace(year=date.today().year - 26)
with self.assertRaises(ValidationError):
self.env["familylaw.child"].create({
"case_id": case.id,
"name": "Old Child",
"date_of_birth": implausible_dob,
})
def test_10_dob_valid_accepted(self):
case = self._make_case()
valid_dob = date.today() - timedelta(days=365 * 8)
child = self.env["familylaw.child"].create({
"case_id": case.id,
"name": "Young Child",
"date_of_birth": valid_dob,
})
self.assertTrue(child.id)
self.assertEqual(child.age, 8)
# --- issues -------------------------------------------------------------
def test_11_issue_links_to_case(self):
case = self._make_case()
issue = self.env["familylaw.issue"].create({
"case_id": case.id,
"issue_type": "child_support",
})
self.assertIn(issue, case.issue_ids)
@tagged("post_install", "-at_install", "familylaw", "familylaw_step2")
class TestStep2SearchByPerson(TransactionCase):
"""Find a case by party name, child name, and case_number."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env["res.partner"].create({"name": "Search Client"})
cls.case = cls.env["familylaw.case"].create({
"name": "Search Matter",
"client_id": cls.partner.id,
"case_type": "dissolution_children",
"case_number": "2024-DR-999999",
})
cls.env["familylaw.party"].create({
"case_id": cls.case.id,
"name": "John Opposing",
"role": "opposing_party",
})
cls.env["familylaw.child"].create({
"case_id": cls.case.id,
"name": "Little One",
"date_of_birth": date.today() - timedelta(days=365 * 6),
})
def test_12_find_by_case_number(self):
results = self.env["familylaw.case"].search(
[("case_number", "=", "2024-DR-999999")]
)
self.assertIn(self.case, results)
def test_13_find_by_party_name(self):
results = self.env["familylaw.case"].search(
[("party_ids.name", "ilike", "John Opposing")]
)
self.assertIn(self.case, results)
def test_14_find_by_child_name(self):
results = self.env["familylaw.case"].search(
[("child_ids.name", "ilike", "Little One")]
)
self.assertIn(self.case, results)
@tagged("post_install", "-at_install", "familylaw", "familylaw_step2")
class TestStep2ConflictScreening(TransactionCase):
"""Conflict screening surfaces a past-client opposing party; does not auto-clear."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.attorney = new_test_user(
cls.env,
login="fl_atty_conflict",
name="Conflict Attorney",
email="catty@example.com",
groups="base.group_user,activeblue_familylaw.group_familylaw_attorney",
)
# Case A: "Old Client" is the client
cls.old_client = cls.env["res.partner"].create({"name": "Old Client"})
cls.case_a = cls.env["familylaw.case"].create({
"name": "Old Matter",
"client_id": cls.old_client.id,
"case_type": "dissolution_children",
})
# Case B: "Old Client" appears as an opposing party
cls.new_client = cls.env["res.partner"].create({"name": "New Client"})
cls.case_b = cls.env["familylaw.case"].create({
"name": "New Matter",
"client_id": cls.new_client.id,
"case_type": "support_modification",
})
cls.env["familylaw.party"].create({
"case_id": cls.case_b.id,
"name": "Old Client",
"role": "opposing_party",
})
def test_15_conflict_screening_finds_client_match(self):
self.case_b.action_run_conflict_screening()
hits = self.case_b.conflict_hit_ids
self.assertTrue(hits, "Expected at least one conflict hit for 'Old Client'")
def test_16_conflict_does_not_auto_clear(self):
self.case_b.action_run_conflict_screening()
self.assertFalse(
self.case_b.conflict_check_cleared,
"Conflict screening must never auto-clear the attorney gate.",
)
def test_17_conflict_hit_count_updates(self):
self.case_b.action_run_conflict_screening()
self.assertGreater(self.case_b.conflict_hit_count, 0)
def test_18_engage_blocked_with_uncleared_conflict(self):
"""Ensure Step 1's engage gate still works after Step 2 additions."""
with self.assertRaises(UserError):
self.case_b.action_engage()
def test_19_attorney_can_clear_conflict_after_reviewing_hits(self):
self.case_b.action_run_conflict_screening()
self.case_b.with_user(self.attorney).action_mark_conflict_cleared()
self.assertTrue(self.case_b.conflict_check_cleared)
def test_20_no_conflict_for_unrelated_opposing_party(self):
unique_client = self.env["res.partner"].create({"name": "Completely Unique Person XYZ"})
case_c = self.env["familylaw.case"].create({
"name": "Unrelated Matter",
"client_id": unique_client.id,
"case_type": "paternity",
})
self.env["familylaw.party"].create({
"case_id": case_c.id,
"name": "Completely Unique Opposing XYZ",
"role": "opposing_party",
})
case_c.action_run_conflict_screening()
self.assertEqual(case_c.conflict_hit_count, 0)
@tagged("post_install", "-at_install", "familylaw", "familylaw_step2")
class TestStep2IntakeWizard(TransactionCase):
"""Intake questionnaire — strict path, emergency fast-path, triage, question capture."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Wizard = cls.env["familylaw.intake.wizard"]
cls.partner = cls.env["res.partner"].create({"name": "Existing Client"})
def _make_wizard(self, **vals):
return self.Wizard.create(vals)
# --- standard path strict validation ------------------------------------
def test_21_standard_path_requires_matter_name(self):
wiz = self._make_wizard(
step="details",
case_type="dissolution_children",
county="miami_dade",
caller_name="Caller X",
)
with self.assertRaises(UserError):
wiz._validate_standard_details()
def test_22_standard_path_requires_case_type(self):
wiz = self._make_wizard(
step="details",
matter_name="Test Matter",
county="miami_dade",
caller_name="Caller X",
)
with self.assertRaises(UserError):
wiz._validate_standard_details()
def test_23_standard_path_requires_county(self):
wiz = self._make_wizard(
step="details",
matter_name="Test Matter",
case_type="dissolution_children",
caller_name="Caller X",
county=False,
)
with self.assertRaises(UserError):
wiz._validate_standard_details()
def test_24_standard_path_requires_client_or_caller_name(self):
wiz = self._make_wizard(
step="details",
matter_name="Test Matter",
case_type="dissolution_children",
county="miami_dade",
)
with self.assertRaises(UserError):
wiz._validate_standard_details()
def test_25_standard_path_creates_case(self):
wiz = self._make_wizard(
caller_name="John Smith",
case_type="dissolution_children",
county="miami_dade",
)
wiz.matter_name = "In re Smith"
wiz.client_partner_id = self.partner.id
wiz._create_standard_case()
case = wiz.created_case_id
self.assertTrue(case)
self.assertEqual(case.state, "intake")
self.assertFalse(case.is_emergency)
def test_26_standard_case_gets_initial_proceeding(self):
wiz = self._make_wizard(
caller_name="Jane Doe",
case_type="support_modification",
county="miami_dade",
)
wiz.matter_name = "In re Doe"
wiz.client_partner_id = self.partner.id
wiz._create_standard_case()
case = wiz.created_case_id
self.assertEqual(len(case.proceeding_ids), 1)
self.assertEqual(case.proceeding_ids[0].proceeding_type, "modification")
# --- triage sets case type ----------------------------------------------
def test_27_triage_case_type_propagates_to_case(self):
wiz = self._make_wizard(
caller_name="Alice",
case_type="paternity",
county="miami_dade",
)
wiz.matter_name = "Paternity Matter"
wiz.client_partner_id = self.partner.id
wiz._create_standard_case()
self.assertEqual(wiz.created_case_id.case_type, "paternity")
# --- emergency fast-path ------------------------------------------------
def test_28_emergency_path_creates_case_on_minimum_facts(self):
wiz = self._make_wizard(
caller_name="Emergency Caller",
urgency_child_withheld=True,
)
self.assertTrue(wiz.is_emergency)
wiz._create_emergency_case()
case = wiz.created_case_id
self.assertTrue(case, "Emergency case should be created")
self.assertEqual(case.state, "intake")
self.assertTrue(case.is_emergency)
def test_29_emergency_flag_set_on_case(self):
wiz = self._make_wizard(
caller_name="Urgent Caller",
urgency_violence=True,
)
wiz._create_emergency_case()
self.assertTrue(wiz.created_case_id.is_emergency)
def test_30_emergency_creates_partner_from_caller_name(self):
wiz = self._make_wizard(
caller_name="Brand New Person",
urgency_removal_threat=True,
)
wiz._create_emergency_case()
case = wiz.created_case_id
self.assertEqual(case.client_id.name, "Brand New Person")
def test_31_emergency_urgency_notes_captured(self):
wiz = self._make_wizard(
caller_name="Person In Danger",
urgency_violence=True,
urgency_description="Ex-partner threatened caller this morning.",
)
wiz._create_emergency_case()
self.assertIn("Ex-partner", wiz.created_case_id.urgency_notes)
# --- caller concern captured as attorney question, never answered -------
def test_32_caller_concern_logged_on_case_not_answered(self):
wiz = self._make_wizard(
caller_name="Concerned Caller",
case_type="support_modification",
county="miami_dade",
caller_concern="Do I have a good case?",
)
wiz.matter_name = "Concern Matter"
wiz.client_partner_id = self.partner.id
wiz._create_standard_case()
case = wiz.created_case_id
chatter_bodies = " ".join(
msg.body for msg in case.message_ids if msg.body
)
self.assertIn("Do I have a good case?", chatter_bodies)
self.assertIn("attorney", chatter_bodies.lower())
def test_33_emergency_concern_also_logged(self):
wiz = self._make_wizard(
caller_name="Emergency Concern",
urgency_child_withheld=True,
caller_concern="Am I going to win this?",
)
wiz._create_emergency_case()
case = wiz.created_case_id
chatter_bodies = " ".join(msg.body for msg in case.message_ids if msg.body)
self.assertIn("Am I going to win this?", chatter_bodies)
# --- opposing party + conflict screening via wizard ---------------------
def test_34_wizard_adds_opposing_party_and_screens(self):
# Set up an existing case with this person as a client
existing_client = self.env["res.partner"].create({"name": "Opposing Client Match"})
self.env["familylaw.case"].create({
"name": "Existing Matter",
"client_id": existing_client.id,
"case_type": "dissolution_children",
})
wiz = self._make_wizard(
caller_name="New Caller",
case_type="enforcement",
county="miami_dade",
opposing_party_name="Opposing Client Match",
)
wiz.matter_name = "Enforcement Matter"
wiz.client_partner_id = self.partner.id
wiz._create_standard_case()
case = wiz.created_case_id
# Should have at least one party record
self.assertTrue(case.party_ids)
# Should have at least one conflict hit
self.assertGreater(case.conflict_hit_count, 0)

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<!-- LIST (Odoo 18 uses <list>, not <tree>) --> <!-- LIST view -->
<record id="view_familylaw_case_list" model="ir.ui.view"> <record id="view_familylaw_case_list" model="ir.ui.view">
<field name="name">familylaw.case.list</field> <field name="name">familylaw.case.list</field>
<field name="model">familylaw.case</field> <field name="model">familylaw.case</field>
@@ -9,10 +9,12 @@
<list string="Cases"> <list string="Cases">
<field name="name"/> <field name="name"/>
<field name="client_id"/> <field name="client_id"/>
<field name="case_number"/>
<field name="case_type"/> <field name="case_type"/>
<field name="attorney_id"/> <field name="attorney_id"/>
<field name="representation"/> <field name="representation"/>
<field name="conflict_check_cleared"/> <field name="conflict_check_cleared"/>
<field name="is_emergency" widget="boolean_toggle" optional="show"/>
<field name="state" widget="badge" <field name="state" widget="badge"
decoration-success="state == 'closed'" decoration-success="state == 'closed'"
decoration-info="state in ('intake','engaged')" decoration-info="state in ('intake','engaged')"
@@ -28,12 +30,16 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<form string="Case"> <form string="Case">
<header> <header>
<!-- Conflict screening — available to all users -->
<button name="action_run_conflict_screening" type="object"
string="Run Conflict Screening"
invisible="state != 'intake'"/>
<!-- Attorney-only gate: clear the conflict check --> <!-- Attorney-only gate: clear the conflict check -->
<button name="action_mark_conflict_cleared" type="object" <button name="action_mark_conflict_cleared" type="object"
string="Clear Conflict Check" class="btn-primary" string="Clear Conflict Check" class="btn-warning"
invisible="conflict_check_cleared" invisible="conflict_check_cleared"
groups="activeblue_familylaw.group_familylaw_attorney"/> groups="activeblue_familylaw.group_familylaw_attorney"/>
<!-- Forward transitions (visible only from the valid stage) --> <!-- Forward transitions -->
<button name="action_engage" type="object" string="Engage" <button name="action_engage" type="object" string="Engage"
class="btn-primary" invisible="state != 'intake'"/> class="btn-primary" invisible="state != 'intake'"/>
<button name="action_start_disclosure" type="object" <button name="action_start_disclosure" type="object"
@@ -55,16 +61,34 @@
statusbar_visible="intake,engaged,disclosure,discovery,mediation,hearing,closed"/> statusbar_visible="intake,engaged,disclosure,discovery,mediation,hearing,closed"/>
</header> </header>
<sheet> <sheet>
<!-- Emergency banner -->
<div class="alert alert-danger" role="alert"
invisible="not is_emergency">
⚠️ EMERGENCY MATTER — Opened on minimum facts. Complete this record immediately.
<field name="urgency_notes" readonly="1" nolabel="1"/>
</div>
<!-- Conflict warning banner -->
<div class="alert alert-warning" role="alert"
invisible="conflict_hit_count == 0 or conflict_check_cleared">
⚠️ <field name="conflict_hit_count" readonly="1" nolabel="1"/> potential conflict(s) detected.
See the Conflict Hits tab. Attorney must review and clear before engaging.
</div>
<div class="oe_title"> <div class="oe_title">
<label for="name"/> <label for="name"/>
<h1> <h1>
<field name="name" placeholder="e.g. In re the Marriage of Client A"/> <field name="name" placeholder="e.g. In re the Marriage of Client A"/>
</h1> </h1>
</div> </div>
<group> <group>
<group string="Matter"> <group string="Matter">
<field name="client_id"/> <field name="client_id"/>
<field name="case_type"/> <field name="case_type"/>
<field name="case_number"
placeholder="e.g. 2024-DR-001234 (court-assigned)"/>
<field name="county"/>
<field name="representation"/> <field name="representation"/>
<field name="date_opened"/> <field name="date_opened"/>
</group> </group>
@@ -72,11 +96,46 @@
<field name="attorney_id"/> <field name="attorney_id"/>
<field name="paralegal_id"/> <field name="paralegal_id"/>
<field name="conflict_check_cleared" readonly="1"/> <field name="conflict_check_cleared" readonly="1"/>
<field name="is_emergency"/>
</group> </group>
</group> </group>
<notebook>
<page string="Parties" name="parties">
<field name="party_ids"
view_id="view_familylaw_party_inline_list"/>
</page>
<page string="Children" name="children">
<field name="child_ids"
view_id="view_familylaw_child_inline_list"/>
</page>
<page string="Issues" name="issues">
<field name="issue_ids"
view_id="view_familylaw_issue_inline_list"/>
</page>
<page string="Proceedings" name="proceedings">
<field name="proceeding_ids"
view_id="view_familylaw_proceeding_inline_list"/>
</page>
<page string="Conflict Hits" name="conflict_hits"
invisible="conflict_hit_count == 0">
<field name="conflict_hit_ids">
<list>
<field name="party_name"/>
<field name="hit_case_id"/>
<field name="hit_role"/>
<field name="reason"/>
</list>
</field>
</page>
<page string="Emergency Notes" name="emergency"
invisible="not is_emergency">
<group>
<field name="urgency_notes"/>
</group>
</page>
</notebook>
</sheet> </sheet>
<!-- Audit trail. Odoo 18 also offers the shorthand <chatter/> tag;
this explicit form is used for maximum cross-release safety. -->
<div class="oe_chatter"> <div class="oe_chatter">
<field name="message_follower_ids"/> <field name="message_follower_ids"/>
<field name="activity_ids"/> <field name="activity_ids"/>
@@ -86,7 +145,7 @@
</field> </field>
</record> </record>
<!-- SEARCH --> <!-- SEARCH — extended to support party name, child name, case number -->
<record id="view_familylaw_case_search" model="ir.ui.view"> <record id="view_familylaw_case_search" model="ir.ui.view">
<field name="name">familylaw.case.search</field> <field name="name">familylaw.case.search</field>
<field name="model">familylaw.case</field> <field name="model">familylaw.case</field>
@@ -95,11 +154,18 @@
<field name="name"/> <field name="name"/>
<field name="client_id"/> <field name="client_id"/>
<field name="attorney_id"/> <field name="attorney_id"/>
<field name="case_number" string="Case Number"/>
<field name="party_ids" string="Party Name"
filter_domain="[('party_ids.name', 'ilike', self)]"/>
<field name="child_ids" string="Child Name"
filter_domain="[('child_ids.name', 'ilike', self)]"/>
<filter name="my_cases" string="My Cases" <filter name="my_cases" string="My Cases"
domain="[('attorney_id', '=', uid)]"/> domain="[('attorney_id', '=', uid)]"/>
<separator/> <separator/>
<filter name="open_cases" string="Open" <filter name="open_cases" string="Open"
domain="[('state', '!=', 'closed')]"/> domain="[('state', '!=', 'closed')]"/>
<filter name="emergency_cases" string="Emergency"
domain="[('is_emergency', '=', True)]"/>
<filter name="needs_conflict_clearance" string="Awaiting Conflict Clearance" <filter name="needs_conflict_clearance" string="Awaiting Conflict Clearance"
domain="[('conflict_check_cleared', '=', False), ('state', '=', 'intake')]"/> domain="[('conflict_check_cleared', '=', False), ('state', '=', 'intake')]"/>
<group expand="0" string="Group By"> <group expand="0" string="Group By">
@@ -123,8 +189,9 @@
<field name="context">{'search_default_open_cases': 1}</field> <field name="context">{'search_default_open_cases': 1}</field>
<field name="help" type="html"> <field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Open your first matter</p> <p class="o_view_nocontent_smiling_face">Open your first matter</p>
<p>Create a case to begin. New matters start in Intake; an attorney <p>Create a case or use New Intake to start from the triage questionnaire.
clears the conflict check, then the matter can be engaged.</p> New matters start in Intake; an attorney clears the conflict check,
then the matter can be engaged.</p>
</field> </field>
</record> </record>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_familylaw_child_list" model="ir.ui.view">
<field name="name">familylaw.child.list</field>
<field name="model">familylaw.child</field>
<field name="arch" type="xml">
<list string="Children">
<field name="name"/>
<field name="date_of_birth"/>
<field name="age"/>
<field name="case_id"/>
</list>
</field>
</record>
<record id="view_familylaw_child_form" model="ir.ui.view">
<field name="name">familylaw.child.form</field>
<field name="model">familylaw.child</field>
<field name="arch" type="xml">
<form string="Child">
<sheet>
<group>
<group string="Identity">
<field name="name"/>
<field name="date_of_birth"/>
<field name="age" readonly="1"/>
</group>
<group string="Matter">
<field name="case_id"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Inline list used inside the case form notebook -->
<record id="view_familylaw_child_inline_list" model="ir.ui.view">
<field name="name">familylaw.child.inline.list</field>
<field name="model">familylaw.child</field>
<field name="arch" type="xml">
<list string="Children" editable="bottom">
<field name="name"/>
<field name="date_of_birth"/>
<field name="age" readonly="1"/>
<field name="notes"/>
</list>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_familylaw_intake_wizard_form" model="ir.ui.view">
<field name="name">familylaw.intake.wizard.form</field>
<field name="model">familylaw.intake.wizard</field>
<field name="arch" type="xml">
<form string="Family Law Intake">
<sheet>
<!-- Hidden step tracker for conditional visibility -->
<field name="step" invisible="1"/>
<field name="is_emergency" invisible="1"/>
<!-- ===== STEP 1 — TRIAGE ===== -->
<div invisible="step != 'triage'">
<h2>Step 1 — Triage</h2>
<group string="Caller Information">
<field name="caller_name"/>
<field name="caller_phone"/>
<field name="caller_email"/>
<field name="county"/>
<field name="case_type"/>
<field name="opposing_party_name"/>
<field name="has_children"/>
<field name="has_existing_order"/>
</group>
<group string="Urgency Screen — answer all three">
<field name="urgency_child_withheld"/>
<field name="urgency_removal_threat"/>
<field name="urgency_violence"/>
<field name="urgency_description"
invisible="not (urgency_child_withheld or urgency_removal_threat or urgency_violence)"
placeholder="Describe the emergency — captured for the attorney"/>
</group>
<div class="alert alert-danger" role="alert"
invisible="not (urgency_child_withheld or urgency_removal_threat or urgency_violence)">
Emergency flags detected — clicking Next will open the matter immediately
on minimum facts. Complete the record in full after the call.
</div>
</div>
<!-- ===== STEP 2 — CASE DETAILS (standard path) ===== -->
<div invisible="step != 'details'">
<h2>Step 2 — Case Details</h2>
<group string="Matter">
<field name="matter_name" required="step == 'details'"/>
<field name="client_partner_id"/>
<field name="county"/>
<field name="case_type" required="step == 'details'"/>
</group>
<group string="Team">
<field name="attorney_id"/>
<field name="paralegal_id"/>
</group>
<group string="Caller's Question for the Attorney">
<field name="caller_concern" nolabel="1"
placeholder="Record the caller's question verbatim. This software does not answer 'do I have a good case?' — that determination is the attorney's."/>
</group>
</div>
<!-- ===== STEP 3 — MODIFICATION DETAILS ===== -->
<div invisible="step != 'modification'">
<h2>Step 3 — Modification Details</h2>
<group string="Prior Order">
<field name="existing_order_summary"
placeholder="Brief summary of what the existing order says — facts only. Legal interpretation is Step 6+ (attorney-reviewed)."/>
</group>
<group string="Reason for Modification">
<field name="reason_for_modification"
placeholder="What has changed since the last order?"/>
<field name="income_change_details"
placeholder="Income / financial changes (e.g. job loss, new employment). Facts only — no legal conclusions."/>
</group>
<group string="Caller's Question for the Attorney">
<field name="caller_concern" nolabel="1"
placeholder="Record the caller's question verbatim. Do not answer 'do I have a good case?' — that is the attorney's determination."/>
</group>
</div>
<!-- ===== STEP 4 — CONFIRM ===== -->
<div invisible="step != 'confirm'">
<h2>Step 4 — Intake Complete</h2>
<group string="Matter Created">
<field name="created_case_id" readonly="1"/>
</group>
<group string="Conflict Screening Results">
<field name="conflict_summary" nolabel="1" readonly="1"/>
</group>
<div class="alert alert-warning" role="alert"
invisible="not conflict_summary or 'No potential' in (conflict_summary or '')">
Potential conflicts detected. The attorney must review the Conflict Hits
tab on the case and clear the conflict check before engaging.
</div>
<div class="alert alert-info" role="alert">
The matter has been opened in <b>Intake</b> state.
An attorney must clear the conflict check before the matter can be engaged.
</div>
</div>
</sheet>
<footer>
<button string="← Back" type="object" name="action_back"
invisible="step in ('triage', 'confirm')"/>
<button string="Next →" type="object" name="action_next"
class="btn-primary"
invisible="step == 'confirm'"/>
<button string="Open Matter" type="object" name="action_open_case"
class="btn-primary"
invisible="step != 'confirm'"/>
<button string="Cancel" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_familylaw_intake_wizard" model="ir.actions.act_window">
<field name="name">New Intake</field>
<field name="res_model">familylaw.intake.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Inline list used inside the case form notebook -->
<record id="view_familylaw_issue_inline_list" model="ir.ui.view">
<field name="name">familylaw.issue.inline.list</field>
<field name="model">familylaw.issue</field>
<field name="arch" type="xml">
<list string="Issues" editable="bottom">
<field name="issue_type"/>
<field name="description"/>
</list>
</field>
</record>
</odoo>

View File

@@ -6,6 +6,13 @@
name="Family Law" name="Family Law"
sequence="50"/> sequence="50"/>
<!-- New Intake — opens the triage wizard -->
<menuitem id="menu_familylaw_intake"
name="New Intake"
parent="menu_familylaw_root"
action="action_familylaw_intake_wizard"
sequence="5"/>
<!-- Cases --> <!-- Cases -->
<menuitem id="menu_familylaw_cases" <menuitem id="menu_familylaw_cases"
name="Cases" name="Cases"

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_familylaw_party_list" model="ir.ui.view">
<field name="name">familylaw.party.list</field>
<field name="model">familylaw.party</field>
<field name="arch" type="xml">
<list string="Parties">
<field name="name"/>
<field name="role" widget="badge"/>
<field name="partner_id"/>
<field name="case_id"/>
</list>
</field>
</record>
<record id="view_familylaw_party_form" model="ir.ui.view">
<field name="name">familylaw.party.form</field>
<field name="model">familylaw.party</field>
<field name="arch" type="xml">
<form string="Party">
<sheet>
<group>
<group string="Identity">
<field name="name"/>
<field name="role"/>
<field name="partner_id"/>
</group>
<group string="Matter">
<field name="case_id"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Inline list used inside the case form notebook -->
<record id="view_familylaw_party_inline_list" model="ir.ui.view">
<field name="name">familylaw.party.inline.list</field>
<field name="model">familylaw.party</field>
<field name="arch" type="xml">
<list string="Parties" editable="bottom">
<field name="name"/>
<field name="role" widget="badge"/>
<field name="partner_id"/>
<field name="notes"/>
</list>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_familylaw_proceeding_list" model="ir.ui.view">
<field name="name">familylaw.proceeding.list</field>
<field name="model">familylaw.proceeding</field>
<field name="arch" type="xml">
<list string="Proceedings">
<field name="name"/>
<field name="case_id"/>
<field name="proceeding_type"/>
<field name="state" widget="badge"
decoration-success="state == 'closed'"
decoration-info="state == 'open'"/>
<field name="date_opened"/>
<field name="date_closed"/>
</list>
</field>
</record>
<record id="view_familylaw_proceeding_form" model="ir.ui.view">
<field name="name">familylaw.proceeding.form</field>
<field name="model">familylaw.proceeding</field>
<field name="arch" type="xml">
<form string="Proceeding">
<header>
<button name="action_close_proceeding" type="object"
string="Close Proceeding"
invisible="state == 'closed'"/>
<button name="action_reopen_proceeding" type="object"
string="Reopen"
invisible="state == 'open'"/>
<field name="state" widget="statusbar"
statusbar_visible="open,closed"/>
</header>
<sheet>
<group>
<group string="Proceeding">
<field name="name"/>
<field name="proceeding_type"/>
<field name="proceeding_number"/>
</group>
<group string="Matter">
<field name="case_id"/>
<field name="date_opened"/>
<field name="date_closed" readonly="1"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Inline list used inside the case form notebook -->
<record id="view_familylaw_proceeding_inline_list" model="ir.ui.view">
<field name="name">familylaw.proceeding.inline.list</field>
<field name="model">familylaw.proceeding</field>
<field name="arch" type="xml">
<list string="Proceedings" editable="bottom">
<field name="name"/>
<field name="proceeding_type"/>
<field name="proceeding_number"/>
<field name="state" widget="badge"
decoration-success="state == 'closed'"
decoration-info="state == 'open'"/>
<field name="date_opened"/>
<field name="date_closed" readonly="1"/>
</list>
</field>
</record>
</odoo>