Skip to content

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.

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")
AttributeTypeDefaultWhat it does
_collaborateboolFalseEnables 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.
_trackboolFalseEnables automatic change tracking: on every create/update, the ORM writes a Tracking Message describing what changed.
_follow_fieldslist[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.

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.

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

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.

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
}

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.

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 / prefetch
await record.update(followers=[["link", contact_id]]) # add a follower (any Contact)
await record.update(followers=[["unlink", contact_id]]) # remove one

To 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: true

A 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 Contact follow 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_fields and 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.

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.

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

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: activities

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

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 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 (collaboration panel + the followers relation) and, if you want automatic change logging, _track = True.
  2. Choose who follows — set _follow_fields to 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"].
  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 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.