Skip to content

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.

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")
AttributeTypeDefaultWhat it does
_collaborateboolFalseEnables the collaboration experience: the form gets a chatter panel (messages, notes, followers, attachments) and an activities section.
_trackboolFalseEnables automatic change tracking: on every create/update, the ORM writes a Tracking Message describing what changed.
_collaborate_contactlist[str] | NoneNoneA 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_contact is a list, and entries can be a relation or a raw email field. CRM uses ["contact", "email_from"] (a Contact M2O plus a Char email), purchase uses ["vendor"], sales uses ["contact"]. When a message is sent, each named field is read: a Contact value is added to the audience, a string value is treated as an email address.

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")

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.

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.

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 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.

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 typesDefault track
Char, Boolean, Date, Datetime, Selection, Integer, Float, Monetary, ManyToOne, OneToOneTrue
Text, JSON, ManyToManyFalse
OneToMany, ManyToManynever 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 IN
internal_ref = Char(description="Reference", track=False) # opt a Char field OUT

Relational 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.

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").

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_date
await activity[0].action_done()
# Cancel — deletes the activity
await 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).

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 cursor
recent = 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")
  1. Opt in — set _collaborate = True (chatter panel) and, if you want automatic change logging, _track = True.
  2. Name the correspondent — set _collaborate_contact to the list of Contact M2O / Char email fields that identify the record’s customer (e.g. ["contact"]). This pre-populates outgoing-message recipients.
  3. Tune tracked fields — accept the per-type track defaults, then flip track=True on any important Text/JSON field and track=False on noisy Char/Integer fields.
  4. (Optional) post from code — call await Message.create_message(...) for an automated message, or await 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.