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
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
class Meta()Django model options for the thread behavior mixin.
save
def save(*args: Any, **kwargs: Any) -> NonePersist this row and log configured field changes in its chatter.
message_thread
def message_thread(*, create: bool = True) -> models.Model | NoneReturn this row's chatter thread, optionally creating it.
message_thread_attachment
def message_thread_attachment(*, create: bool = True) -> models.Model | NoneReturn this row's chatter thread attachment, optionally creating it.
message_post
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.ModelPost 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
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.ModelLog 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
def message_track(changes: tuple[TrackingChange | dict[str, Any], ...],
*,
body: str = "",
subject: str = "",
subtype_key: str = "record_updated") -> models.ModelLog Odoo-style field tracking values in this row's chatter thread.
message_update_content
def message_update_content(message: models.Model, *,
body: str) -> models.ModelUpdate a comment in this row's chatter thread.
message_unlink
def message_unlink(message: models.Model) -> models.ModelDelete a message from this row's chatter thread.
message_reaction
def message_reaction(message: models.Model,
*,
reaction: str,
action: str = "toggle",
user: Any) -> models.ModelAdd, remove, or toggle user's reaction on a chatter message.
message_starred
def message_starred(message: models.Model, *, user: Any) -> boolReturn whether user has starred message in this row's chatter.
message_set_starred
def message_set_starred(message: models.Model,
*,
user: Any,
starred: bool | None = None) -> boolSet or toggle user's star on a message in this row's chatter.
message_unstar_all
def message_unstar_all(*, user: Any) -> intRemove all Odoo-style stars owned by user.
message_set_done
def message_set_done(message: models.Model, *, user: Any) -> intRemove Odoo-style needaction from message for user.
message_subscribe
def message_subscribe(
*,
user: models.Model | None = None,
notification_policy: str = "inbox",
subtype_keys: tuple[str, ...] = ()
) -> models.ModelSubscribe a user to this row's chatter thread.
message_unsubscribe
def message_unsubscribe(*, user: models.Model | None = None) -> boolUnsubscribe a user from this row's chatter thread.
message_is_follower
def message_is_follower(*, user: models.Model | None = None) -> boolReturn whether a user follows this row's chatter thread.
message_followers
def message_followers() -> models.QuerySetReturn this row's chatter followers.
message_suggested_recipients
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
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.ModelSchedule an activity on this row's chatter thread.
activity_ids
def activity_ids(*, include_done: bool = True) -> models.QuerySetReturn this row's scheduled chatter activities.
activity_feedback
def activity_feedback(activity: models.Model,
*,
feedback: str = "") -> models.ModelMark an activity done and log the feedback in the chatter thread.
activity_unlink
def activity_unlink(activity: models.Model) -> models.ModelCancel a scheduled activity without logging a completion message.
message_thread_subject
def message_thread_subject() -> strReturn the default subject for this row's chatter thread.
message_creation_message
def message_creation_message() -> strReturn the automatic chatter body logged when this row is created.
can_post
def can_post(user: Any = None) -> boolReturn 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
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
class Meta()Django model options for the channel child model.
backend
@property
def backend() -> ChannelBackendReturn this channel's selected backend, bound to this row.
sync
def sync() -> intFetch 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
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
class Modality(models.TextChoices)The structural shape of a thread.
Visibility
class Visibility(models.TextChoices)Who can see a thread.
Meta
class Meta()Django model options for the thread source model.
__str__
def __str__() -> strReturn the thread subject for Django displays.
ThreadAttachment
class ThreadAttachment(SqidMixin, AuditMixin, AngeeModel)Polymorphic edge attaching one chatter thread to one model row.
AttachmentRole
class AttachmentRole(models.TextChoices)Why the thread is attached to the target record.
Meta
class Meta()Django model options for thread attachments.
__str__
def __str__() -> strReturn a readable attachment label.
ThreadFollower
class ThreadFollower(SqidMixin, AuditMixin, AngeeModel)A user's subscription to a model-attached chatter thread.
NotificationPolicy
class NotificationPolicy(models.TextChoices)How a follower wants to receive updates for this thread.
Meta
class Meta()Django model options for thread followers.
__str__
def __str__() -> strReturn a readable follower label.
ThreadActivity
class ThreadActivity(SqidMixin, AuditMixin, AngeeModel)A scheduled activity attached to a model chatter thread.
ActivityStatus
class ActivityStatus(models.TextChoices)Stored lifecycle for an activity.
Meta
class Meta()Django model options for thread activities.
activity_state
@property
def activity_state() -> strReturn the Odoo-style activity state for presentation.
completion_message
def completion_message() -> strReturn the chatter body posted when this activity is completed.
__str__
def __str__() -> strReturn a readable activity label.
MessageSubtype
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
@classmethod
def builtin_default(cls, key: str) -> tuple[str, str] | NoneReturn the (name, description) a built-in subtype key ships with.
builtin_options
@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
class Meta()Django model options for message subtypes.
__str__
def __str__() -> strReturn a readable subtype label.
MessageReactionGroup
@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
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
class Direction(models.TextChoices)Whether a message came in, went out, or is internal.
MessageStatus
class MessageStatus(models.TextChoices)Lifecycle + public moderation state of a message.
MessageKind
class MessageKind(models.TextChoices)Odoo-style functional kind of a message.
Meta
class Meta()Django model options for the message source model.
content_edit_error
def content_edit_error() -> str | NoneReturn 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
def can_edit(*, post_access: bool) -> boolReturn 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
def can_delete(*, post_access: bool) -> boolReturn 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
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
def threaded_record() -> models.Model | NoneReturn 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__
def __str__() -> strReturn a readable message label for Django displays.
ThreadNotification
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
class NotificationType(models.TextChoices)How this notification should be delivered.
NotificationStatus
class NotificationStatus(models.TextChoices)Delivery lifecycle for a notification.
Meta
class Meta()Django model options for thread notifications.
__str__
def __str__() -> strReturn a readable notification label.
TrackingValue
class TrackingValue(SqidMixin, AuditMixin, AngeeModel)One tracked old/new field value attached to a chatter message.
Meta
class Meta()Django model options for tracking values.
__str__
def __str__() -> strReturn a compact tracked change label.
Fragment
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
class FragmentKind(models.TextChoices)What a fragment of text is.
Meta
class Meta()Django model options for the fragment source model.
__str__
def __str__() -> strReturn a truncated preview for Django displays.
Part
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
class Disposition(models.TextChoices)How a part is presented.
PartRole
class PartRole(models.TextChoices)The semantic role of a part — the primary quotation/search filter axis.
Meta
class Meta()Django model options for the part source model.
__str__
def __str__() -> strReturn the part type for Django displays.
MessageEdge
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
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
class Meta()Django model options for the message-edge source model.
__str__
def __str__() -> strReturn a readable edge description for Django displays.
Participant
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
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
class Meta()Django model options for the participant source model.
__str__
def __str__() -> strReturn a readable participant label for Django displays.
Reaction
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
class Meta()Django model options for the reaction source model.
__str__
def __str__() -> strReturn the reaction for Django displays.
clean_reaction
@classmethod
def clean_reaction(cls, value: Any) -> strReturn 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
class MessageStar(SqidMixin, AuditMixin, AngeeModel)A user's Odoo-style star/favorite marker on a message.
Meta
class Meta()Django model options for the message star source model.
__str__
def __str__() -> strReturn a readable message star label.