- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style. - Adjusted punctuation and phrasing for better flow and understanding.
9 KiB
Data model
This page describes the shape every model in this codebase follows, the handful of ways core CRM entities connect to each other, and a few conventions that are easy to assume rather than verify, which is exactly why they're written down here with the file and line that makes each one true.
Core entities
- Lead (
backend/leads/models.py), a prospect not yet qualified into a customer.LeadPipeline/LeadStagegive each org its own configurable Kanban columns. - Account (
backend/accounts/models.py), a company or customer.AccountEmail/AccountEmailLogsupport account-linked inbound mailboxes. - Contact (
backend/contacts/models.py), a person, optionally linked to anAccount. - Opportunity (
backend/opportunity/models.py). A sales deal in progress.OpportunityLineItemholds quoted products;StageAgingConfigis per-org "how many days is too long in this stage" configuration;SalesGoalis a quota/target tracked againstOpportunitydata. - Case (
backend/cases/models.py), a support ticket. By far the largest cluster of supporting models:CaseWatcher,CsatSurvey,Solution(knowledge base),CasePipeline/CaseStage,ReopenPolicy,EscalationPolicy,InboundMailbox/EmailMessage(email-to-ticket),RoutingRule/RoutingRuleState, andTimeEntry.Case.accountis a direct foreign key (not nullable through an intermediate table); Cases carry their own SLA fields (sla_first_response_hours,sla_resolution_hours,first_response_at,resolved_at) directly on the model. - Task (
backend/tasks/models.py). A to-do item, with its own configurableTaskPipeline/TaskStage, and a separateBoard/BoardColumn/BoardTask/BoardMemberfamily for freeform Kanban boards distinct from the Task pipeline. - Invoice (
backend/invoices/models.py): billing, alongsideEstimate,RecurringInvoice,Product,Payment, andInvoiceTemplatein the same app. This app also owns the anonymous client-portal views (see Multi-tenancy and RLS). - Order (
backend/orders/models.py),Order/OrderLineItem.
Relationships
The lead-to-customer path is a one-time conversion, not a standing foreign key.
leads.services.convert_lead_to_account(lead_obj, request, create_opportunity=True)
(backend/leads/services.py) runs as a side effect when a lead's status is updated to
"converted" through the normal lead update views (backend/leads/views/lead_views.py). There is
no dedicated "convert" endpoint. It:
get_or_creates anAccount(case-insensitive name match within the org).get_or_creates aContactfrom the lead's email, if the lead has one, and links it to the account both by direct FK and viaAccount.contacts.- Creates an
Opportunityif requested and the lead carries enough data to justify one, copyingassigned_to/teams/tagsacross. - Reassigns the lead's
Comment/Attachmentsrows (generic-relationContentType/object_idpairs) from the lead onto the new account. - Sets
lead_obj.status = "converted".
There is deliberately no persisted link back from the created Account/Contact/Opportunity to
the originating Lead, Lead's own model file documents this: earlier converted_account/
converted_contact/converted_opportunity/conversion_date fields were removed because they were
"never populated, conversion just sets status." If you need to trace a record back to the lead it
came from, there's nothing in the schema to follow; only the account/contact name or email is
likely to match.
Contact and Account are linked two separate ways at once: Contact.account is a nullable
foreign key (on_delete=SET_NULL), and Account.contacts is an independent many-to-many. Lead
conversion populates both. Code that needs "the contacts belonging to this account" should decide
deliberately which of the two it means, rather than assuming they're always in sync.
Opportunity.account is a nullable FK to Account; Opportunity.contacts is a many-to-many to
Contact. Case.account is a required-in-practice (blank/null allowed, but on_delete=CASCADE)
FK to Account, and Case.contacts is a many-to-many to Contact.
Conventions
Nearly every model in this codebase inherits BaseModel (backend/common/base.py), which via
AuditModel (backend/common/mixins.py) provides:
id: aUUIDFieldprimary key (default=uuid.uuid4), not an auto-incrementing integer.created_at/updated_at: set automatically (auto_now_add/auto_now).created_by/updated_by: foreign keys toUser, notProfile, set fromcrum.get_current_user()inBaseModel.save(). This is worth being precise about: a permission check that comparesrequest.profiledirectly tosome_object.created_byis comparing aProfileinstance to aUserforeign key and will always beFalse. See Permissions and roles for the bug class that causes and how to compare them correctly (profile.user_id == obj.created_by_id).
Three models opt out of this entirely and inherit plain models.Model instead: User
(backend/common/models.py:34), MagicLinkToken (:524), and PortalAccessToken (:910). All
three still declare their own id = models.UUIDField(default=uuid.uuid4, primary_key=True) by
hand, so the UUID-primary-key convention holds even for them, but none of the three has
created_at/updated_by/created_by fields, which makes sense for what they are: User predates
and sits outside the org-membership model entirely (Profile is what's org-scoped), and
MagicLinkToken/PortalAccessToken are short-lived, auth-bootstrap rows with no meaningful
"who last touched this" to record.
Org-scoped models add one more field: an org foreign key to common.Org, either inherited from
BaseOrgModel or (the more common pattern in this codebase) declared by hand on top of
BaseModel. See Multi-tenancy and RLS for which models do
which and why both are valid as long as the table is also registered and migrated for RLS.
Opportunity.stage is a fixed enum; Lead, Case, and Task stages are configurable
per-org pipelines. These are not the same kind of field even though they sound similar.
Opportunity.stage is a plain CharField over common.utils.STAGES: PROSPECTING,
QUALIFICATION, PROPOSAL, NEGOTIATION, CLOSED_WON, CLOSED_LOST: the same six stages for
every organization, with no model backing a per-org column set. Lead.stage, Case.stage, and
Task.stage, by contrast, are nullable foreign keys to a per-org LeadStage/CaseStage/
TaskStage row (itself belonging to a LeadPipeline/CasePipeline/TaskPipeline), so each org
can define its own Kanban columns for leads, cases, and tasks. All three of those models also keep
a separate, fixed-choices status field (Lead.status, Case.status, Task.status) alongside the
configurable stage FK. A stage's maps_to_status field is what keeps the two in sync when a
record moves between stages. A deal in Opportunity has no equivalent second field, because it has
no configurable stage to keep in sync with anything.
Several models (Lead, Opportunity, Case among them) carry a custom_fields = JSONField()
whose values are validated against common.CustomFieldDefinition, a per-org schema extension
mechanism that doesn't require a migration to add a field for one org.
Finally, one validation gotcha worth knowing before you rely on Model.clean(): DRF's
ModelSerializer calls instance.save(), not instance.full_clean(), so a model's clean()
method is invoked through the API only if that model's own save() explicitly calls
self.full_clean() itself: most don't. Lead.clean(), Opportunity.clean(), and Case.clean()
(backend/cases/models.py:180) all define real validation rules this way, and by default those
rules are silently unenforced through the API, because none of Lead.save(), Opportunity.save(),
or Case.save() calls full_clean(). A handful of other models do call it from their own
save(), so their clean() rules reach the API as a Django
ValidationError: a 500 unless the view happens to catch it, not a clean DRF 400: Task.save()
(backend/tasks/models.py:403), and Comment.save()/Attachments.save()
(backend/common/models.py:316-318 and :430-432, both there specifically to enforce that a
comment or attachment's org matches the org of whatever record it's attached to). There's no
blanket rule for which models do this, check the individual save() method rather than assuming.
Where an unenforced clean() rule turned out to matter through the API, it's been reimplemented
directly in the serializer instead of relying on full_clean(), opportunity/serializer.py's
validate() duplicates the two rules from Opportunity.clean() for exactly this reason, per its
own comment. If you add a clean() method to a model, verify whether that model's save() calls
full_clean() before assuming the rule is enforced anywhere outside a unit test that calls
full_clean() directly.