Collaboration, Activities & Channels
Collaboration 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. Realtime delivery of a new message (the in-app toast, the bell badge, Web Push) happens automatically when you use these methods; there is nothing extra to wire up.
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 collaboration with two independent class attributes. They do different things, and most collaboration-enabled models set both:
from fullfinity.engine.base import *
class SaleOrder(Model): _verbose_name = "Sales Order" _collaborate = True # collaboration panel + files + activities + a `followers` relation _track = True # auto-log field changes as "Tracking" messages _follow_fields = ["user", "contact"] # the salesperson and the customer follow the order
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 collaboration panel (messages, notes, followers, attachments) and an activities section, and the ORM injects a navigable followers = ManyToMany("Contact") relation. |
_track | bool | False | Enables automatic change tracking: on every create/update, the ORM writes a Tracking Message describing what changed. |
_follow_fields | list[str] | [] | Relational fields/paths (each resolving to a Contact — a User leaf → its contact, or a Contact directly) whose target is auto-subscribed into the record’s followers on create and on every reassignment. See Auto-subscribing followers. |
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 collaboration-enabled model via __inherit__ keeps the behaviour. (_follow_fields
requires _collaborate — it has nothing to populate without the injected followers relation.)
_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 collaboration panel when approvals apply to it.
There is no separate “recipient” attribute. Both internal people (the salesperson) and the external party (the customer/vendor) are followers — named in
_follow_fields. A sent message reaches the record’s followers automatically; the external party is one of them.
It stays live on its own
Section titled “It stays live on its own”Messages, followers, activities and attachments each live in their own table, not on the record — so nothing about them is picked up by a re-read of the record itself. The framework therefore refreshes them for you: post a message, attach a file, add a follower or schedule an activity, and every screen with that record open re-reads it. Including the screen of the person who did it, whose action response refreshes the record’s fields but knows nothing about these.
You do not opt in and there is no call to make. It is raised by the writes — creating a
Message, creating or deleting an Attachment that names a model and record, writing
followers, and the Activity lifecycle — so it holds however your module gets there,
including a bare Message.create(...) or an attachment saved by a route of your own.
This is deliberately silent: no toast, no bell, no unread row. It is a screen staying honest for someone already looking at it — not a notification. Telling people who are elsewhere is a separate decision, described under Followers below.
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 collaboration-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, 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}This payload is delivered over the WebSocket and Web Push automatically by the notify methods
(create_message(..., send=True) and notify_record_followers(), below); do not hand-roll a
parallel notification channel.
Followers
Section titled “Followers”A record’s followers are its standing audience — the people notified about every message on
it, independent of who is @mentioned on a given post. Followers are a real, navigable
followers = ManyToMany("Contact") relation that the framework injects on every
_collaborate model (you don’t declare it). Because it is an ordinary relation, you can:
await record.fetch_related("followers") # navigate / prefetchawait record.update(followers=[["link", contact_id]]) # add a follower (any Contact)await record.update(followers=[["unlink", contact_id]]) # remove oneTo resolve the followers of a record by (model, id), use the helper so every caller asks the
same question the same way:
followers = await Message.record_followers("SaleOrder", order.id) # -> list[Contact]Because followers is a navigable relation, you can also grant access to a record’s followers
with a record rule — traverse follower → its user:
- data_type: RecordRule model: SaleOrder groups: - - R - - sales_user_group rule: Q(followers__user__id__eq=uid) # users whose contact follows the order read_perm: trueA follower with no linked user simply doesn’t match (they can’t log in anyway), so the rule naturally grants access only to followers who are users — internal or portal.
When a message is sent, the audience is followers ∪ explicit recipients ∪ mentions,
minus the author, filtered by each recipient’s NotificationPreference. The external party
(customer/vendor) is in that set because it is a follower (via _follow_fields).
Auto-subscribing followers — _follow_fields
Section titled “Auto-subscribing followers — _follow_fields”The person a record is assigned to almost always wants to follow it. Rather than wiring
“on assign, add a follower” by hand in every model’s create/update, declare the relevant
fields once and the ORM keeps followers in sync:
class HelpdeskTicket(Model): _collaborate = True _track = True _follow_fields = ["assigned_user"] # the assignee follows the ticket
assigned_user = ManyToOne("User", related_name="assigned_helpdesk_tickets", on_delete="SET NULL")On create, and again whenever one of the listed fields is written (e.g. a reassignment),
the framework resolves each entry to a contact and links it into followers. It is idempotent
(no duplicate links) and additive — reassigning adds the new follower and leaves the previous
one in place, so a handed-off record still notifies everyone who touched it. There is nothing to
call; it happens inside the write.
Each entry resolves to a Contact (the follower is always a Contact). An entry is a relational
field name, or a dotted path through relations, whose leaf is:
_follow_fields = [ "assigned_user", # ManyToOne("User") → the user's linked contact follows "members", # ManyToMany("User") → every member's contact follows "team.team_leader", # path ending at a User "contact", # ManyToOne("Contact") → that contact (e.g. the customer) follows directly]A User leaf is shorthand resolved to its linked contact; a Contact leaf is the follower
itself. Anything that doesn’t resolve to a User or Contact (or a non-relational field) is rejected
when the model loads.
Following an external party has an access consequence. If you list a
Contactfollow field (e.g."contact") and a follower-access record rule exists, that party’s linked user is auto-granted access to the record. That’s a deliberate, useful pattern (a customer follows their own order → their portal user can read it) — just declare it knowing what it does. If you don’t want that, leave the external field out of_follow_fieldsand pass recipients explicitly when you send (e.g. a “send invoice” action that hands the modal the customer contact directly).
The model must also be _collaborate — the followers relation is injected on collaborating
models only, so _follow_fields has nothing to populate without it (rejected at load).
Inheritance. A normal subclass that sets _follow_fields replaces the inherited list
(full control). An __inherit__ extension fragment instead adds its entries to the base
model’s list — so an add-on can subscribe an extra field (e.g. ["team.team_leader"]) without
disturbing the fields the base already follows. Entries are de-duplicated; an extension cannot
remove a base follow field (that is the base model’s decision) — drop or change it at the source.
Notifying after a bare Message.create()
Section titled “Notifying after a bare Message.create()”First, be sure you want to notify at all. A message is visible on its record either way — that is automatic (see It stays live on its own). This is about interrupting people who are elsewhere, and it is per follower: every one of them gets an unread row, a WebSocket toast and a Web Push. On a record with a hundred followers, that is a hundred of each.
So the bar is correspondence — a person addressing people: a customer’s portal reply, a
live-chat message, an outbound reply to the customer. A document your module filed on the
record — a generated label, an exported report, an audit note — is not correspondence, and
wants create_message(..., send=False) (or a bare create), which still shows up on the record
without telling anyone.
create_message(..., send=True) already resolves the audience and notifies, so the collaboration
quick-reply and @mention box need nothing more. But if your module creates correspondence with a
bare Message.create(...) — a portal reply, an inbound webhook, an outbound email, a
live-chat message — call one method right after to make it land in the right people’s
badge, unread inbox and live toast:
msg = (await get_model("Message").create( content=body, author=contact, message_type="Message", model="HelpdeskTicket", document=ticket_id,))[0]await msg.notify_record_followers()notify_record_followers() resolves the audience (record followers ∪ the message’s
@mentions, minus the author), respects each
recipient’s notification preference, sends the WebSocket push
and Web Push, and links the recipients into notified_contacts — the rows that drive each
recipient’s unread inbox.
Call it exactly once per message. It both notifies and records the inbox rows, so a second call re-pushes a duplicate toast. Create the message at one site and notify there; don’t also notify from a lifecycle hook on the same message.
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).
Showing activities in List & Kanban views
Section titled “Showing activities in List & Kanban views”Add an activities indicator to a List column or a Kanban card by declaring an
- type: activities node in the view arch (nothing is shown automatically):
# List view — a column- type: activities
# List view — tight variant (icon + urgency only, activity name hidden)- type: activities properties: compact: true
# Kanban view — a card row- type: activitiesThe indicator shows the single most-urgent pending activity (type icon + name + urgency), with a count badge when more than one is pending; clicking it opens a drawer listing every activity where the user can schedule a new one or mark/edit/cancel existing ones. Scheduling from the indicator refreshes the view in place.
- Kanban cards always render the rich form (icon + name + urgency).
- List columns render the rich form too by default; set
properties: { compact: true }to fall back to the tight icon-plus-urgency form (drops the name) for a narrower column.
Channels & direct messages
Section titled “Channels & direct messages”Beyond per-record collaboration, 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 collaboration to a model
Section titled “Recipe: add collaboration to a model”- Opt in — set
_collaborate = True(collaboration panel + thefollowersrelation) and, if you want automatic change logging,_track = True. - Choose who follows — set
_follow_fieldsto the people who should be auto-subscribed: the internal owner (e.g."user"/"assigned_user") and, if you want them in the standing audience, the external party (e.g."contact") —_follow_fields = ["user", "contact"]. - 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 collaboration panel and
activities section automatically from _collaborate; the ORM writes tracking entries from
_track; and new-message notifications are delivered automatically via the shared
Message.notification_payload() shape.