Skip to content

angee.parties.managers

Managers that own the directory-sync write path for parties.

A directory backend parses a source into neutral ParsedContact rows; these managers turn one into a Party (a Person) and its Handle / PartyHandle / Address rows. A contact is keyed by its source UID within its folder (the idempotent (folder, source_uid) upsert), handles dedupe on (platform, value), and handle_count plus the resolved Handle.party are maintained here in the same transaction — so every directory source shares one write path (the map lives on the models, not in each backend) and a re-sync converges instead of duplicating. The sync runs under system_context, so created_by is set explicitly to the directory owner.

CircleQuerySet

python
class CircleQuerySet(HierarchyQuerySet, AngeeQuerySet)

Circle read scopes: the hierarchy subtree vocabulary over the Angee base.

with_member_counts

python
def with_member_counts() -> Self

Annotate each circle with its distinct-party count across its subtree.

CircleManager

python
class CircleManager(AngeeManager.from_queryset(CircleQuerySet))

Manager for circles — subtree scopes ride in through :class:CircleQuerySet.

HandleQuerySet

python
class HandleQuerySet(AngeeQuerySet)

Handle read scopes over the Angee base.

owned_by

python
def owned_by(user: Any) -> Self

Return the handles this user controls — the owner column, no joins.

HandleManager

python
class HandleManager(AngeeManager.from_queryset(HandleQuerySet))

Factory + upsert for handles (the contact-point write path).

renormalize_phone_values

python
def renormalize_phone_values() -> int

Repair stored phone comparison values after normalization rules change.

The pass is idempotent and collision-safe because normalized_value is a comparison projection rather than a uniqueness key: equal E.164 results are deliberately retained on their distinct source handles for duplicate review.

upsert

python
def upsert(*,
           platform: str,
           value: str,
           created_by_id: Any = None,
           **fields: Any) -> Any

Get-or-create a handle on the identity it actually has, refreshing display fields.

A source-stable external_id (in fields, when the source has one) is the stronger identity — the model's conditional unique key — so the write serializes on whichever identity is present: get_or_create on (platform, external_id) when given, else (platform, value). That means an address whose human-readable value drifts (a chat account behind a changed number) refreshes the existing row instead of forking a duplicate or crashing a concurrent insert on the external-id constraint. The value-keyed path never rewrites external_id (it is not the key it matched on).

created_by_id stamps the audit owner. Control ownership is deliberately excluded from the generic refresh loop; :meth:claim_own is its only write path, so a routine upsert cannot silently transfer an account between users. normalized_value tracks value on every hit. Display fields refresh on every hit; blank values never clobber.

claim_own

python
def claim_own(user: Any,
              *,
              platform: str,
              value: str,
              source: LinkSource,
              display_name: str = "",
              metadata: dict[str, Any] | None = None) -> Any

Record value as user's own account handle: the control fact + a confirmed self-link.

The one verb the connect flows (OIDC login, CardDAV/OAuth connect) call for the signed-in user's own address. It writes BOTH facts the model separates: the control fact (:attr:Handle.owner — this user sends/syncs as the address) and the identity fact (a confirmed :class:PartyHandle to the user's own :class:~angee.parties.models.Person, so the address resolves to them). Directory contacts' handles get neither. Idempotent.

A claim is contested when another user already controls the row. The existing control owner is never reassigned; instead the competing user's identity link is recorded unconfirmed at 0.3 confidence and returned for later review. The whole decision is atomic and row-locked.

PartyHandleManager

python
class PartyHandleManager(AngeeManager)

Owns the confidence link between a party and a handle, and the resolution.

python
def link(party: Any,
         handle: Any,
         *,
         confidence: float = 1.0,
         source: LinkSource = cast(LinkSource, LinkSource.MANUAL),
         is_confirmed: bool = False,
         metadata: dict[str, Any] | None = None,
         created_by_id: Any = None) -> Any

Link handle to party with confidence, then resolve the handle's owner.

