Skip to content

angee.messaging.models

Source models for the messaging addon.

Messaging is threads and messages built on the parties contacts foundation: a message's sender and participants are :class:~angee.parties.models.Handle rows, so the dependency points one way (messaging → parties). A :class:Channel is an integrate.Integration child (a bridge) that ingests messages from an external source; the email/social mapping lands in messaging_integrate_* backends.

The shapes mirror JMAP/Gmail/RFC-5322: a :class:Thread aggregates :class:Message rows; a message's body is a recursive :class:Part tree whose text nodes reference a content-addressed :class:Fragment (dedup + quotation + signature isolation) and whose byte nodes reference a storage.File; cross-message relations (quote/reply/ mention) live on :class:MessageEdge. Ingestion idempotency rests on (platform, external_id) unique constraints; the write path lives on the managers.

ThreadedModelMixin

python
class ThreadedModelMixin(models.Model)

Add Odoo-style chatter thread behavior to a model row.

A concrete model opts in by inheriting this abstract mixin. The model remains the owner of the record; messaging owns the attached thread edge and the message write path.

thread_attachment_role

The attachment role used for the model's primary chatter thread.

thread_post_access

Record permission required to post a chatter message.

thread_read_access

Record permission required to read/react to personal chatter state.

thread_autofollow_author

Whether posting a message subscribes the posting actor to replies.

thread_create_autofollow_author

Whether creating a threaded row subscribes the creating actor.

thread_create_log

Whether creating a threaded row logs a creation message.

thread_creation_subtype_key

Subtype key used for automatic record creation messages.

thread_activity_access

Record permission required to schedule or update chatter activities.

thread_tracking_fields

Model field names automatically tracked into the chatter on save.

thread_tracking_subtype_key

Subtype key used for automatic field tracking messages.

thread_suggested_recipient_fields

User FK fields suggested as chatter recipients, like Odoo's user_id.

thread_attachments

Reverse edge to this row's chatter attachments.

The polymorphic ThreadAttachment binds through a GenericForeignKey, which Django's delete collector cannot follow on its own; declaring the reverse GenericRelation makes any delete of this row (instance or bulk queryset) collect its attachments, so no attachment is left keyed to a reused primary key. Full thread-graph teardown (the private Thread and its messages, which the attachment's FK cannot cascade up to) runs on both delete paths through the pre_delete receiver messaging wires onto every threaded model (angee.messaging.signals), inside the delete collector's own transaction.

Meta

python
class Meta()

Django model options for the thread behavior mixin.

save

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

Persist this row and log configured field changes in its chatter.

message_thread

python
def message_thread(*, create: bool = True) -> models.Model | None

Return this row's chatter thread, optionally creating it.

message_thread_attachment

python
def message_thread_attachment(*, create: bool = True) -> models.Model | None

Return this row's chatter thread attachment, optionally creating it.

message_post

python
def message_post(body: str,
                 *,
                 subject: str = "",
                 attachments: tuple[models.Model, ...] = (),
                 recipient_user_ids: tuple[Any, ...] = (),
                 autofollow_recipients: bool = False,
                 message_type: Message.MessageKind | None = None,
                 subtype_key: str = "comment",
                 parent: models.Model | None = None) -> models.Model

Post an internal comment on this row's chatter thread.

message_type defaults to :attr:Message.MessageKind.COMMENT (resolved by the message write path), keeping the enum the single source of truth.

message_log

python
def message_log(body: str = "",
                *,
                subject: str = "",
                subtype_key: str = "note",
                message_type: Message.MessageKind | None = None,
                tracking_values: tuple[TrackingChange | dict[str, Any],
                                       ...] = (),
                attachments: tuple[models.Model, ...] = (),
                parent: models.Model | None = None) -> models.Model

Log a structured system note on this row's chatter thread.

Defaults to the :attr:Message.MessageKind.NOTIFICATION kind; callers logging a tracked change (message_track) pass AUTO_COMMENT.

message_track

python
def message_track(changes: tuple[TrackingChange | dict[str, Any], ...],
                  *,
                  body: str = "",
                  subject: str = "",
                  subtype_key: str = "record_updated") -> models.Model

