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 -*-
{
"name": "Active Blue Family Law",
"version": "18.0.1.0.0",
"version": "18.0.2.0.0",
"category": "Services/Legal",
"summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)",
"description": """
Active Blue Family Law
======================
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
state machine, security groups (attorney / staff), and the case views.
steps. Step 1 delivers the case spine; Step 2 adds parties, children, issues,
the proceeding layer, automated conflict screening, and the intake questionnaire.
Each subsequent step adds one vertical, independently testable slice. See
BUILD_PLAN.md for the full sequence and the test method.
@@ -27,6 +27,11 @@ signing, citation verification, wire map, training, forms & playbook).
"data": [
"security/familylaw_security.xml",
"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_menus.xml",
],

View File

@@ -1 +1,7 @@
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 -*-
"""STEP 1 — The case spine.
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.
"""STEP 1 — Case spine (updated in Step 2: case_number, county, emergency flag,
related records, initial-proceeding creation, conflict screening).
"""
from odoo import api, fields, models, _
@@ -17,7 +8,6 @@ from odoo.exceptions import UserError
ATTORNEY_GROUP = "activeblue_familylaw.group_familylaw_attorney"
# The case lifecycle. Order matters: it defines the legal "forward" path.
STATE_SEQUENCE = [
"intake",
"engaged",
@@ -28,6 +18,12 @@ STATE_SEQUENCE = [
"closed",
]
_MODIFICATION_TYPES = {
"support_modification",
"parenting_modification",
"alimony_modification",
}
class FamilyLawCase(models.Model):
_name = "familylaw.case"
@@ -48,6 +44,24 @@ class FamilyLawCase(models.Model):
required=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(
selection=[
("dissolution_no_children", "Dissolution — no children"),
@@ -78,7 +92,7 @@ class FamilyLawCase(models.Model):
tracking=True,
)
# --- Representation (drives the subpoena workflow in a later step) ------
# --- Representation (drives the Step 8 subpoena branch) -----------------
representation = fields.Selection(
selection=[
("attorney", "Attorney of Record"),
@@ -89,7 +103,20 @@ class FamilyLawCase(models.Model):
required=True,
tracking=True,
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 -----------------------------------------------------
@@ -99,7 +126,7 @@ class FamilyLawCase(models.Model):
copy=False,
tracking=True,
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(
selection=[
@@ -117,16 +144,158 @@ class FamilyLawCase(models.Model):
copy=False,
tracking=True,
)
date_opened = fields.Date(
string="Date Opened",
default=fields.Date.context_today,
)
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 ---------------------------------------------------
def _ensure_attorney(self):
"""Raise unless the acting user is in the attorney group."""
if not self.env.user.has_group(ATTORNEY_GROUP):
raise UserError(
_("Only a licensed attorney (Family Law / Attorney group) may "
@@ -134,7 +303,6 @@ class FamilyLawCase(models.Model):
)
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)
if bad:
raise UserError(
@@ -145,28 +313,26 @@ class FamilyLawCase(models.Model):
)
def _advance_to(self, target):
"""Write the new state and log it on the chatter."""
for case in self:
case.state = target
case.message_post(
body=_("Stage changed to: %s") % dict(
self._fields["state"].selection).get(target, target)
body=_("Stage changed to: %s")
% dict(self._fields["state"].selection).get(target, target)
)
# --- Gated transitions --------------------------------------------------
def action_mark_conflict_cleared(self):
"""Attorney-only: record that the conflict check is clear."""
self._ensure_attorney()
for case in self:
if case.conflict_check_cleared:
continue
case.conflict_check_cleared = True
case.message_post(body=_("Conflict check cleared by %s.")
% self.env.user.name)
case.message_post(
body=_("Conflict check cleared by %s.") % self.env.user.name
)
return True
def action_engage(self):
"""intake -> engaged. Requires a cleared conflict check."""
self._require_state(["intake"])
not_cleared = self.filtered(lambda c: not c.conflict_check_cleared)
if not_cleared:
@@ -178,38 +344,32 @@ class FamilyLawCase(models.Model):
return True
def action_start_disclosure(self):
"""engaged -> disclosure (Rule 12.285 phase begins in a later step)."""
self._require_state(["engaged"])
self._advance_to("disclosure")
return True
def action_start_discovery(self):
"""disclosure -> discovery."""
self._require_state(["disclosure"])
self._advance_to("discovery")
return True
def action_start_mediation(self):
"""discovery -> mediation (mandatory in Miami-Dade before final hearing)."""
self._require_state(["discovery"])
self._advance_to("mediation")
return True
def action_set_hearing(self):
"""mediation -> hearing / trial."""
self._require_state(["mediation"])
self._advance_to("hearing")
return True
def action_close(self):
"""Attorney-only: close the matter from any non-closed stage."""
self._ensure_attorney()
self._require_state([s for s in STATE_SEQUENCE if s != "closed"])
self._advance_to("closed")
return True
def action_reopen(self):
"""Attorney-only: reopen a closed matter back to engaged."""
self._ensure_attorney()
self._require_state(["closed"])
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
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_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_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"?>
<odoo>
<!-- LIST (Odoo 18 uses <list>, not <tree>) -->
<!-- LIST view -->
<record id="view_familylaw_case_list" model="ir.ui.view">
<field name="name">familylaw.case.list</field>
<field name="model">familylaw.case</field>
@@ -9,10 +9,12 @@
<list string="Cases">
<field name="name"/>
<field name="client_id"/>
<field name="case_number"/>
<field name="case_type"/>
<field name="attorney_id"/>
<field name="representation"/>
<field name="conflict_check_cleared"/>
<field name="is_emergency" widget="boolean_toggle" optional="show"/>
<field name="state" widget="badge"
decoration-success="state == 'closed'"
decoration-info="state in ('intake','engaged')"
@@ -28,12 +30,16 @@
<field name="arch" type="xml">
<form string="Case">
<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 -->
<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"
groups="activeblue_familylaw.group_familylaw_attorney"/>
<!-- Forward transitions (visible only from the valid stage) -->
<!-- Forward transitions -->
<button name="action_engage" type="object" string="Engage"
class="btn-primary" invisible="state != 'intake'"/>
<button name="action_start_disclosure" type="object"
@@ -55,16 +61,34 @@
statusbar_visible="intake,engaged,disclosure,discovery,mediation,hearing,closed"/>
</header>
<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">
<label for="name"/>
<h1>
<field name="name" placeholder="e.g. In re the Marriage of Client A"/>
</h1>
</div>
<group>
<group string="Matter">
<field name="client_id"/>
<field name="case_type"/>
<field name="case_number"
placeholder="e.g. 2024-DR-001234 (court-assigned)"/>
<field name="county"/>
<field name="representation"/>
<field name="date_opened"/>
</group>
@@ -72,11 +96,46 @@
<field name="attorney_id"/>
<field name="paralegal_id"/>
<field name="conflict_check_cleared" readonly="1"/>
<field name="is_emergency"/>
</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>
<!-- Audit trail. Odoo 18 also offers the shorthand <chatter/> tag;
this explicit form is used for maximum cross-release safety. -->
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
@@ -86,7 +145,7 @@
</field>
</record>
<!-- SEARCH -->
<!-- SEARCH — extended to support party name, child name, case number -->
<record id="view_familylaw_case_search" model="ir.ui.view">
<field name="name">familylaw.case.search</field>
<field name="model">familylaw.case</field>
@@ -95,11 +154,18 @@
<field name="name"/>
<field name="client_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"
domain="[('attorney_id', '=', uid)]"/>
<separator/>
<filter name="open_cases" string="Open"
domain="[('state', '!=', 'closed')]"/>
<filter name="emergency_cases" string="Emergency"
domain="[('is_emergency', '=', True)]"/>
<filter name="needs_conflict_clearance" string="Awaiting Conflict Clearance"
domain="[('conflict_check_cleared', '=', False), ('state', '=', 'intake')]"/>
<group expand="0" string="Group By">
@@ -123,8 +189,9 @@
<field name="context">{'search_default_open_cases': 1}</field>
<field name="help" type="html">
<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
clears the conflict check, then the matter can be engaged.</p>
<p>Create a case or use New Intake to start from the triage questionnaire.
New matters start in Intake; an attorney clears the conflict check,
then the matter can be engaged.</p>
</field>
</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"
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 -->
<menuitem id="menu_familylaw_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>