angee.parties.models
Source models for the parties addon.
Parties are the people and organisations a project keeps track of. A party is reached through one or more :class:Handle rows (an email address, a phone number, a social handle) — the same handle that messaging uses as a participant, so this addon is the contacts foundation the messaging addon builds on. The link between a party and a handle is itself confidence-bearing (:class:PartyHandle) so a sync can record an uncertain match as a weak candidate instead of guessing.
Party is a multi-table-inheritance parent; the concrete kind is the child model (:class:Person, :class:Organization), not a column — a person carries name parts and a link to its :class:~angee.iam.models.User, an organisation carries its legal name and domain.
Parties are organised two ways, both human-owned facts: :class:Circle is a private, overlapping grouping (a party may belong to many circles; circles nest as a :class:~angee.base.mixins.HierarchyMixin tree), and :class:Relationship is a typed, directed party↔party edge whose vocabulary (:class:RelationshipKind) is catalogue data seeded from the XFN / vCard RELATED values — never a schema fact.
Party
class Party(SqidMixin, AuditMixin, AngeeModel)A person or organisation the project tracks.
The parent owns the common contact identity — the public id, ownership, the display name, avatar, notes, and the lossless-vCard carriers. The concrete kind (and its kind-specific fields) lives on the :class:Person / :class:Organization child row.
introduced_by
The party that introduced this one — acquaintance provenance, not a typed edge.
first_met_note
Free-text "how I know them" note (vCard has no property for this).
Meta
class Meta()Django model options for the party source model.
__str__
def __str__() -> strReturn the party's display name for Django displays.
PLACEHOLDER_NAME
The display name imports write when a source carries no name at all.
has_real_name
@property
def has_real_name() -> boolWhether display_name is a human-given name.
The import placeholder and letterless names (a phone number or other handle spelling promoted into the name slot) do not count; ranking surfaces such as merge-survivor proposals prefer parties that pass.
concrete_kind
@property
def concrete_kind() -> str | NoneReturn this party's concrete kind ("person" / "organization"), or None.
A structural fact read straight from the multi-table-inheritance child rows that share this party's primary key — not a stored column (the child model is the kind). Read through the base manager: it is a system integrity fact, never actor-scoped user data.
canonical
def canonical() -> PartyReturn the surviving party this one resolves to, following merge pointers.
:meth:merge_into flattens normal writes to the terminal, but this method still follows a longer chain and guards a cycle so legacy/corrupt data cannot loop forever during a read.
apply_merge_field_overrides
def apply_merge_field_overrides(source: Party, field_overrides: Any) -> NoneApply the allow-listed scalar overrides selected for a merge survivor.
Common human fields live on Party. Person-only name and birthday fields are accepted only when both endpoints materialize a Person child; the child owns coercion and persistence for those columns.
merge_into
def merge_into(target: Party) -> PartyAtomically merge this party into target and return the terminal target.
The source chain head is row-locked through lock_if_supported before its pointer changes. The target resolves defensively to its canonical terminal, and existing rows that pointed at the source are repointed in the same transaction so normal merge chains stay one hop deep. Reversing an existing merge raises :class:ValidationError instead of silently clearing either pointer.
Person
class Person(AngeeModel)A human party — carries name parts and an optional platform-user link.
Meta
class Meta()Django model options for the person child model.
MergeVeto
class MergeVeto(SqidMixin, AuditMixin, AngeeModel)A durable decision that two canonically ordered parties must stay separate.
Meta
class Meta()Django model options for the canonical keep-separate pair.
__str__
def __str__() -> strReturn the canonical pair for Django displays.
save
def save(*args: Any, **kwargs: Any) -> NonePersist the veto in canonical primary-key order.
Organization
class Organization(AngeeModel)An organisation party — carries its legal name and primary domain.
Meta
class Meta()Django model options for the organization child model.
Handle
class Handle(SqidMixin, AuditMixin, AngeeModel)A reachable address or handle of a party on one platform.
Keyed on (platform, value) and, when present, (platform, external_id) — those unique constraints are the ingestion-dedup keys that make re-sync idempotent. party is the resolved owner the :class:PartyHandle manager materialises; it is null until a handle is linked, so a handle synced for an unknown sender is still a valid row.
Platform
class Platform(models.TextChoices)The kind of channel a handle reaches a party through.
for_value
@classmethod
def for_value(cls, value: str) -> Handle.PlatformClassify a raw handle value through the one platform heuristic.
owner
The user who controls this address — sends, posts, or syncs as it.
The control fact, distinct from the identity fact (party — who the address reaches, resolved from :class:PartyHandle). A shared team inbox or a service account may carry an owner without resolving to that user's party, and an ex-address may reach a person's party without anyone controlling it.
party_link_confirmed
Whether the winning link materialised into party is human-confirmed.
Meta
class Meta()Django model options for the handle source model.
__str__
def __str__() -> strReturn the handle value for Django displays.
normalize_value
@classmethod
def normalize_value(cls, platform: str, value: str) -> strReturn the persisted comparison value for one platform/value pair.
Every platform strips surrounding whitespace and lowercases. Email keeps that rule and additionally collapses dots and plus-tags in Gmail local parts for both gmail.com and googlemail.com domains. Phone and WhatsApp values parse without an assumed region and format as E.164; values require a leading country code and must be possible and valid. Anything unparseable, invalid, or region-unknown falls back to its digits so punctuation still does not fork the same contact point. iMessage carries either kind of address, so it routes by value shape — the email rule when the value contains @, the phone rule otherwise.
normalize_display_name
@staticmethod
def normalize_display_name(value: str) -> strReturn the comparison key used by cross-platform display-name pooling.
save
def save(*args: Any, **kwargs: Any) -> NonePersist the handle while keeping normalized_value in lockstep.
resolved_confidence
@property
def resolved_confidence() -> float | NoneConfidence of the link that resolved this handle's owner.
party is materialised from the winning :class:PartyHandle (see :meth:PartyHandleManager.resolve) and (party, handle) is unique, so the link matching the resolved party is that winner — its score is the resolution confidence. None when the handle is unowned or, under actor-scoped loading, the resolving link is not readable by the actor.
PartyHandle
class PartyHandle(ScoredLinkMixin, SqidMixin, AuditMixin, AngeeModel)A confidence-bearing link between a party and one of its handles.
A handle may carry several scored candidate parties (:class:ScoredLinkMixin), so a sync can surface an uncertain match as a weak link (a conflicting claim is recorded at 0.3 confidence) instead of silently reassigning. The resolved owner is the highest-confidence, non-dismissed link — the value the manager materialises onto :attr:Handle.party.
Meta
class Meta()Django model options for the party-handle link source model.
__str__
def __str__() -> strReturn a readable link description for Django displays.
confirm
def confirm() -> NoneHuman-confirm this link, then re-resolve the handle's owner.
Overrides the plain :meth:ScoredLinkMixin.confirm to add the two facts a contacts confirmation carries that the generic mixin must not: the actor must hold write on the link, and the resolution cascade (the handle's owner pointer, both parties' counts) is server-owned bookkeeping that runs elevated.
dismiss
def dismiss() -> NoneDismiss this link — the durable anti-link — then re-resolve the handle.
Gated and elevated like :meth:confirm; the mixin flips the flags and calls :meth:_resolve_link, which demotes the handle to its next candidate or to unowned.
Address
class Address(SqidMixin, AuditMixin, AngeeModel)A physical or postal address of a party (the vCard ADR property).
There is intentionally no (party, label) uniqueness — a party may carry two same-labelled addresses — so a CardDAV mapper keys idempotency on the address content, not the label.
Meta
class Meta()Django model options for the address source model.
__str__
def __str__() -> strReturn a one-line address for Django displays.
Folder
class Folder(SqidMixin, AuditMixin, AngeeModel)A group of parties — the local mirror of a synced address book.
The contacts counterpart of storage's Drive/Folder and knowledge's Vault container idea, kept to exactly what sync needs today: the directory it mirrors, the collection source_href (one folder per (directory, source_href) makes the folder upsert idempotent), and the incremental cursors (ctag / sync_token). Owned via created_by; deleting a folder leaves its parties (SET_NULL on :attr:Party.folder). Manual creation and a folder tree (parent) are deferred until a create path lands to exercise them.
Meta
class Meta()Django model options for the folder source model.
__str__
def __str__() -> strReturn the folder name for Django displays.
Circle
class Circle(HierarchyMixin, SqidMixin, AuditMixin, AngeeModel)A private, overlapping grouping of parties — how the owner organises people.
Circles are the curated counterpart of :class:Folder (which mirrors a synced address book): "Family", "Inner Circle", a climbing crew. A party may belong to many circles (:class:CircleMember), and circles nest as a tree — overlap comes from multi-membership, never from multiple parents. The tree is :class:~angee.base.mixins.HierarchyMixin's materialized path, so "everyone in this circle including sub-circles" is an indexed prefix scan (Circle.objects.subtree_of(circle)), and hierarchy_scope_fields keeps a personal circle tree from straddling owners.
Circles are an organising surface only — they never gate visibility or sharing; REBAC stays the one authorization owner.
color
Display color token or hex for chips/dots; presentation only.
icon
Icon registry name for navigation; presentation only.
Meta
class Meta(HierarchyMixin.Meta)Django model options carrying the hierarchy path index.
__str__
def __str__() -> strReturn the circle name for Django displays.
CircleMember
class CircleMember(ScoredLinkMixin, SqidMixin, AuditMixin, AngeeModel)A party's membership of one circle, scored like a :class:PartyHandle link.
Membership is a :class:ScoredLinkMixin so a suggester (community detection, an org-domain rule, an LLM) can propose a weak membership for review instead of silently filing people; a human decision writes manual at full confidence. One row per (circle, party) — re-suggesting an existing membership updates the row rather than duplicating it.
Meta
class Meta()Django model options for the circle-membership source model.
__str__
def __str__() -> strReturn a readable membership description for Django displays.
RelationshipKind
class RelationshipKind(SqidMixin, AuditMixin, AngeeModel)The relationship vocabulary — types as catalogue data, never schema.
One row expresses both directions of an asymmetric type through inverse_name ("Parent" / "Child"); a blank inverse means the type is symmetric ("Friend"). The master tier seeds the XFN / vCard RELATED vocabulary (adopted by slug, so a project may rename labels without forking rows), and users may add their own kinds alongside.
RelationshipCategory
class RelationshipCategory(models.TextChoices)The XFN category a relationship kind belongs to.
Schema-unique class name — enum class names project as global GraphQL enum names.
PartyKind
class PartyKind(models.TextChoices)The concrete party kind an edge end must be for a kind to be legal.
any places no constraint; person / organization require that end to be that concrete :class:Party child (employment kinds require an organisation counterparty). Schema-unique class name — projects as a global GraphQL enum.
model
def model() -> type[models.Model] | NoneReturn the explicitly mapped concrete party model for this kind.
name
Anchor-side label: what the counterparty is to the anchor ("Mother").
inverse_name
Counterparty-side label ("Child"); blank means the kind is symmetric.
party_kind
Concrete kind the anchor (:attr:Relationship.party) must be, or any.
other_party_kind
Concrete kind the counterparty (:attr:Relationship.other_party) must be, or any.
Meta
class Meta()Django model options for the relationship-kind catalogue.
__str__
def __str__() -> strReturn the kind's forward label for Django displays.
is_symmetric
@property
def is_symmetric() -> boolWhether the kind reads the same in both directions.
label_for
def label_for(*, outbound: bool) -> strReturn the label as seen from one side of the edge.
outbound=True is the anchor's side — on Maya's card her mother's row renders name ("Mother"); the counterparty's card renders the inverse ("Child": Maya is the mother's child), falling back to the forward name for a symmetric kind.
validate_ends
def validate_ends(party: Party | None, other_party: Party | None) -> NoneRaise :class:ValidationError if an edge's ends violate this kind's legality.
The knowledge-level guard: an organization-typed end must be a tracked organisation-kind party, a person-typed end a person-kind party. A free-text (untracked) counterparty is unconstrained — its kind is unknown — so an employment kind still records a person's employer by free-text name.
Relationship
class Relationship(SqidMixin, AuditMixin, AngeeModel)A typed edge from one party's viewpoint: the other is kind of party.
kind.name names what the counterparty is to the anchor ("Mother", "Mentor", "Colleague"); the counterparty's own card renders the reverse through :meth:RelationshipKind.label_for ("Child", "Mentee"). A single row carries both readings (Monica's Chandler shape — the mirror-row scheme was abandoned there for drifting). The counterparty is a tracked :class:Party when known, falling back to free-text other_name so a family-history relative — or a person's employer parsed from a vCard — who is not a directory entry still records. Edges are time-bounded (party-model from/thru), so "was my colleague 2019–2022" stays queryable after it ends; an open edge has no ended_at. title carries the vCard TITLE ("CTO", "Godmother of").
party
The anchor — the contact whose card this fact lives on.
other_name
Free-text counterparty when the relative is not a tracked party.
Unbounded free text like its sibling title/notes: a synced vCard ORG can legitimately list many affiliated entities in one value, which a fixed varchar truncates or rejects.
source
Provenance of the edge; sync-owned rows stay distinct from human rows.
title
Role title on this edge — the vCard TITLE ("CTO", "Godmother of").
Meta
class Meta()Django model options for the relationship source model.
__str__
def __str__() -> strReturn a readable edge description for Django displays.
clean
def clean() -> NoneEnforce the kind's end legality so the Hasura full_clean create path surfaces it.
save
def save(*args: Any, **kwargs: Any) -> NonePersist the edge, validating ends only when the write can change them.
Directory
class Directory(Bridge)A connected contacts source that syncs parties from an external directory.
An integrate.Integration child (so it draws its credential / owner / status from the connection substrate) and a Bridge (so the scheduler and the eager syncIntegration mutation drive it). backend_class selects the protocol — carddav (contributed by parties_integrate_carddav) — and config carries the source URL. sync() fetches + parses the source, then maps each contact onto the parties managers.
backend_class
Registry key for the directory backend bound to this directory.
Meta
class Meta()Django model options for the directory child model.
backend
@property
def backend() -> DirectoryBackendReturn this directory's selected backend, bound to this row.
sync
def sync() -> intDiscover address books and resolve every contact into parties (the Bridge contract).
Idempotent: each address book mirrors to one :class:Folder (keyed by its source_href), every contact upserts by (folder, source_uid), and a contact that vanished from the source is purged from its folder — so a re-sync converges to the source instead of duplicating it. A collection whose ctag is unchanged is skipped wholesale.