Log Odoo-style field tracking values in this row's chatter thread.

message_update_content

python
def message_update_content(message: models.Model, *,
                           body: str) -> models.Model

Update a comment in this row's chatter thread.

python
def message_unlink(message: models.Model) -> models.Model

Delete a message from this row's chatter thread.

message_reaction

python
def message_reaction(message: models.Model,
                     *,
                     reaction: str,
                     action: str = "toggle",
                     user: Any) -> models.Model

Add, remove, or toggle user's reaction on a chatter message.

message_starred

python
def message_starred(message: models.Model, *, user: Any) -> bool

Return whether user has starred message in this row's chatter.

message_set_starred

python
def message_set_starred(message: models.Model,
                        *,
                        user: Any,
                        starred: bool | None = None) -> bool

Set or toggle user's star on a message in this row's chatter.

message_unstar_all

python
def message_unstar_all(*, user: Any) -> int

Remove all Odoo-style stars owned by user.

message_set_done

python
def message_set_done(message: models.Model, *, user: Any) -> int

Remove Odoo-style needaction from message for user.

message_subscribe

python
def message_subscribe(
    *,
    user: models.Model | None = None,
    notification_policy: str = "inbox",
    subtype_keys: tuple[str, ...] = ()
) -> models.Model

Subscribe a user to this row's chatter thread.

message_unsubscribe

python
def message_unsubscribe(*, user: models.Model | None = None) -> bool

Unsubscribe a user from this row's chatter thread.

message_is_follower

python
def message_is_follower(*, user: models.Model | None = None) -> bool

Return whether a user follows this row's chatter thread.

message_followers

python
def message_followers() -> models.QuerySet

Return this row's chatter followers.

message_suggested_recipients

python
def message_suggested_recipients(
        *,
        role: str = "chatter",
        reply_discussion: bool = True,
        user: models.Model | None = None) -> tuple[dict[str, Any], ...]

Return Odoo-style suggested recipients for this record's chatter.

Suggestions come from fields the record declares as recipient owners and, when there is a discussion, from the latest user-facing comment's direct notification recipients. Existing followers and the current user are omitted so the composer suggests only additional recipients.

activity_schedule

python
def activity_schedule(
        *,
        user: models.Model | None = None,
        summary: str,
        note: str = "",
        due_date: object | None = None,
        activity_type: str = "todo",
        metadata: dict[str, object] | None = None) -> models.Model

Schedule an activity on this row's chatter thread.

activity_ids

python
def activity_ids(*, include_done: bool = True) -> models.QuerySet

Return this row's scheduled chatter activities.

activity_feedback

python
def activity_feedback(activity: models.Model,
                      *,
                      feedback: str = "") -> models.Model

Mark an activity done and log the feedback in the chatter thread.

python
def activity_unlink(activity: models.Model) -> models.Model

Cancel a scheduled activity without logging a completion message.

message_thread_subject

python
def message_thread_subject() -> str

Return the default subject for this row's chatter thread.

message_creation_message

python
def message_creation_message() -> str

Return the automatic chatter body logged when this row is created.

can_post

python
def can_post(user: Any = None) -> bool

Return whether user may post to this row's chatter thread.

The single public owner of chatter post access — the record's configured :attr:thread_post_access, resolved against the ambient rebac actor. The chatter write path (message_post/message_update_content/ message_unlink/message_reaction) and the :meth:Message.can_edit / :meth:Message.can_delete read projections both consult it, so the write gate and the projection can never drift. An explicitly unauthenticated user is denied; None defers to the ambient actor the write path already runs under.

Channel

python
class Channel(Bridge)

A connected message source that ingests threads/messages from email or social.

An integrate.Integration child (credential / owner / status from the connection substrate) and a Bridge (the scheduler + syncIntegration drive it through run_sync). backend_class selects the protocol — imap (contributed by messaging_integrate_imap), later youtube/facebook — and config carries source settings. sync() fetches + parses, then maps each message onto the messaging managers.

backend_class

Registry key for the channel backend bound to this channel.

Meta

python
class Meta()

Django model options for the channel child model.

backend

python
@property
def backend() -> ChannelBackend

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

