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.
Declare a path in the module that owns the field it names. Each entry is resolved against the model as composed from the module you are in and its dependencies, so a base model that names a field an add-on contributes fails to load for anyone without that add-on — not with a missing follower, but with a hard error at install.
This is enforced, not advised: ./fullfinity-server check --only follow-fields (part of a
bare check, and run by CI and the pre-commit hook) fails the build on a path whose first hop
the declaring module does not own or depend on. The runtime can only judge the module
combination actually installed, which is why this class of mistake reaches production.
An add-on subscribes its own field from its __inherit__ fragment; the framework unions each
fragment’s list onto the base’s, so nothing is replaced:
# hr — the base model. It knows nothing about recruiting.class JobPosition(Model): _collaborate = True name = Char(max_length=255, required=True)
# hr_recruitment — adds the recruiter, and subscribes them beside the field.class JobPositionRecruitment(Model): __inherit__ = "JobPosition"
_follow_fields = ["recruiter.work_contact"] recruiter = ManyToOne("Employee", related_name="recruiting_positions", on_delete="SET NULL")The fragment does not restate _collaborate — the composed model answers that, and it is
checked there.
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 creation entry; an update logs one entry describing the
fields that changed.
Which fields earn a line
Section titled “Which fields earn a line”Every field type accepts track=..., and it has three states — True, False, and
unset. The third one is what lets the log tell “the author asked for this” apart from
“nobody said anything”:
| Declared | Result |
|---|---|
track=True | Always logged. |
track=False | Never logged. |
| nothing (the default) | Logged unless the value is derived — see below — in which case the per-type default applies. |
| Field types | Default when nothing is declared |
|---|---|
Char, Boolean, Date, Datetime, Selection, Integer, Float, Monetary, ManyToOne, OneToOne | logged |
Text, JSON, File, Binary, Encrypted | not logged |
OneToMany, ManyToMany | never logged (they carry command arrays, not single values) |
A derived value is not a change
Section titled “A derived value is not a change”A field the ORM works out for itself is not logged unless it says track=True — that
covers calculate=, aggregate=, and a mirrored related_field, whether stored or not.
A change log answers who changed what. Nobody edits a rollup: it moved because something else did, and that something else is already logged. Writing it down again is the same event twice, in the less useful form — an order that lost one line reported six money fields moving, and the line that actually changed was nowhere in the entry.
Some derived values genuinely are events, and those opt back in:
# Derived, and nobody's edit — the discount moved because a line did.coupon_discount = Monetary(calculate="compute_coupon_discount", store=True, currency_field="currency")
# Also derived, but reaching "Paid" is the event the reader is looking for.payment_status = Selection(choices=["Unpaid", "Partially Paid", "Paid"], calculate="compute_payment_status", store=True, track=True)The rule of thumb: would someone reading this record’s history want to be told, and is it
something no other logged line already says? A status crossing into a new state, a flag
going up (is_overdue, needs_replenishment, reconciled) — yes. A total, a quantity, a
count, a mirrored name, a date computed from another date — no.
Nothing to nothing is not a change
Section titled “Nothing to nothing is not a change”An empty before and an empty after never produce a line. For a numeric field
(Integer, BigInt, Float, Monetary) zero counts as empty, because a number that
has never been written reads as unset and lands on 0 the first time it is computed —
which is not an edit. Only both sides have to be empty, so 5 → 0 is still a real
change and is still logged.
What an entry contains
Section titled “What an entry contains”A Tracking message carries its content twice:
tracking_changes— the structured record.{"event": "created"}, or{"event": "changed", "changes": [{field, label, type, old, new, currency?}]}, wherelabelis the field’s caption in the source language and the values are raw.content— a plain source-language line per change, for a reader that has no way to render the structured form.
The structured payload is the one to read. A line composed at write time is stuck in the writer’s language and the writer’s number punctuation forever; holding the caption and the values separately lets each reader’s client resolve the caption through the ordinary translation catalogue (the English text is the key) and print an amount in its own currency’s convention rather than as a bare Python number.
Relational changes are compared by id and recorded 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, on a derived field whose value is itself an event (a status rollup reaching a new state), andtrack=Falseon noisy authored ones. - (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.