is_confirmed records a human-strength decision (a connect flow claiming the signed-in user's own handle); it upgrades an existing weaker link to the confirmed self-link. Resolution only re-runs when the link is new, upgraded, or the handle's owner is not already this party, so a re-sync of an unchanged contact does no extra work. Source metadata merges onto the existing link so a later importer can add provenance without erasing prior evidence.

resolve

python
def resolve(handle: Any) -> None

Materialise handle.party and its confirmed state from the winning link.

The resolution ordering (-is_confirmed, -confidence) is the contacts rule: a human-confirmed link wins, then the strongest score. A handle with no surviving link is left unowned. A demotion (a dismissed winner) recounts the previous owner too, so its handle_count never goes stale.

recount

python
def recount(party: Any) -> None

Refresh party.handle_count from the handles resolved onto it (write only on change).

Idempotent, so it doubles as the repair pass for the drift the counter signals cannot see (bulk_create / QuerySet.update skip signals).

suggest_for

python
def suggest_for(handle: Any) -> Any

Propose a party for a freshly-seen, unresolved handle (the EMAIL_MATCH producer).

Three branches, all leaving links unconfirmed for review and never duplicating an existing pair: indexed normalized twins contribute distinct candidate parties (the first at 1.0, competing parties at 0.3); otherwise an email whose non-generic domain matches a tracked :attr:Organization.domain contributes a rule suggestion at 0.4; otherwise no-op. Both branches stay inside handle.created_by's audit partition, and public mailbox-provider domains never imply organization membership. Returns the strongest created/existing link, or None.

suggest_from_signature

python
def suggest_from_signature(*, text: str, party_ids: Iterable[Any],
                           fragment_hash: str, owner_id: Any) -> int

Mine one unique signature fragment for weak party-to-phone suggestions.

text and its content hash are neutral evidence supplied by the scheduled task; this manager owns phone extraction, Handle creation, link provenance, owner partition, and the durable-pair check. Every mined link records its fragment evidence at 0.3 confidence. An existing pair, including a dismissed anti-link, is never changed.

suggest_from_display_names

python
def suggest_from_display_names() -> int

Pool normalized display names per audit owner into weak identity links.

A resolved handle supplies evidence only to an unresolved handle on another platform. Each distinct candidate party receives one 0.4 rule link; existing pairs, including dismissed links, remain untouched.

DuplicatePartyCandidate

python
@dataclass(frozen=True, slots=True)
class DuplicatePartyCandidate()

One deterministic duplicate candidate and the normalized handle it shares.

MergeVetoManager

python
class MergeVetoManager(AngeeManager)

Own canonical keep-separate pair lookup and creation.

forbids

python
def forbids(a: Any, b: Any) -> bool

Return whether the canonical pair a/b has a durable veto.

forbidden_pairs

python
def forbidden_pairs(party_ids: set[Any]) -> set[tuple[Any, Any]]

Return vetoed canonical pairs whose two endpoints are in party_ids.

veto

python
def veto(a: Any, b: Any) -> Any

Persist the canonical keep-separate fact after locking both writable parties.

The pair lock is the same lock, in the same order, that :meth:PartyManager.merge takes. A simultaneous merge and veto therefore serialize around the human identity decision instead of both committing after independent checks.

PartyQuerySet

python
class PartyQuerySet(AngeeQuerySet)

Party read scopes: canonical (unmerged) rows and organisation membership.

canonical

python
def canonical() -> Self

Return only the canonical (unmerged) parties.

Writes flatten every merge chain to its terminal (Party.merge_into), so a party is canonical exactly when it points nowhere — one indexed filter that stays correct even against a longer chain, since a terminal always points nowhere.

with_circle_names

python
def with_circle_names() -> Self

Prefetch actor-visible circle names for generic chip rendering.

A scoped prefetch is portable across the supported database floor and, unlike a reverse-join aggregate, independently applies both the membership and circle row policies before projecting names.

unassigned

python
def unassigned() -> Self

Return canonical parties with no actor-visible circle membership.

to_review

python
def to_review() -> Self

Return canonical parties with an undecided low-confidence handle link.

in_circle

python
def in_circle(circle: Any) -> Self

Return canonical parties in circle or any of its descendants.

members_of

python
def members_of(organization: Any) -> Self

Return the parties whose relationships name organization as counterparty.

The successor to the removed Organization.members reverse accessor: a member is any party anchoring a current (open-ended) :class:Relationship whose tracked counterparty is this organisation (employment and other org-typed edges).

duplicate_candidates

python
def duplicate_candidates(*, limit: int = 50) -> list[DuplicatePartyCandidate]

Return bounded actor-visible party pairs sharing a normalized handle.

Candidate order is deterministic by normalized value then primary-key pair. A pair appears once even if it shares several handles, and any durable :class:~angee.parties.models.MergeVeto removes it from the queue.

PartyManager

python
class PartyManager(AngeeManager.from_queryset(PartyQuerySet))

Factory for parties, including the idempotent directory-sync ingest.

Also the effective manager of the MTI children (Person / Organization inherit the parent's concrete default manager), so the Person-per-user factory :meth:for_user lives here.

circle_names_for

python
def circle_names_for(party: Any) -> list[str]

Return one party's actor-visible circle names through the fast projection.

Resource reads install :meth:PartyQuerySet.with_circle_names; alternate authored paths fall back to the same independently scoped membership and circle collections instead of silently presenting an empty list.

for_user

python
def for_user(user: Any) -> Any

Return the :class:Person linked to user, get-or-created on the user O2O.

The single owner of the one-Person-per-user invariant: every identity-link writer (OIDC first login, the connect flows, messaging reaction attribution) routes through here, so a user never grows two person rows. Targets the Person model explicitly, runs within the caller's system_context, and stamps created_by.

search_display_name

python
def search_display_name(query: str, *, limit: int = 20) -> list[Any]

Return a bounded actor-visible people list filtered by display name.

merge

python
def merge(*, into: Any, source: Any, field_overrides: Any = None) -> Any

Merge source into into with vetted scalar overrides in one transaction.

identity_for_user_id

python
def identity_for_user_id(user_id: Any) -> Any | None

Return the existing Person party linked to user_id without creating one.

user_for

python
def user_for(party: Any) -> Any | None

Return the platform user linked to party when it is a Person.

This manager owns the Party-to-Person MTI lookup so consumers never inspect the concrete child table or its base manager themselves. An organization or external Person without a user resolves to None.

ingest_contact

python
def ingest_contact(parsed: ParsedContact, *, folder: Any,
                   created_by_id: Any) -> Any

Upsert a person and its handles/addresses from one parsed contact.

Keyed on (folder, source_uid) so a re-sync updates the same row instead of forking a duplicate, and the whole contact is written in one transaction so a partial card is never half-applied. Emails/phones still upsert as shared Handle rows and link to the person, but the person's identity is the source UID, not handle overlap. A contact with no source_uid has no stable key and is skipped — without it the (folder, "") upsert would collapse every keyless card onto one row.

purge_missing

python
def purge_missing(*, folder: Any, keep_uids: set[str]) -> int

Delete the folder's synced parties whose source UID is no longer present.

This is how a contact deleted on the source is mirrored locally: anything in folder carrying a source_uid not in keep_uids is removed (the MTI child cascades with its parent). Cascaded PartyHandle deletes re-resolve shared handles through the delete-path signal owner.

Released under the AGPL-3.0 License.