sync

python
def sync() -> int

Fetch new messages and ingest them (the Bridge child-sync contract).

ingest returns the landed message rows; the Bridge child-sync contract is a count, so this reports how many landed.

Thread

python
class Thread(SqidMixin, AuditMixin, AngeeModel)

An aggregation of related messages — an email conversation or a social post.

Two orthogonal axes, both base-owned: modality (the shape — email thread / direct / group / public post) and visibility (who can see it). A public thread's post payload (subject_url/body/tags/parent) has no producer in this base slice, so the social addon owns those columns and folds them onto this same row through the same-row extends seam. message_count/ last_message_at are denormalised and maintained with F() deltas by the ingest write path.

Modality

python
class Modality(models.TextChoices)

The structural shape of a thread.

Visibility

python
class Visibility(models.TextChoices)

Who can see a thread.

Meta

python
class Meta()

Django model options for the thread source model.

__str__

python
def __str__() -> str

Return the thread subject for Django displays.

ThreadAttachment

python
class ThreadAttachment(SqidMixin, AuditMixin, AngeeModel)

Polymorphic edge attaching one chatter thread to one model row.

AttachmentRole

python
class AttachmentRole(models.TextChoices)

Why the thread is attached to the target record.

Meta

python
class Meta()

Django model options for thread attachments.

__str__

python
def __str__() -> str

Return a readable attachment label.

ThreadFollower

python
class ThreadFollower(SqidMixin, AuditMixin, AngeeModel)

A user's subscription to a model-attached chatter thread.

NotificationPolicy

python
class NotificationPolicy(models.TextChoices)

How a follower wants to receive updates for this thread.

Meta

python
class Meta()

Django model options for thread followers.

__str__

python
def __str__() -> str

Return a readable follower label.

ThreadActivity

python
class ThreadActivity(SqidMixin, AuditMixin, AngeeModel)

A scheduled activity attached to a model chatter thread.

ActivityStatus

python
class ActivityStatus(models.TextChoices)

Stored lifecycle for an activity.

Meta

python
class Meta()

Django model options for thread activities.

activity_state

python
@property
def activity_state() -> str

Return the Odoo-style activity state for presentation.

completion_message

python
def completion_message() -> str

Return the chatter body posted when this activity is completed.

__str__

python
def __str__() -> str

Return a readable activity label.

MessageSubtype

python
class MessageSubtype(SqidMixin, AuditMixin, AngeeModel)

A typed chatter event category, mirroring Odoo's message subtypes.

Subtypes classify system notifications and comments so followers can later opt into precise event families. model_label scopes a subtype to one model; an empty value is a global subtype.

builtin_default

python
@classmethod
def builtin_default(cls, key: str) -> tuple[str, str] | None

Return the (name, description) a built-in subtype key ships with.

builtin_options

python
@classmethod
def builtin_options(cls) -> dict[str, dict[str, Any]]

Return the follower-selectable option dict for each built-in subtype key.

Ordered by declaration so the option sequence is deterministic before any row exists; existing global/model rows override these in the option list.

Meta

python
class Meta()

Django model options for message subtypes.

__str__

python
def __str__() -> str

Return a readable subtype label.

MessageReactionGroup

python
@dataclass(frozen=True)
class MessageReactionGroup()

One message's reactions of a single content, grouped for the chatter feed.

The domain read shape :meth:Message.reaction_groups returns; the GraphQL layer projects it onto its own type. Owned here beside :class:Message so the grouping fact lives once, next to the rows it summarizes, not in the resolver layer.

Message

python
class Message(SqidMixin, AuditMixin, AngeeModel, HistoryMixin)

One message — the unit of a thread. The root post is itself a Message.

Dedup key is (platform, external_id) (NOT thread-scoped — the same comment can surface under two threads). parent is the single-parent reply pointer (In-Reply-To); richer cross-message relations live on :class:MessageEdge. The body is the :class:Part tree; raw envelope recipients are kept in metadata as the lossless source behind :class:Participant.

Direction

python
class Direction(models.TextChoices)

Whether a message came in, went out, or is internal.

MessageStatus

python
class MessageStatus(models.TextChoices)

