Matters, contacts and parties¶
A matter is the file everything else hangs off: time, invoices, documents,
tasks, events and chats all carry a foreign key to it. This page covers
apps/matters/ (the matter itself, its proceedings, parties, rates,
settlement entries and ledger tab), apps/contacts/ (the firm's address
book and the client relationship) and apps/folders/ (the sidebar folders
contacts are filed in). The Case shell at /case/<id>/ is in
Case building and the two shells that share a matter
are in Architecture.
Where the code is¶
| Module | Holds |
|---|---|
apps/matters/models.py |
Matter, PracticeArea, Group, Role, Relationship |
apps/matters/views.py |
List, filters, detail tabs, add, edit, delete, the open-matters JSON |
apps/matters/forms.py |
MatterForm, ContactComboboxWidget |
apps/matters/filter.py |
MatterFilter (django-filter) for the list |
apps/matters/get_matter_list.py |
Session filter, pagination and the quick-status buttons |
apps/matters/client_wizard.py |
Create a contact or convert an intake from inside the matter form |
apps/matters/templatetags/matter_tags.py |
get_open_matters, get_adjacent_open_matters (the switcher and steppers) |
apps/matters/contacts/ |
The matter's Contacts tab: party rows, assign, groups |
apps/matters/proceedings/, settlement/, rates/ |
One model, form and views each |
apps/matters/ledger/ |
The Ledger tab: get_ledger_data.py, generate_ledger.py (PDF) |
apps/matters/activity/, events/, tasks/, categories/ |
Matter-scoped views over other subsystems' records |
apps/contacts/models.py |
Contact, ContactQuerySet, RelationshipType, ContactRelationship, derive_client_status() |
apps/contacts/access.py, apps/contacts/contacts.py |
Matter and trust checks; get_list_data() for the sidebar lists and the selected contact |
apps/contacts/google.py |
Copy a contact to Google Contacts and remove it again |
apps/folders/ |
Folder and the sidebar's folder and client-status selection |
apps/settings/matters/, apps/settings/contacts/ |
Practice areas; roles, groups and relationship types |
templates/matters/, templates/contacts/, templates/folders/ |
The screens |
static/js/main.js |
matterSwitcher (Space m) and the [ / ] stepper shortcut |
Data model¶
Matter (table app_matter). The fields that carry rules:
statusis a plainCharField: no choices on the model and nothing in the database. The four values,Pending,Open,CompleteandClosed, areMatter.STATUSES(withMatter.STATUS_CHOICESfor the form and the filter);Matter.ACTIVE_STATUSESis("Pending", "Open").Matter.INACTIVE_STATUSESis("Complete", "Closed"); the comment there says which is which: Complete is the closing-out phase, usually waiting on a trust reimbursement, and Closed is final and starts the chat retention clock.work_statusis free text shown in the list and edited inline; it is not a workflow state.clientis a nullableForeignKeytoContactwithSET_NULL(related_name="client_matters").MatterFormrelaxes it to optional so an administrative matter can have no client.contactsis a many-to-many throughRelationship, the parties list.membersis a many-to-many to users (related_name="assigned_matters") and is whatperm_all_mattersoff is measured against.practice_areais a nullableForeignKeytoPracticeAreawithSET_NULL, so retiring or deleting an area leaves the matter without one.drive_folderanddrive_folder_idname the matter's Google Drive folder; the id is the link and the name is for display.gmail_label_nameis the Gmail contract. All three are cleared when the matter leaves active work (below).date_startanddate_end: the opening date comes from the form; the closing date is written bysave().billable,billing_type(HOURLYorFLAT_FEE),flat_fee_amount,deferred_feesand thereport_*flags belong to billing and are explained in Time and billing.useris set to the requesting user on every add and edit, so it is "last saved by";created_byandupdated_byfromAuditMixin(utils/models.py) are the audit fields.
Matter.value is a property that runs the fee, expense and flat-fee
aggregates for one matter and returns a dict of dicts. The keys a reader
meets most:
| Key | Counts |
|---|---|
total |
Every entry on the matter, gross, comp and net |
unbilled |
Entries with entered=False and no invoice: the invoicing queue |
drafted |
Entries on an invoice whose status is in UNSENT_STATUSES (DRAFT, APPROVED) |
work_in_progress |
unbilled plus drafted, net of the discounts already on those drafts; one number |
billed |
total minus unbilled minus drafted |
invoices |
billed from issued invoices less their discounts, payment_sum, and due |
The split between unbilled and drafted dates from October 2026
("ledger: count work on draft invoices as work in progress"): before it,
work moved onto a draft invoice dropped out of work in progress. The
property runs about a dozen aggregate queries; do not call it in a loop
over the matter list.
Proceeding (apps/matters/proceedings/models.py): one court or
forum case under a matter, CASCADE on the matter. status is again a
free field whose values (Ongoing, Concluded, Stayed, Dismissed)
are Proceeding.STATUSES. primary is enforced in save(): setting it clears
the flag on the matter's other proceedings. display_name prefers
nickname over forum. Drive record folders that feed a proceeding are
DriveFolderMapping rows in apps/drive/, not fields here; the
drive_folder field on the proceeding was removed in migration 0052.
Group, Role and Relationship are the parties list. A
Relationship row is one contact on one matter in one group with one
role; CASCADE on all four foreign keys (migration 0022 made the role
and group cascades deliberate: deleting a role or group removes the party
rows that used it). A group is firm-wide when matter is null, or a
matter's own; Group.MATTER_GROUP_ORDER_BASE (1000) keeps matter groups
ordered after the firm's, and GroupQuerySet.for_matter() returns the
active ones for a matter in that order. The firm-wide Client group and
the Client role carry is_system=True (seeded as such by migration
0042), and Settings refuses to rename or delete them. Co-clients also
take the Client role, on a different contact, and are ordinary rows.
PracticeArea is a name and is_active. Migration 0023 created the
model from a free-text field and seeded ten names; migration 0048
added four more that the website intake form maps onto. Both lists are
one firm's practice areas, not a general default; a new installation
inherits them until Settings → Matters retires them.
Rate (apps/matters/rates/models.py) is a per-user hourly rate on
one matter. One per user per matter is enforced in RateForm.clean_user()
(not in the database); a time entry takes it over CustomUser.user_rate.
SettlementEntry is a dated amount with medium, type and notes,
listed on the Settlement tab and fed to the AI context
(apps/case/ai/context.py); nothing financial reads it.
Contact (apps/contacts/models.py, table app_contact) is the
firm-wide address book row: name, company, address, three labelled phones,
two emails, folder (SET_NULL), intake (SET_NULL, the intake it was
converted from), and google_id when it has been copied to Google
Contacts. user is SET_NULL to the user who last saved it. A contact's
client status is never stored: derive_client_status() reads it from the
statuses of its client_matters (Open or Complete gives Current, Pending
gives Pending, only Closed gives Former, none gives Nonclient, or Pending
when an intake is linked), and ContactQuerySet repeats the same rule as
Exists() annotations so the sidebar lists and reports can filter in the
database. Keep the two in step.
Contact.deletion_blockers() is the one place that says when a contact
may not be deleted: while it is the client on a matter, has trust
Transaction rows (CASCADE on the contact), or has trust deposit
requests (trust_requests, from apps/invoicing/requests/). The contact delete
view and the folder delete view both call it and refuse with a toast.
RelationshipType and ContactRelationship record how two
contacts are related, one directed edge per link, unique on the pair and
type, with a check constraint against a self-link. They are distinct from
matters.Relationship, the contact-to-matter row.
Folder (apps/folders/models.py) is a named folder with an app
column; only "contacts" is used.
How it works¶
The list and the detail page¶
matter_index renders matters/main.html from get_matter_list(), which
keeps the filter in request.session["matter_filter"], applies
MatterFilter, narrows with filter_matters_for_user() and paginates 40
to a page through CustomPaginator (see
Session state). The quick-status
buttons, the custom filter and the sort buttons each write the session and
answer 204 with HX-Trigger: mattersChanged; the list re-fetches
itself. The default filter is status=Open.
detail redirects to the tab the user last had open on that matter
(get_last_detail_tab(), one session key per matter; default
contacts). tab_content serves a tab for HTMX and records it;
_get_detail_tab_data() is the one switch that knows every tab's
template and context, and it is also where the Ledger and Rates tabs are
refused without the Financial permission (the middleware refuses the same
paths by regex; this covers the tab-switch route). VALID_DETAIL_TABS
lists the nine tabs.
The header's matter switcher (templates/matters/includes/switcher.html)
and the prev/next steppers come from get_open_matters and
get_adjacent_open_matters in matter_tags.py: the open matters the user
may see, by name, wrapping at the ends. The steppers are hidden when the
current matter is not on that list (closed, or not the user's). In
static/js/main.js, [ and ] click those steppers on a matter page,
unless the page declares a user cycle (data-cycle-*-url: the Tasks,
Time, Expenses and Flat Fees lists), which the brackets walk instead.
Space then m opens matterSwitcher, which fetches /matters/api/open/
(open_matters_json, the same filtered list).
Adding and editing¶
add and edit share MatterForm and templates/matters/form.html in
the modal. add sets user, saves, and adds the creator to members
when has_matter_access() would otherwise be false, so a user limited to
assigned matters can open what they just made. The client field is a
ContactComboboxWidget: a typeahead over all contacts served by
client_search.
The combobox's footer offers "Create new contact" and "Convert an intake"
without leaving the matter form. apps/matters/client_wizard.py does
this by stashing the posted matter fields in
request.session["matter_client_draft"], swapping only the dialog to the
contact form, and re-rendering the matter form from the draft with the
new contact selected (_render_matter_dialog()). The docstring explains
why: the application has one modal container, and a swap into it would
destroy the matter form. A draft that carries matter_id returns to the
edit form for that matter; returning an add form there once created a
second matter. The intake picker needs the Intakes permission
(require_intakes()), and intake_for_new_contact() refuses a second
contact for the same intake.
Parties¶
Matter.save() ends with _ensure_client_relationship(): if the matter
has a client, a Relationship in the system Client group and Client role
is created for it when missing. It is purely additive and never removes a
row, so changing the client leaves the former client's row in place.
is_client_mirror() in apps/contacts/access.py identifies that row
(matter.client_id == relationship.contact_id and the role is the system
one), and assign_update and assign_delete in
apps/matters/contacts/views.py refuse it with CLIENT_ROW_GUARD_MSG:
the client is managed on the matter, not the parties list. The Contacts
tab pins the mirror row to the top of the Client group and leaves it out
of select-all and the bulk group and role changes
(_without_client_mirror()).
assignable_roles() is the one list of roles an assign dialog offers (the
system Client role and "Client (Invoicing)" excluded), used by the
matter's Contacts tab and the contact page alike so the two cannot drift.
already_assigned() makes the same contact, group and role on a matter a
duplicate; the same contact in another role is allowed.
Closing, reopening and the chat purge¶
Closing is a status change through edit or the Overview's status
dropdown (overview_status_update); there is no separate close view. The
rules are in Matter.save() and run on every save whose status is in
INACTIVE_STATUSES:
- On the way in (the previous status was active):
date_endis set to today if empty, and every proceeding notDismissedis markedConcluded. This runs once, so a proceeding corrected afterwards stays corrected. Migration0054filleddate_endfor matters closed before the date was recorded, from their history. - On every save while inactive: the Drive folder, its id, the Gmail label
fields, and the matter's
DriveFolderMappingandDriveMatterStaterows are cleared. The comment gives the reason: closed files move out of the "Matters - Open" Drive and Gmail roots, so stale links only produce drift warnings, and automatic label setup would recreate the labels. Synced documents and emails all survive. - Moving back to
PendingorOpenclearsdate_end. Nothing relinks the mirrors; the user maps the folder again.date_endis added toupdate_fieldswhen a caller passed them, so a partial save still records it.
A Closed matter also starts the chat retention clock.
apps/case/ai/purge.py deletes a matter's AI conversations, messages and
their history rows once the matter has been Closed for
CHAT_RETENTION_DAYS (180 by default; 0 keeps them). closed_at() walks
the matter's simple-history rows newest-first and takes the start of the
latest unbroken run of Closed, so a reopened and re-closed matter counts
from the second closing. Complete does not start the clock.
Deleting¶
delete in apps/matters/views.py is admin-only. GET renders a
confirmation with counts of the time entries, expenses, tasks, documents,
notes, events and invoices that will go; DELETE runs in one transaction.
Every model that points at a matter cascades (documents, notes, emails,
tasks, events, entries, payments, credits, proceedings, settlement
entries, rates, parties, chats, research, drive mappings), with one
exception: Invoice.matter is SET_NULL. The view deletes the matter's
invoices explicitly first, because the dialog counts them and the
database would otherwise leave invoices with no matter, no entries and no
payments. Trust Transaction rows belong to the contact, not the matter,
and are untouched. The members and contacts rows are through-table
rows and go with the matter; the contacts themselves stay.
Contacts and folders¶
The Contacts page is one view family over get_list_data(): the sidebar
holds the two client lists (CLIENT_LISTS, derived status), the folders
(Folder rows with app="contacts") and Unsorted (contacts with no
folder); the session holds which is selected and which contact is open.
The contact's own page has Details, Matters, Trust and Intake tabs, each a
small view in apps/contacts/<tab>/views.py that sets
selected_contact_id and renders contacts/main.html. The Trust tab
raises PermissionDenied without can_see_trust().
A contact with google_id is mirrored to the connected Google account:
edit deletes and re-adds it there (google.delete_contact,
google.add_contact), and the delete views remove it. google.py reads
one token file (GOOGLE_CONTACTS_TOKEN_PATH), so the Google account is the
firm's, not per user; check_credentials() guards every call so an
unconnected server changes nothing.
Deleting a folder optionally deletes its contacts; those with
deletion_blockers() are kept and counted.
Background work¶
chat-purge-weekly(apps.case.ai.purge.scheduled_purge_closed_chats, Sunday 03:00): the purge above, also runnable as thepurge_closed_chatscommand with--dry-run. It logs one line per matter; an exception aborts the run, and the next Sunday recomputes from scratch, since nothing is marked. Installed bysetup_chat_purge_schedule; see Schedules.
The matters, contacts and folders apps run no other tasks. The Drive and
Gmail mirrors that a matter's drive_folder_id and gmail_label_name
feed are in Case building and
Email and intakes.
Access¶
- Every
/matters/<id>/…view carries@matter_access_required(theswitcherpartial included); the list, quick search, switcher dropdown andopen_matters_jsonnarrow withfilter_matters_for_user(). - Rates and Ledger:
perm_financial, at the middleware by path, again in_get_detail_tab_data()for the tab-switch route, and again inapps/matters/ledger/views.py. The Overview's balance, work in progress and trust figures are omitted without it (show_financial). - Parties:
matter_for_user(),relationship_for_user()andrelationships_for_user()inapps/contacts/access.py, so a user limited to assigned matters sees and changes only those matters' rows from either side. - Contacts are the firm's: every signed-in user may list, add, edit and delete them. Only what a contact page shows about matters and money is narrowed. Deleting a matter needs the Admin role.
- Practice areas, roles, groups and relationship types are under
/settings/, admin-only at the middleware.
The full matrix is Permissions matrix; the mechanism is Identity and access.
Things that bite¶
- The statuses are strings. The form, the filter and the access
modules' matter choices (
TASK_MATTER_STATUSES,EVENT_MATTER_STATUSES,ENTRY_FORM_STATUSES,ASSIGNABLE_STATUSES) all readMatter.STATUSESandMatter.ACTIVE_STATUSES, but the database accepts any value, andstatus="Open"literals remain in queries elsewhere. Matter.save()does work on every save. A status change through any path (the form, the Overview dropdown, a shell) unlinks mirrors and writesdate_end; aqueryset.update()skips all of it, and so does a save that happens while the status is already inactive expecting the "on the way in" step to run again.- The chat purge reads simple-history.
closed_at()returnsNonewhen the history holds noClosedrow, and such a matter is never purged. Bulk imports that bypass history, or aclean_historyrun with a short window, change what the purge sees. - The client mirror is additive only. Changing
Matter.clientadds a row for the new client and keeps the old one as a co-client. If that is not what the firm meant, the old row is removed from the parties list by hand. Matter.valueis expensive, andget_matter_list()turns the queryset into a list before paginating, so the matter list page touches every matter the filter admits. The computed-sort branch inget_matter_list()(unbilled,balance_due) strips the sort from the filter and then applies no ordering; the list comes back in database order.- Deleting a role or group deletes party rows. The cascade is
deliberate (migration
0022). Settings protects only the system Client rows; retiring withis_activeis the safe path for the rest. - Deleting a practice area detaches its matters (
SET_NULL). Retiring one withis_activekeeps it on its matters, and_practice_area_choices()still offers it there.
Related¶
- Matters, Contacts and Settings in the user guide show the screens; Users and permissions covers assigning matters to a user limited to them.
- Identity and access: the membership checks.
- Case building: the Case shell, documents and the
Drive mirror that
drive_folder_idfeeds. - Time and billing: entries, invoices and the billing fields on the matter.
- Trust and payments: the trust ledger that blocks a client's deletion.
- Email and intakes: the Gmail label contract and intake conversion.
- Session state: the filter, selection and pagination keys the list uses.