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
class CircleQuerySet(HierarchyQuerySet, AngeeQuerySet)Circle read scopes: the hierarchy subtree vocabulary over the Angee base.
with_member_counts
def with_member_counts() -> SelfAnnotate each circle with its distinct-party count across its subtree.
CircleManager
class CircleManager(AngeeManager.from_queryset(CircleQuerySet))Manager for circles — subtree scopes ride in through :class:CircleQuerySet.
HandleQuerySet
class HandleQuerySet(AngeeQuerySet)Handle read scopes over the Angee base.
owned_by
def owned_by(user: Any) -> SelfReturn the handles this user controls — the owner column, no joins.
HandleManager
class HandleManager(AngeeManager.from_queryset(HandleQuerySet))Factory + upsert for handles (the contact-point write path).
renormalize_phone_values
def renormalize_phone_values() -> intRepair 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
def upsert(*,
platform: str,
value: str,
created_by_id: Any = None,
**fields: Any) -> AnyGet-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
def claim_own(user: Any,
*,
platform: str,
value: str,
source: LinkSource,
display_name: str = "",
metadata: dict[str, Any] | None = None) -> AnyRecord 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
class PartyHandleManager(AngeeManager)Owns the confidence link between a party and a handle, and the resolution.
link
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) -> AnyLink 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
def resolve(handle: Any) -> NoneMaterialise 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
def recount(party: Any) -> NoneRefresh 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
def suggest_for(handle: Any) -> AnyPropose 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
def suggest_from_signature(*, text: str, party_ids: Iterable[Any],
fragment_hash: str, owner_id: Any) -> intMine 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
def suggest_from_display_names() -> intPool 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
@dataclass(frozen=True, slots=True)
class DuplicatePartyCandidate()One deterministic duplicate candidate and the normalized handle it shares.
MergeVetoManager
class MergeVetoManager(AngeeManager)Own canonical keep-separate pair lookup and creation.
forbids
def forbids(a: Any, b: Any) -> boolReturn whether the canonical pair a/b has a durable veto.
forbidden_pairs
def forbidden_pairs(party_ids: set[Any]) -> set[tuple[Any, Any]]Return vetoed canonical pairs whose two endpoints are in party_ids.
veto
def veto(a: Any, b: Any) -> AnyPersist 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
class PartyQuerySet(AngeeQuerySet)Party read scopes: canonical (unmerged) rows and organisation membership.
canonical
def canonical() -> SelfReturn 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
def with_circle_names() -> SelfPrefetch 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
def unassigned() -> SelfReturn canonical parties with no actor-visible circle membership.
to_review
def to_review() -> SelfReturn canonical parties with an undecided low-confidence handle link.
in_circle
def in_circle(circle: Any) -> SelfReturn canonical parties in circle or any of its descendants.
members_of
def members_of(organization: Any) -> SelfReturn 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
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
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
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
def for_user(user: Any) -> AnyReturn 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
def search_display_name(query: str, *, limit: int = 20) -> list[Any]Return a bounded actor-visible people list filtered by display name.
merge
def merge(*, into: Any, source: Any, field_overrides: Any = None) -> AnyMerge source into into with vetted scalar overrides in one transaction.
identity_for_user_id
def identity_for_user_id(user_id: Any) -> Any | NoneReturn the existing Person party linked to user_id without creating one.
user_for
def user_for(party: Any) -> Any | NoneReturn 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
def ingest_contact(parsed: ParsedContact, *, folder: Any,
created_by_id: Any) -> AnyUpsert 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
def purge_missing(*, folder: Any, keep_uids: set[str]) -> intDelete 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.