Lifecycle + public moderation state of a message.

MessageKind

python
class MessageKind(models.TextChoices)

Odoo-style functional kind of a message.

Meta

python
class Meta()

Django model options for the message source model.

content_edit_error

python
def content_edit_error() -> str | None

Return why this message's body cannot be edited, or None if it can.

The Odoo mail edit rule: only an internally authored plain comment carrying no tracking values may be re-edited; a tracked, ingested, or system message is an immutable record. Editability keys on direction == INTERNAL as well as COMMENT kind, so an ingested COMMENT-kind message (a reused-table social/mail row that never came from post_to_thread) stays immutable. This is the single predicate behind both the update_content write guard and the can_edit projection, so the two never drift.

can_edit

python
def can_edit(*, post_access: bool) -> bool

Return whether a post-authorised actor may edit this message's body.

Composes the caller-supplied record thread post access with the mail edit rule (:meth:content_edit_error) — the exact two-part guard the update_record_message mutation enforces. Owned once here so the write guard and the can_edit projection stay one predicate; the caller resolves (and, in the schema, memoizes per thread) post_access through :meth:ThreadedModelMixin.can_post.

can_delete

python
def can_delete(*, post_access: bool) -> bool

Return whether a post-authorised actor may delete this message.

Deletion carries no mail-kind restriction of its own, so the record thread's post access is the whole gate; owned beside :meth:can_edit so the projection mirrors the delete_record_message mutation without reassembling the rule.

reaction_groups

python
def reaction_groups(user: Any = None) -> list[MessageReactionGroup]

Return this message's reactions grouped by content, with user's state.

The chatter feed shows reactions grouped by content — each with a count, the reacting handles, and whether user reacted. A reactions prefetch is reused when present so a page of messages groups without a per-row query. This is the single owner of the grouping fact; the GraphQL resolver only projects it.

threaded_record

python
def threaded_record() -> models.Model | None

Return the chatter record this message's thread is attached to, if any.

A record chatter post lands in a private thread attached to one model row; walking message → thread → attachment → target lets the can_edit / can_delete projections ask that record for its own post access — the exact gate the update/delete mutations enforce.

__str__

python
def __str__() -> str

Return a readable message label for Django displays.

ThreadNotification

python
class ThreadNotification(SqidMixin, AuditMixin, AngeeModel)

One per-recipient notification/read row for a chatter message.

This is the Angee equivalent of Odoo's mail.notification for model-attached chatter: followers receive notification rows when a message is posted, and the row owns delivery/read state for that recipient.

NotificationType

python
class NotificationType(models.TextChoices)

How this notification should be delivered.

NotificationStatus

python
class NotificationStatus(models.TextChoices)

Delivery lifecycle for a notification.

Meta

python
class Meta()

Django model options for thread notifications.

__str__

python
def __str__() -> str

Return a readable notification label.

TrackingValue

python
class TrackingValue(SqidMixin, AuditMixin, AngeeModel)

One tracked old/new field value attached to a chatter message.

Meta

python
class Meta()

Django model options for tracking values.

__str__

python
def __str__() -> str

Return a compact tracked change label.

Fragment

python
class Fragment(SqidMixin, AuditMixin, AngeeModel)

A content-addressed text node shared across messages.

Email threads re-quote the same paragraphs in every reply; a hashed shared row dedups that text, makes the quotation graph a cheap FK-join (two messages quote-link iff their parts share a Fragment), and isolates signatures (one repeated signature → one Fragment, excluded from search/quotation). kind is the secondary skip axis in the quotation builder; :attr:Part.role is primary.

Because the row is content-addressed and shared — two owners quoting the same paragraph dedup to one row — it carries no REBAC type: a per-owner read on a shared row would hide the text from every owner but the first. Visibility is scoped instead by the owning :class:Part/:class:Message (each REBAC-gated); the row is reached only through a readable Part and is never enumerable on its own, mirroring storage's unscoped MimeType catalogue.

FragmentKind

python
class FragmentKind(models.TextChoices)

What a fragment of text is.

Meta

python
class Meta()

Django model options for the fragment source model.

__str__

python
def __str__() -> str

