Chatter, Activities & Channels
Chatter is the discussion + audit feed attached to a record: messages, internal notes, automatic “what changed” tracking entries, file attachments, followers, and scheduled to-do activities. It is the same surface you see at the bottom of a Sales Order or a CRM lead form. This page covers the backend model surface — the models, the opt-in flags, and the methods you call. The realtime delivery of a new message (the in-app toast, the bell badge, Web Push) is documented separately in Realtime Rails (WebSocket) — link to it rather than re-deriving it.
All of these models live in the core engine and are always available:
Message, Followers, Channel, ChannelMember, Activity, ActivityType.
Two flags, two features
Section titled “Two flags, two features”A model opts into chatter with two independent class attributes. They do different things, and most chatter-enabled models set both:
from fullfinity.engine.base import *
class SaleOrder(Model): _verbose_name = "Sales Order" _collaborate = True # show the chatter panel + files + activities _track = True # auto-log field changes as "Tracking" messages _collaborate_contact = ["contact"] # default recipient(s) for outgoing messages
name = Char(required=True) contact = ManyToOne("Contact", related_name="orders", on_delete="RESTRICT")| Attribute | Type | Default | What it does |
|---|---|---|---|
_collaborate | bool | False | Enables the collaboration experience: the form gets a chatter panel (messages, notes, followers, attachments) and an activities section. |
_track | bool | False | Enables automatic change tracking: on every create/update, the ORM writes a Tracking Message describing what changed. |
_collaborate_contact | list[str] | None | None | A list of field names on the model that name the record’s customer/correspondent — each is either a ManyToOne to Contact or a Char email field. Used to pre-populate recipients when a message is sent (e.g. the chatter quick-reply, which has no recipient picker). |
These flags are independent: _track logs changes regardless of _collaborate, and
_collaborate shows the panel regardless of _track. In practice you usually want both.
The metaclass inherits each flag from base classes if a subclass does not redefine it, so
extending a chatter-enabled model via __inherit__ keeps the behaviour.
_collaborate is also auto-enabled at the view layer when an approval rule targets the
model — a model with a pending approval needs somewhere to discuss it — so even a model
that left _collaborate = False can get a chatter panel when approvals apply to it.
_collaborate_contactis a list, and entries can be a relation or a raw email field. CRM uses["contact", "email_from"](aContactM2O plus aCharemail), purchase uses["vendor"], sales uses["contact"]. When a message is sent, each named field is read: aContactvalue is added to the audience, a string value is treated as an email address.
The Message model
Section titled “The Message model”A Message is one entry in the feed. The same model backs three things, distinguished by
message_type (a Selection with human-readable values):
- “Message” — a discussion post (may be emailed to recipients).
- “Note” — an internal note (not emailed).
- “Tracking” — an automatic change-tracking entry written by the ORM.
A message is attached to a record by the model + document pair (the target model
name as a string, and the target record id as an integer) — it is not a foreign key, so
one Message model serves every chatter-enabled model. Key fields:
class Message(Model): content = Text(description="Message Content") # body (HTML) name = Text(description="Subject") from_email = Char(description="From") author = ManyToOne("Contact", related_name="messages", on_delete="SET NULL") message_type = Selection(choices=["Message", "Note", "Tracking"], default="Message") channel = ManyToOne("Channel", related_name="messages", on_delete="CASCADE") # for chat document = Integer(description="Document ID") # target record id model = Char(description="Model") # target model name parent = ManyToOne("Message", related_name="child_messages", on_delete="CASCADE") read = Boolean(default=False) contacts = ManyToMany("Contact", related_name="related_messages", through="FkMessageContact") notified_contacts = ManyToMany("Contact", related_name="related_notified_messages", through="FkMessageNotifiedContact") attachments = ManyToMany("Attachment", related_name="related_messages", through="FkMessageAttachment")Posting a message
Section titled “Posting a message”The frontend posts through the message controller’s /api/send_message route, which
calls the classmethod Message.create_message(...). You can call it directly from backend
code too:
Message = get_model("Message")await Message.create_message( body="<p>Shipped today, tracking attached.</p>", subject="Order shipped", author=author_contact, # a Contact record message_type="Message", # "Message" | "Note" | "Tracking" model="SaleOrder", document=order.id, contacts=[], # explicit recipient command rows (optional) attachments=[], # attachment command rows (optional) send=True, # also email the audience send_immediately=False,)When send=True, create_message assembles the audience from the record’s followers
plus the message’s explicit contacts plus any _collaborate_contact fields, parses
@mentions out of the body, filters by each user’s notification preference, and dispatches
the email through OutgoingMailServer and the in-app/Web-Push notification. If send=True
yields no valid email recipients it raises UserError — sending a message to nobody is an
error, not a silent no-op.
@mentions
Section titled “@mentions”create_message parses mentions from the body and adds the mentioned contacts to the
recipients. Three forms are recognised: @[42] (explicit user id), @"Full Name"
(quoted, matched against the contact name), and @username (a single word matched against
the user name). Mentioned contacts that have an email and a notification preference become
part of the notified audience.
The notification shape
Section titled “The notification shape”There is one “you received a message” payload, built by
Message.notification_payload(), so the in-app toast, the bell badge, and Web Push are all
consistent:
{ "kind": "message", "model": self.model, "document": self.document, "message_id": self.id, "title": who, # author name, else from_email, else "Someone" "body": body[:140], # subject, else stripped content}Delivery of this payload over the WebSocket / Web Push is covered in Realtime Rails; do not hand-roll a parallel notification channel.
Followers
Section titled “Followers”Followers is the standing audience of a record — the people who get notified about every
message on it, independent of who is explicitly @mentioned on a given post.
class Followers(Model): document = Integer(required=True) # target record id model = Char(required=True) # target model name contact = ManyToOne("Contact", related_name="documents_followed", required=True, on_delete="CASCADE") channel = ManyToOne("Channel", related_name="followers", on_delete="CASCADE")To resolve the followers of a record, use the helper rather than querying Followers
directly so every caller asks the same question the same way:
followers = await Message.record_followers("SaleOrder", order.id) # -> list[Contact]When a message is sent, the audience is followers ∪ explicit recipients ∪ mentions ∪
_collaborate_contact, minus the author, filtered by each recipient’s
NotificationPreference.
Automatic field-change tracking
Section titled “Automatic field-change tracking”When a model sets _track = True, the ORM calls Message.log_field_change(...) on every
create and update (it skips compute-cascade writes, clones, and the Message model
itself). Creation logs a single “Record Created” entry; an update logs an HTML diff of the
changed fields.
Which fields appear in the diff is controlled per field by the track option. Every
field type accepts track=...; the defaults are chosen so noisy/bulk fields stay out of the
feed:
| Field types | Default track |
|---|---|
Char, Boolean, Date, Datetime, Selection, Integer, Float, Monetary, ManyToOne, OneToOne | True |
Text, JSON, ManyToMany | False |
OneToMany, ManyToMany | never tracked (they carry command arrays, not single values) |
Additionally, non-stored fields (store=False: related fields and non-stored computed
fields) are never tracked — changes to those belong to their source model.
Turn tracking on or off for a single field explicitly:
notes = Text(description="Notes", track=True) # opt a Text field INinternal_ref = Char(description="Reference", track=False) # opt a Char field OUTRelational changes are compared by id and rendered as the related record’s display name
(e.g. Salesperson: Alice → Bob), so object identity never produces a false diff.
Activities
Section titled “Activities”An Activity is a scheduled to-do attached to a record and assigned to a user — “call
back”, “upload signed contract”, “review”. _collaborate models show an activities section.
class ActivityType(Model): name = Char(required=True, max_length=100) icon = Char(max_length=255) default_scheduled_date = Integer(description="Schedule After (Days)", default=1) action_type = Selection(choices=["None", "Meeting", "Upload Document"], default="None")
class Activity(Model): name = Char(description="Title", required=True, max_length=200) description = Text(description="Notes") deadline = Date(description="Due Date", required=True, default=lambda self: datetime.date.today()) activity_type = ManyToOne("ActivityType", related_name="activities", required=True, on_delete="CASCADE") model = Char() # target model name record = Integer() # target record id assigned_to = ManyToOne("User", related_name="activities", required=True, on_delete="CASCADE") done = Boolean(default=False) done_date = Datetime(description="Completed On")ActivityType ships standard types (Email, Call, Meeting, To-Do, Upload Document) whose
identifiers are protected — deleting one raises UserError. action_type uses
human-readable Selection values ("None", "Meeting", "Upload Document").
Scheduling and completing
Section titled “Scheduling and completing”Activity = get_model("Activity")ActivityType = get_model("ActivityType")
call = await ActivityType.filter(identifier="activity_call").get()activity = await Activity.create( name="Call the customer back", activity_type=call, model="CrmLead", record=lead.id, assigned_to=salesperson, # a User record deadline=date(2026, 7, 1),)
# Mark complete — sets done=True and stamps done_dateawait activity[0].action_done()
# Cancel — deletes the activityawait activity[0].action_cancel()Creating or reassigning an activity fires a best-effort realtime ping to the assignee so their Activities badge updates immediately (a self-assignment is not pinged, and the notification never breaks the create/update path).
Channels & direct messages
Section titled “Channels & direct messages”Beyond per-record chatter, the same Message model powers standalone chat — Public /
Private channels and one-to-one Direct messages. A chat message is just a Message with
channel set (there is no separate message table).
class Channel(Model): name = Char(max_length=255) kind = Selection(choices=["Public", "Private", "Direct"], default="Public") description = Char(max_length=500) members = ManyToMany("User", related_name="chat_channels", through="ChannelMember") created_by = ManyToOne("User", related_name="created_channels", on_delete="SET NULL") is_archived = Boolean(default=False) last_message_at = Datetime(description="Last Message At")
class ChannelMember(Model): channel = ManyToOne("Channel", related_name="channel_members", on_delete="CASCADE", index=True) user = ManyToOne("User", related_name="channel_memberships", on_delete="CASCADE", index=True) last_read_message = ManyToOne("Message", related_name="read_markers", on_delete="SET NULL") is_muted = Boolean(default=False)Channel exposes the chat operations as instance methods:
Channel = get_model("Channel")
# Post to a channel (creates a Message with channel set, bumps last_message_at)msg = await channel.post(user, "Anyone free to review this?")
# Paginated history, newest first, by id cursorrecent = await channel.history(limit=50, before_id=None)
# Unread = messages newer than this member's last_read marker (computed, never a stored counter)n = await channel.unread_count(user)
# Advance the read marker (defaults to the latest message)await channel.mark_read(user)Unread counts are derived from each ChannelMember.last_read_message, never a stored
counter that can drift.
Direct messages are idempotent per user pair — get_or_create_dm always maps a given pair
of users to exactly one Direct channel, with no racing duplicates:
dm = await Channel.get_or_create_dm(user_a, user_b)await dm.post(user_a, "hi")Recipe: add chatter to a model
Section titled “Recipe: add chatter to a model”- Opt in — set
_collaborate = True(chatter panel) and, if you want automatic change logging,_track = True. - Name the correspondent — set
_collaborate_contactto the list ofContactM2O /Charemail fields that identify the record’s customer (e.g.["contact"]). This pre-populates outgoing-message recipients. - Tune tracked fields — accept the per-type
trackdefaults, then fliptrack=Trueon any importantText/JSONfield andtrack=Falseon noisyChar/Integerfields. - (Optional) post from code — call
await Message.create_message(...)for an automated message, orawait Activity.create(...)to schedule a follow-up.
That is all that is required on the model. The form view picks up the chatter panel and
activities section automatically from _collaborate; the ORM writes tracking entries from
_track; and the realtime layer delivers new-message notifications via the shared
Message.notification_payload() shape described in Realtime Rails.