Skip to content

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

python
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

python
class Meta()

Django model options for the party source model.

__str__

python
def __str__() -> str

Return 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

python
@property
def has_real_name() -> bool

Whether 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

python
@property
def concrete_kind() -> str | None

Return 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

python
def canonical() -> Party

Return 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

python
def apply_merge_field_overrides(source: Party, field_overrides: Any) -> None

Apply 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

python
def merge_into(target: Party) -> Party

Atomically 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

python
class Person(AngeeModel)

A human party — carries name parts and an optional platform-user link.

Meta

python
class Meta()

Django model options for the person child model.

MergeVeto

python
class MergeVeto(SqidMixin, AuditMixin, AngeeModel)

A durable decision that two canonically ordered parties must stay separate.

Meta

python
class Meta()

Django model options for the canonical keep-separate pair.

__str__

python
def __str__() -> str

Return the canonical pair for Django displays.

save

python
def save(*args: Any, **kwargs: Any) -> None

Persist the veto in canonical primary-key order.

Organization

python
class Organization(AngeeModel)

An organisation party — carries its legal name and primary domain.

Meta

python
class Meta()

Django model options for the organization child model.

Handle

python
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

python
class Platform(models.TextChoices)

The kind of channel a handle reaches a party through.

for_value

python
@classmethod
def for_value(cls, value: str) -> Handle.Platform

Classify 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.

Whether the winning link materialised into party is human-confirmed.

Meta

python
class Meta()

Django model options for the handle source model.

__str__

python
def __str__() -> str

Return the handle value for Django displays.

normalize_value

python
@classmethod
def normalize_value(cls, platform: str, value: str) -> str

Return 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

python
@staticmethod
def normalize_display_name(value: str) -> str

Return the comparison key used by cross-platform display-name pooling.

save

python
def save(*args: Any, **kwargs: Any) -> None

Persist the handle while keeping normalized_value in lockstep.

resolved_confidence

python
@property
def resolved_confidence() -> float | None

Confidence 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

python
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

python
class Meta()

Django model options for the party-handle link source model.

__str__

python
def __str__() -> str

Return a readable link description for Django displays.

confirm

python
def confirm() -> None

Human-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

python
def dismiss() -> None

Dismiss 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

python
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

python
class Meta()

Django model options for the address source model.

__str__

python
def __str__() -> str

Return a one-line address for Django displays.

Folder

python
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

python
class Meta()

Django model options for the folder source model.

__str__

python
def __str__() -> str

Return the folder name for Django displays.

Circle

python
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

python
class Meta(HierarchyMixin.Meta)

Django model options carrying the hierarchy path index.

__str__

python
def __str__() -> str

Return the circle name for Django displays.

CircleMember

python
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

python
class Meta()

Django model options for the circle-membership source model.

__str__

python
def __str__() -> str

Return a readable membership description for Django displays.

RelationshipKind

python
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

python
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

python
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

python
def model() -> type[models.Model] | None

Return 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

python
class Meta()

Django model options for the relationship-kind catalogue.

__str__

python
def __str__() -> str

Return the kind's forward label for Django displays.

is_symmetric

python
@property
def is_symmetric() -> bool

Whether the kind reads the same in both directions.

label_for

python
def label_for(*, outbound: bool) -> str

Return 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

python
def validate_ends(party: Party | None, other_party: Party | None) -> None

Raise :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

python
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

python
class Meta()

Django model options for the relationship source model.

__str__

python
def __str__() -> str

Return a readable edge description for Django displays.

clean

python
def clean() -> None

Enforce the kind's end legality so the Hasura full_clean create path surfaces it.

save

python
def save(*args: Any, **kwargs: Any) -> None

Persist the edge, validating ends only when the write can change them.

Directory

python
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

python
class Meta()

Django model options for the directory child model.

backend

python
@property
def backend() -> DirectoryBackend

Return this directory's selected backend, bound to this row.

sync

python
def sync() -> int

Discover 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.

Released under the AGPL-3.0 License.