Return a truncated preview for Django displays.

Part

python
class Part(SqidMixin, AuditMixin, AngeeModel)

One recursive body node of a message (the MIME/JMAP part shape, one model).

type/role is a genuine discriminator, not MTI: a multipart/* is a container; a text part references a :class:Fragment; a byte part references a storage.File. Attachments are disposition=attachment + file; inline images are disposition=inline + cid.

Disposition

python
class Disposition(models.TextChoices)

How a part is presented.

PartRole

python
class PartRole(models.TextChoices)

The semantic role of a part — the primary quotation/search filter axis.

Meta

python
class Meta()

Django model options for the part source model.

__str__

python
def __str__() -> str

Return the part type for Django displays.

MessageEdge

python
class MessageEdge(SqidMixin, AuditMixin, AngeeModel)

One typed cross-message relation — the unified quote/reply/reference graph.

Message.parent stays the single-parent reply pointer and Thread is membership; this carries the M2M/derived relations. A derived quote edge sets fragment (the shared content-addressed text) and a confidence; both direction indexes back the bulk BFS.

EdgeKind

python
class EdgeKind(models.TextChoices)

The type of cross-message relation.

quote is produced by the messaging quotation builder; mention/ crosspost/forward are produced by the social feed overlay onto this shared graph (through MessageEdgeManager.relate). reply (carried instead by Message.parent) and duplicate have no producer in the shipped slice.

Meta

python
class Meta()

Django model options for the message-edge source model.

__str__

python
def __str__() -> str

Return a readable edge description for Django displays.

Participant

python
class Participant(SqidMixin, AuditMixin, AngeeModel)

A Handle-keyed membership of a thread/message — the queryable recipient row.

The raw to/cc/bcc stays in Message.metadata as the lossless source; this is its queryable projection, so the inbox can group/filter by participant.

ParticipantRole

python
class ParticipantRole(models.TextChoices)

The RFC-5322 envelope role of a participant.

Base messaging owns only the mail-envelope roles. The social addon layers public-membership semantics (author/owner/moderator/viewer) as additional documented string values on this same role field: a same-row extends cannot widen an existing enum, and StateField stores the raw string, so social writes those values without a schema change here.

Meta

python
class Meta()

Django model options for the participant source model.

__str__

python
def __str__() -> str

Return a readable participant label for Django displays.

Reaction

python
class Reaction(SqidMixin, AuditMixin, AngeeModel)

One attributed reaction to a message, keyed by the reactor's parties Handle.

This is the single per-actor reaction store: MessageManager.set_reaction (reached from ThreadedModelMixin.message_reaction) adds/removes/toggles a row per (message, handle, reaction), and Message.reaction_groups reads the rows back grouped by content for the chatter feed. The social addon reuses this same table for public reactions (like/repost are reaction values on the shared messaging.Message), so there is one reaction table, not two; the rolled-up public counts live separately on social.PostMetrics.

Dedup — one reaction of a given content per reactor — is enforced only for an attributed row (handle set): the unique constraint is partial on handle IS NOT NULL. A row whose handle was SET_NULL by a later Handle delete is de-attributed history, not a live reactor, so it falls out of the invariant rather than colliding (SQL treats NULLs as distinct regardless).

Meta

python
class Meta()

Django model options for the reaction source model.

__str__

python
def __str__() -> str

Return the reaction for Django displays.

clean_reaction

python
@classmethod
def clean_reaction(cls, value: Any) -> str

Return value normalized into a valid stored reaction, or raise.

The single owner of what a stored reaction value may be: null-byte scrubbed, whitespace-stripped, non-empty, and within the reaction field's own max_length. Both write paths — the user-keyed toggle (MessageManager.set_reaction) and the attributed batch overlay (ReactionManager.attribute) — clean through here, so an empty or over-length value cannot reach the table by one path while the other guards it.

MessageStar

python
class MessageStar(SqidMixin, AuditMixin, AngeeModel)

A user's Odoo-style star/favorite marker on a message.

Meta

python
class Meta()

Django model options for the message star source model.

__str__

python
def __str__() -> str

Return a readable message star label.

Released under the AGPL-3.0 License.