EVERJUST Website Forum — Agent Skill
Operate the Q&A Forum of a live everjust.app tenant as an agent: create and configure a
forum, ask questions and post answers, accept the correct answer, vote, tag, and moderate — all
through the Odoo MCP / ORM (search, get, create, update, call, describe_model; see
[[everjust-agent-mcp]] for opening the session against the right tenant DB). The public
/forum/... pages are stock QWeb; edit those with the website_* tools (see the last
recipe), not this model layer.
The crucial everjust fact: there is NO custom everjust forum addon. This is stock Odoo
website_forum riding on gamification. Read the field set from the live model
(describe_model / fields_get), not from an everjust addon. What you must internalize is
everything else on this page: the lifecycle is expressed as forum.post rows (a question is
a root post; an answer is a forum.post with parent_id set — an answer is NOT a
mail.message comment), the "accept" is a boolean is_correct, and every mutation is
karma-gated by the owning forum.forum's karma_* fields against the acting user's
res.users.karma. website_forum is optional per tenant — it is installed on connectdomain
(one live forum, Connect Domain Community), but on a tenant without it forum.forum is a
KeyError; check first (see Pitfalls).
When to use this skill
- Configure a forum — create a
forum.forum, set mode (questions vs discussions),
privacy, default sort, welcome/FAQ, and (importantly) tune the karma_* economy.
- Ask a question — create a root
forum.post (parent_id unset).
- Answer a question — create a child
forum.post with parent_id = <question id>.
- Accept the correct answer — set the answer's
is_correct = True (awards karma).
- Vote — up/downvote a post via its
vote() method (never write forum.post.vote raw).
- Tag / retag — create
forum.tag rows and attach them to a question's tag_ids.
- Moderate — validate a
pending question, close/reopen with a reason, flag, or
mark offensive; read the moderation queues (count_posts_waiting_validation,
count_flagged_posts).
- Grant karma — bootstrap a user so they clear the
karma_* gates (_add_karma /
writing res.users.karma).
- Edit the public forum pages / layout — QWeb, via the website_* MCP tools.
Do NOT use this skill for, and stop if the task is really:
- Odoo Discuss / chatter / general
message_post — forum comments are mail.message
on a post, but the Q&A objects (question/answer) are forum.post. Don't model an answer as a
chatter comment (see Pitfalls) and don't reach for [[everjust-mail-ops]] (that's the separate
webmail stack).
- The
/forum page HTML/design — that's stock QWeb served by the website_forum
controllers; rewrite it with website_edit_page (COW-safe), not by writing ir.ui.view.
- A tenant without
website_forum installed — installing a module is platform-ops; see
[[everjust-platform]].
Architecture (the model map)
website_forum layers on mail.thread + website.* mixins + gamification. Everything below
is stock Odoo 19 (verified live on connectdomain).
| Model |
Role |
Key fields |
forum.forum |
A forum container. Holds the entire karma economy — the karma_gen_* (earnings) and karma_* (action thresholds) fields the whole app reads. |
name, mode (questions=1 answer|discussions=multiple), privacy (public|connected|private + authorized_group_id), default_order, faq, welcome_message, tag_ids, post_ids, can_moderate (computed: user.karma >= karma_moderate), count_posts_waiting_validation, count_flagged_posts, plus the karma fields below. |
forum.post |
The Q&A object — both questions AND answers. A question is a root post (parent_id unset); an answer is a post with parent_id = the question. |
name (title; answers usually blank/Re:), content (Html), forum_id (required), parent_id (→ question; set = this is an answer), state (active|pending|close|offensive|flagged), is_correct (the "accepted answer" flag), active, tag_ids, vote_count (store), child_count, has_validated_answer, closed_reason_id/uid/date, moderator_id, flag_user_id, and a large family of can_*/karma_* computed rights (can_answer, can_accept, can_close, can_moderate, karma_accept, …). |
forum.post.vote |
One user's vote on a post. Written only via forum.post.vote() — the karma side-effects live in its own create/write. |
post_id, user_id (default = caller; can't set another's), vote ('1'|'-1'|'0'), forum_id/recipient_id (related, stored). Unique (post_id, user_id). |
forum.tag |
A tag scoped to one forum. Creating one costs karma_tag_create. |
name, forum_id (required), color, post_ids (m2m via forum_tag_rel, domain state=active), posts_count. Unique (name, forum_id). |
forum.post.reason |
Close/offensive reason picklist (seed data). |
name, reason_type (basic|offensive). Key xmlids: website_forum.reason_7 (offensive) and website_forum.reason_8 (spam) trigger the karma-penalty branch in close(). |
res.users |
Carries karma — a STORED computed integer recomputed from gamification.karma.tracking history (_compute_karma). Every earn/spend is a tracking row. |
karma, karma_tracking_ids (grouped base.group_system), rank_id, *_badge. |
gamification.karma.tracking |
Immutable ledger of karma changes (old_value→new_value, reason, origin_ref). The source of truth karma derives from. |
Don't hand-edit; use _add_karma. |
Forum URL shape (all computed): forum /forum/<slug>-<id>, question
/forum/<forum-slug>/<post-slug>, answer adds #answer_<id>, tag
/forum/<forum-slug>/tag/<tag-slug>/questions.
The karma economy (this is the whole app)
Two families of Integer fields on forum.forum (defaults shown; live connectdomain
values differ — e.g. that forum runs karma_ask=3, karma_answer=3, karma_upvote=5, karma_downvote=50, karma_tag_create=30, karma_moderate=1000, karma_answer_accept_own=20, karma_answer_accept_all=500 — always read the real forum, never assume defaults):
- Earnings —
karma_gen_* (added to a user's karma when an event fires):
karma_gen_question_new (+2 ask), karma_gen_question_upvote (+5), karma_gen_question_downvote
(−2), karma_gen_answer_upvote (+10), karma_gen_answer_downvote (−2),
karma_gen_answer_accept (+2 to the accepter), karma_gen_answer_accepted (+15 to the answer
author when their answer is accepted), karma_gen_answer_flagged (−100, spam/offensive
penalty).
- Action thresholds —
karma_* (min karma the acting user needs, else AccessError):
karma_ask (3), karma_answer (3), karma_upvote (5), karma_downvote (50),
karma_answer_accept_own (20) / karma_answer_accept_all (500), karma_edit_own (1) /
karma_edit_all (300), karma_edit_retag (75), karma_close_own (100) / karma_close_all
(500), karma_unlink_own (500) / karma_unlink_all (1000), karma_tag_create (30),
karma_flag (500), karma_moderate (1000), karma_post (100 — below it, a new question lands
pending and needs moderation), karma_editor (30 — below it you can't post images/links),
karma_dofollow (500 — below it your links get rel=nofollow), karma_comment_*,
karma_comment_convert_*, karma_comment_unlink_*.
The own-vs-all pattern is everywhere: acting on your own post needs the _own threshold;
acting on anyone's needs the (higher) _all threshold. The post exposes the resolved value as
computed fields — post.karma_accept, post.karma_close, post.karma_edit, post.karma_unlink
— and the boolean gate as post.can_accept, post.can_close, post.can_edit, etc. Read those
can_* booleans before you attempt the write — they already fold in own/all AND admin bypass
(env.is_admin() skips all karma gates).
Recipes
Route each through the Odoo MCP (open an env on the tenant DB — see [[everjust-agent-mcp]]).
Prefer the generic search/get/create/update tools; use call for the model methods
(vote, close, validate, reopen). Non-read call and update on structural things need
confirm:true. Because writes are karma-gated, the agent's own res.users.karma matters —
if you're acting as a low-karma service user, either bootstrap its karma (last recipe) or act as
an admin user (admin bypasses every gate).
0. Confirm the app + read the forum's live karma settings (do this first)
"forum.forum" in env # False on tenants without website_forum → stop
forum = env["forum.forum"].search([], limit=1) # e.g. "Connect Domain Community", id 2
forum.read(["name", "mode", "privacy", "default_order",
"karma_ask", "karma_answer", "karma_upvote", "karma_downvote",
"karma_tag_create", "karma_post", "karma_moderate",
"karma_answer_accept_own", "karma_answer_accept_all",
"karma_gen_question_new", "karma_gen_answer_accepted", "karma_gen_answer_flagged"])
env.user.karma # the acting user's spendable karma — every gate compares to this
forum.can_moderate # True iff env.user.karma >= forum.karma_moderate
Never assume the module defaults — this forum may have been re-tuned. mode='questions'
enforces one answer per question in the UI flow; discussions allows many.
1. Ask a question (root post) and answer it (child post)
# QUESTION = a root forum.post (no parent_id). Requires env.user.karma >= forum.karma_ask.
qid = env["forum.post"].create({
"forum_id": forum.id,
"name": "How does Connect Domain auto-provision SSL?",
"content": "<p>What issues the cert, and when does it renew?</p>",
"tag_ids": [(6, 0, [])], # optional; see tag recipe
}).id
# Note the auto-gates baked into create():
# • karma < karma_ask → AccessError; karma < karma_post → the question is saved state='pending'
# (needs a moderator to validate() before it's publicly 'active').
# • karma < karma_editor and content has <img>/<a> → AccessError ("karma required to post image/link").
# • asking a question (when it lands 'active') auto-awards karma_gen_question_new.
# ANSWER = a forum.post WITH parent_id = the question. Requires karma >= forum.karma_answer.
aid = env["forum.post"].create({
"forum_id": forum.id,
"parent_id": qid, # <-- THIS is what makes it an answer, not a question
"content": "<p>ACM issues it via DNS-01; it renews ~30 days before expiry.</p>",
}).id
# Answering a closed/deleted question raises UserError. In 'questions' mode the UI allows
# only one answer per author; the ORM lets you create more, so respect mode yourself.
An answer's name is typically empty or Re: <question>; the body is content. Do not
create an answer as a chatter comment — an answer is a first-class forum.post and only rows
with a parent_id count as answers (child_count, has_validated_answer, the QAPage schema).
2. Accept the correct answer (is_correct) — the karma-bearing "solve"
answer = env["forum.post"].browse(aid)
answer.can_accept # gate: fold(own/all) — accepting on YOUR OWN question needs
# karma_answer_accept_own, on anyone's needs karma_answer_accept_all
# Accept = set is_correct True on the ANSWER (write() enforces can_accept and moves karma):
env["forum.post"].write([aid], {"is_correct": True}) # MCP update tool
# → answer author gains karma_gen_answer_accepted (+15 default);
# the accepter gains karma_gen_answer_accept (+2 default) — UNLESS accepter == author
# (self-acceptance grants no karma). Un-accepting (is_correct=False) reverses both.
# Read back the "solved" rollup on the question:
env["forum.post"].browse(qid).read(["has_validated_answer", "child_count"])
is_correct is the accepted-answer flag; the question's _order floats correct answers to the
top (is_correct DESC, vote_count DESC, ...). In questions mode there is conceptually one
accepted answer.
3. Vote — always via the vote() method (never write forum.post.vote directly)
# Toggle semantics: calling vote(upvote=True) again removes the upvote; opposite vote flips it.
env["forum.post"].call(qid, "vote", kwargs={"upvote": True}) # → {"vote_count", "user_vote"}
env["forum.post"].call(aid, "vote", kwargs={"upvote": False}) # downvote
Gates the method enforces for you: you cannot vote on your own post (UserError);
upvoting needs karma_upvote, downvoting needs the (much higher) karma_downvote — unless you're
toggling off an existing opposite vote. Voting moves karma to the post's author
(recipient_id) per the forum's karma_gen_*_upvote/downvote. Writing a forum.post.vote row by
hand bypasses the own-post check, the karma gate, AND the karma award — don't.
4. Tags: create + attach + retag
# Create a forum-scoped tag (costs karma_tag_create; unique per forum). Admin bypasses.
tag = env["forum.tag"].create({"name": "ssl", "forum_id": forum.id})
# Attach tags to a QUESTION (only questions carry tags). Retagging an existing post's tags
# needs karma_edit_retag if you're changing the set:
env["forum.post"].write([qid], {"tag_ids": [(6, 0, [tag.id])]}) # replace set
env["forum.post"].write([qid], {"tag_ids": [(4, tag.id)]}) # add one
# Read a forum's tags + usage:
env["forum.tag"].search_read([("forum_id", "=", forum.id)], ["name", "posts_count"])
posts_count only counts state='active' posts (it's in the m2m domain). The forum also
exposes tag_most_used_ids / tag_unused_ids.
5. Moderate: validate a pending question, close/reopen, flag, mark offensive
# The moderation queues on the forum:
forum.read(["count_posts_waiting_validation", "count_flagged_posts"])
pending = env["forum.post"].search([("forum_id","=",forum.id), ("state","=","pending")])
flagged = env["forum.post"].search([("forum_id","=",forum.id), ("state","=","flagged")])
# VALIDATE a pending question (approve it) — needs karma_moderate; awards the ask-karma that
# was withheld while pending, sets state='active', moderator_id=you:
env["forum.post"].call(pending.ids, "validate")
# CLOSE a question with a reason (pass the reason RECORD id). Closing spam/offensive
# (reason_8 / reason_7) also DOCKS the author's karma (karma_gen_answer_flagged, ×10 on a
# first post). Only questions (parent_id unset) can be closed.
reason_off = env.ref("website_forum.reason_7").id # "offensive" (id 6 on connectdomain)
reason_spam = env.ref("website_forum.reason_8").id # "spam" (id 7)
reason_dupe = env.ref("website_forum.reason_1").id # "Duplicate post" (no karma penalty)
env["forum.post"].call(qid, "close", args=[reason_dupe]) # gate: can_close (own/all)
env["forum.post"].call(qid, "reopen") # reverses; refunds spam/offensive dock
# FLAG a post as offensive (needs karma_flag) → state='flagged' for a moderator to review:
env["forum.post"].call(aid, "_flag")
# MARK OFFENSIVE (moderator, needs karma_moderate): archives + docks author karma:
env["forum.post"].call(aid, "_mark_as_offensive", args=[reason_off])
close/reopen/validate/_flag/_mark_as_offensive are model methods (use the call tool
with confirm:true). They each re-check the karma gate server-side and raise AccessError if the
acting user is short — so if you're a low-karma service user, moderate as an admin or grant
karma_moderate first.
6. Grant a user forum karma (so they clear the gates)
res.users.karma is a stored computed field backed by the karma ledger — writing it directly
works (it logs a tracking row via res.users.write), but the idiomatic path is _add_karma:
u = env["res.users"].browse(uid)
u.read(["login", "karma"])
# Idiomatic: append a ledger entry (also updates the stored karma):
env["res.users"].call([uid], "_add_karma", args=[1000], kwargs={"reason": "Forum bootstrap"})
# Or set an absolute value (write() diffs and logs the delta to gamification.karma.tracking):
env["res.users"].write([uid], {"karma": 1000})
Karma is per-user, tenant-wide (not per-forum) — it's a res.users field, so a user's karma
applies across every forum on that tenant. Grant enough to clear the specific karma_* gate the
task needs (e.g. karma_moderate to run recipe 5). Do not hand-edit
gamification.karma.tracking rows.
7. Edit the PUBLIC forum pages (QWeb) — website_* tools, not this ORM
The /forum list, question, and tag pages are stock website_forum QWeb served by its
controllers. To restyle/copy-tweak them, go through the website tools (COW-safe — Odoo forks
the module view into a site-specific ir.ui.view so upgrades never clobber your edit):
website_pages # find the /forum-related pages + their view ids
website_edit_page(url="/forum", arch=<full QWeb XML>) # read current arch FIRST; keep the idiom
website_publish(url="/forum", published=True, indexed=True)
website_menu(name="Community", url="/forum") # add a nav entry (there's already one)
On everjust the marketing pages are Tailwind-utility QWeb wrapped in
<t t-call="website.layout">; the forum templates are stock Bootstrap QWeb — either way, read
the current arch before rewriting and reuse the existing classes. Editing forum pages needs the
website.group_website_designer role (the everjust Administrator role includes it —
see [[everjust-platform]]). Never hand-write the ir.ui.view directly; use website_edit_page.
Pitfalls
An answer is a forum.post with parent_id, NOT a chatter comment. The Q&A hierarchy is
parent (question) → children (answers), all forum.post. mail.message comments exist on a
post too, but they are comments, and only real answers count toward child_count,
has_validated_answer, acceptance, and the QAPage schema. Don't model "answer" as
message_post — and don't confuse this with the [[everjust-mail-ops]] webmail stack.
is_correct is the "accept," and self-acceptance grants no karma. Setting
is_correct=True on an answer awards karma_gen_answer_accepted to the author and
karma_gen_answer_accept to the accepter — unless the accepter is the author, in which case
no karma moves. Flipping it back reverses the award. Deleting an already-accepted answer also
claws back the karma.
Every write is karma-gated against env.user.karma — read can_* first. create on a
post checks can_ask/can_answer; write checks can_edit/can_close/can_accept/
can_unlink/retag; vote/close/validate/_flag/_mark_as_offensive each re-check. A
low-karma service user gets AccessError. Either act as an admin (bypasses all gates via
env.is_admin()) or grant the user the needed karma (recipe 6) — don't try to force the write.
Never write forum.post.vote directly — call vote(). The own-post ban, the
up/downvote karma gate, and the karma award to the author all live in the vote model's
create/write and in the toggle logic. A raw row skips all three and corrupts the karma ledger.
karma_post decides pending vs active on a NEW question. Below karma_post (default 100),
a newly asked question is saved state='pending' and is invisible publicly until a moderator
validate()s it (which then awards the withheld ask-karma). So "I created it but it's not on
the site" usually means it's pending, not failed.
close/reopen/mark offensive take a reason RECORD id and can move karma. Pass the
forum.post.reason id (resolve via env.ref("website_forum.reason_N")). Closing with the
spam (reason_8) or offensive (reason_7) reason docks the author's karma
(karma_gen_answer_flagged, ×10 for a first post); reopen refunds it. Only root questions
(parent_id unset) can be closed/reopened.
res.users.karma is a stored computed field over gamification.karma.tracking. Writing
karma works (it diffs and logs a tracking row) but never edit tracking rows by hand and never
assume karma is a plain writable integer with no side-effects. Karma is tenant-wide per
user, not per-forum.
website_forum is optional per tenant. It's installed on connectdomain; on a tenant
without it, forum.forum/forum.post raise KeyError. Check "forum.forum" in env (or
list_installed_modules) before acting, and remember you're bounded by your Odoo user's
permissions and karma on this one tenant DB (see [[everjust-agent-mcp]]).
mode is advisory at the ORM layer. questions mode means "one answer" in the guided UI,
but the ORM will let you create multiple child posts. If the task implies single-answer
semantics, enforce it yourself (check existing child_ids before adding an answer).
Don't restyle the forum by writing ir.ui.view. Use website_edit_page (COW-safe) so a
module upgrade never clobbers the edit; read the page's current arch first and keep the
existing QWeb/class idiom (recipe 7).
1---2name: everjust-website-forum3description: Operate the Q&A Forum (website_forum) app of a live everjust.app tenant via the Odoo MCP/ORM — the full ask→answer→accept→close/validate lifecycle plus votes, tags, the karma economy, and moderation. Use when the task is to create/configure a forum.forum, post a question or an answer (answers are forum.post rows with a parent_id, NOT comments), accept the correct answer (is_correct), up/downvote a post, add/retag tags, moderate (validate a pending post, close/reopen, flag, mark offensive), grant a user forum karma, or read forum state. This is STOCK Odoo website_forum + gamification (karma is res.users.karma, a STORED field driven by gamification.karma.tracking) — NO custom everjust forum addon; every write is karma-gated by the forum's karma_* fields and you act as one Odoo user. The public /forum pages are QWeb edited via the website_* tools, NOT this ORM. Cross-references [[everjust-platform]], [[everjust-agent-mcp]], and siblings [[everjust-events]], [[everjust-mail-ops]].4---56# EVERJUST Website Forum — Agent Skill78Operate the **Q&A Forum** of a live everjust.app tenant as an agent: create and configure a9forum, ask questions and post answers, accept the correct answer, vote, tag, and moderate — all10through the Odoo MCP / ORM (`search`, `get`, `create`, `update`, `call`, `describe_model`; see11[[everjust-agent-mcp]] for opening the session against the right tenant DB). The public12`/forum/...` pages are stock QWeb; edit those with the **website_\*** tools (see the last13recipe), not this model layer.1415The crucial everjust fact: **there is NO custom everjust forum addon.** This is **stock Odoo16`website_forum`** riding on **`gamification`**. Read the field set from the live model17(`describe_model` / `fields_get`), not from an everjust addon. What you must internalize is18everything else on this page: the lifecycle is expressed as `forum.post` rows (a **question** is19a root post; an **answer** is a `forum.post` with `parent_id` set — an answer is NOT a20`mail.message` comment), the "accept" is a boolean `is_correct`, and **every mutation is21karma-gated** by the owning `forum.forum`'s `karma_*` fields against the acting user's22`res.users.karma`. `website_forum` is optional per tenant — it is installed on `connectdomain`23(one live forum, *Connect Domain Community*), but on a tenant without it `forum.forum` is a24`KeyError`; check first (see Pitfalls).2526## When to use this skill2728- **Configure a forum** — create a `forum.forum`, set `mode` (questions vs discussions),29 `privacy`, default sort, welcome/FAQ, and (importantly) tune the `karma_*` economy.30- **Ask a question** — create a root `forum.post` (`parent_id` unset).31- **Answer a question** — create a child `forum.post` with `parent_id = <question id>`.32- **Accept the correct answer** — set the answer's `is_correct = True` (awards karma).33- **Vote** — up/downvote a post via its `vote()` method (never write `forum.post.vote` raw).34- **Tag / retag** — create `forum.tag` rows and attach them to a question's `tag_ids`.35- **Moderate** — validate a `pending` question, close/reopen with a reason, flag, or36 mark offensive; read the moderation queues (`count_posts_waiting_validation`,37 `count_flagged_posts`).38- **Grant karma** — bootstrap a user so they clear the `karma_*` gates (`_add_karma` /39 writing `res.users.karma`).40- **Edit the public forum pages / layout** — QWeb, via the **website_\*** MCP tools.4142**Do NOT use this skill for**, and stop if the task is really:43- **Odoo Discuss / chatter / general `message_post`** — forum *comments* are `mail.message`44 on a post, but the Q&A objects (question/answer) are `forum.post`. Don't model an answer as a45 chatter comment (see Pitfalls) and don't reach for [[everjust-mail-ops]] (that's the separate46 webmail stack).47- **The `/forum` page HTML/design** — that's stock QWeb served by the `website_forum`48 controllers; rewrite it with `website_edit_page` (COW-safe), not by writing `ir.ui.view`.49- **A tenant without `website_forum` installed** — installing a module is platform-ops; see50 [[everjust-platform]].5152## Architecture (the model map)5354`website_forum` layers on `mail.thread` + `website.*` mixins + `gamification`. Everything below55is stock Odoo 19 (verified live on `connectdomain`).5657| Model | Role | Key fields |58|---|---|---|59| `forum.forum` | A forum container. **Holds the entire karma economy** — the `karma_gen_*` (earnings) and `karma_*` (action thresholds) fields the whole app reads. | `name`, `mode` (`questions`=1 answer\|`discussions`=multiple), `privacy` (`public`\|`connected`\|`private` + `authorized_group_id`), `default_order`, `faq`, `welcome_message`, `tag_ids`, `post_ids`, `can_moderate` (computed: `user.karma >= karma_moderate`), `count_posts_waiting_validation`, `count_flagged_posts`, plus the karma fields below. |60| `forum.post` | **The Q&A object — both questions AND answers.** A question is a root post (`parent_id` unset); an answer is a post with `parent_id` = the question. | `name` (title; answers usually blank/`Re:`), `content` (Html), `forum_id` (required), `parent_id` (→ question; set = this is an answer), `state` (`active`\|`pending`\|`close`\|`offensive`\|`flagged`), `is_correct` (**the "accepted answer" flag**), `active`, `tag_ids`, `vote_count` (store), `child_count`, `has_validated_answer`, `closed_reason_id/uid/date`, `moderator_id`, `flag_user_id`, and a large family of `can_*`/`karma_*` computed rights (`can_answer`, `can_accept`, `can_close`, `can_moderate`, `karma_accept`, …). |61| `forum.post.vote` | One user's vote on a post. **Written only via `forum.post.vote()`** — the karma side-effects live in its own create/write. | `post_id`, `user_id` (default = caller; can't set another's), `vote` (`'1'`\|`'-1'`\|`'0'`), `forum_id`/`recipient_id` (related, stored). Unique `(post_id, user_id)`. |62| `forum.tag` | A tag scoped to one forum. Creating one costs `karma_tag_create`. | `name`, `forum_id` (required), `color`, `post_ids` (m2m via `forum_tag_rel`, domain state=active), `posts_count`. Unique `(name, forum_id)`. |63| `forum.post.reason` | Close/offensive reason picklist (seed data). | `name`, `reason_type` (`basic`\|`offensive`). Key xmlids: `website_forum.reason_7` (offensive) and `website_forum.reason_8` (spam) trigger the karma-penalty branch in `close()`. |64| `res.users` | Carries **`karma`** — a **STORED computed integer** recomputed from `gamification.karma.tracking` history (`_compute_karma`). Every earn/spend is a tracking row. | `karma`, `karma_tracking_ids` (grouped `base.group_system`), `rank_id`, `*_badge`. |65| `gamification.karma.tracking` | Immutable ledger of karma changes (`old_value`→`new_value`, `reason`, `origin_ref`). The source of truth `karma` derives from. | Don't hand-edit; use `_add_karma`. |6667Forum URL shape (all computed): forum `/forum/<slug>-<id>`, question68`/forum/<forum-slug>/<post-slug>`, answer adds `#answer_<id>`, tag69`/forum/<forum-slug>/tag/<tag-slug>/questions`.7071### The karma economy (this is the whole app)7273Two families of Integer fields on **`forum.forum`** (defaults shown; **live `connectdomain`74values differ** — e.g. that forum runs `karma_ask=3, karma_answer=3, karma_upvote=5,75karma_downvote=50, karma_tag_create=30, karma_moderate=1000, karma_answer_accept_own=20,76karma_answer_accept_all=500` — always read the real forum, never assume defaults):7778- **Earnings — `karma_gen_*`** (added to a user's `karma` when an event fires):79 `karma_gen_question_new` (+2 ask), `karma_gen_question_upvote` (+5), `karma_gen_question_downvote`80 (−2), `karma_gen_answer_upvote` (+10), `karma_gen_answer_downvote` (−2),81 `karma_gen_answer_accept` (+2 to the *accepter*), `karma_gen_answer_accepted` (+15 to the *answer82 author* when their answer is accepted), `karma_gen_answer_flagged` (−100, spam/offensive83 penalty).84- **Action thresholds — `karma_*`** (min karma the acting user needs, else `AccessError`):85 `karma_ask` (3), `karma_answer` (3), `karma_upvote` (5), `karma_downvote` (50),86 `karma_answer_accept_own` (20) / `karma_answer_accept_all` (500), `karma_edit_own` (1) /87 `karma_edit_all` (300), `karma_edit_retag` (75), `karma_close_own` (100) / `karma_close_all`88 (500), `karma_unlink_own` (500) / `karma_unlink_all` (1000), `karma_tag_create` (30),89 `karma_flag` (500), `karma_moderate` (1000), `karma_post` (100 — below it, a new question lands90 `pending` and needs moderation), `karma_editor` (30 — below it you can't post images/links),91 `karma_dofollow` (500 — below it your links get `rel=nofollow`), `karma_comment_*`,92 `karma_comment_convert_*`, `karma_comment_unlink_*`.9394The **own-vs-all pattern is everywhere**: acting on *your own* post needs the `_own` threshold;95acting on *anyone's* needs the (higher) `_all` threshold. The post exposes the resolved value as96computed fields — `post.karma_accept`, `post.karma_close`, `post.karma_edit`, `post.karma_unlink`97— and the boolean gate as `post.can_accept`, `post.can_close`, `post.can_edit`, etc. **Read those98`can_*` booleans before you attempt the write** — they already fold in own/all AND admin bypass99(`env.is_admin()` skips all karma gates).100101## Recipes102103Route each through the Odoo MCP (open an `env` on the tenant DB — see [[everjust-agent-mcp]]).104Prefer the generic `search`/`get`/`create`/`update` tools; use `call` for the model methods105(`vote`, `close`, `validate`, `reopen`). Non-read `call` and `update` on structural things need106`confirm:true`. **Because writes are karma-gated, the agent's own `res.users.karma` matters** —107if you're acting as a low-karma service user, either bootstrap its karma (last recipe) or act as108an admin user (admin bypasses every gate).109110### 0. Confirm the app + read the forum's live karma settings (do this first)111112```python113"forum.forum" in env # False on tenants without website_forum → stop114forum = env["forum.forum"].search([], limit=1) # e.g. "Connect Domain Community", id 2115forum.read(["name", "mode", "privacy", "default_order",116 "karma_ask", "karma_answer", "karma_upvote", "karma_downvote",117 "karma_tag_create", "karma_post", "karma_moderate",118 "karma_answer_accept_own", "karma_answer_accept_all",119 "karma_gen_question_new", "karma_gen_answer_accepted", "karma_gen_answer_flagged"])120env.user.karma # the acting user's spendable karma — every gate compares to this121forum.can_moderate # True iff env.user.karma >= forum.karma_moderate122```123Never assume the module defaults — this forum may have been re-tuned. `mode='questions'`124enforces **one** answer per question in the UI flow; `discussions` allows many.125126### 1. Ask a question (root post) and answer it (child post)127128```python129# QUESTION = a root forum.post (no parent_id). Requires env.user.karma >= forum.karma_ask.130qid = env["forum.post"].create({131 "forum_id": forum.id,132 "name": "How does Connect Domain auto-provision SSL?",133 "content": "<p>What issues the cert, and when does it renew?</p>",134 "tag_ids": [(6, 0, [])], # optional; see tag recipe135}).id136# Note the auto-gates baked into create():137# • karma < karma_ask → AccessError; karma < karma_post → the question is saved state='pending'138# (needs a moderator to validate() before it's publicly 'active').139# • karma < karma_editor and content has <img>/<a> → AccessError ("karma required to post image/link").140# • asking a question (when it lands 'active') auto-awards karma_gen_question_new.141142# ANSWER = a forum.post WITH parent_id = the question. Requires karma >= forum.karma_answer.143aid = env["forum.post"].create({144 "forum_id": forum.id,145 "parent_id": qid, # <-- THIS is what makes it an answer, not a question146 "content": "<p>ACM issues it via DNS-01; it renews ~30 days before expiry.</p>",147}).id148# Answering a closed/deleted question raises UserError. In 'questions' mode the UI allows149# only one answer per author; the ORM lets you create more, so respect mode yourself.150```151An answer's `name` is typically empty or `Re: <question>`; the body is `content`. Do **not**152create an answer as a chatter comment — an answer is a first-class `forum.post` and only rows153with a `parent_id` count as answers (`child_count`, `has_validated_answer`, the QAPage schema).154155### 2. Accept the correct answer (`is_correct`) — the karma-bearing "solve"156157```python158answer = env["forum.post"].browse(aid)159answer.can_accept # gate: fold(own/all) — accepting on YOUR OWN question needs160 # karma_answer_accept_own, on anyone's needs karma_answer_accept_all161# Accept = set is_correct True on the ANSWER (write() enforces can_accept and moves karma):162env["forum.post"].write([aid], {"is_correct": True}) # MCP update tool163# → answer author gains karma_gen_answer_accepted (+15 default);164# the accepter gains karma_gen_answer_accept (+2 default) — UNLESS accepter == author165# (self-acceptance grants no karma). Un-accepting (is_correct=False) reverses both.166# Read back the "solved" rollup on the question:167env["forum.post"].browse(qid).read(["has_validated_answer", "child_count"])168```169`is_correct` is the accepted-answer flag; the question's `_order` floats correct answers to the170top (`is_correct DESC, vote_count DESC, ...`). In `questions` mode there is conceptually one171accepted answer.172173### 3. Vote — always via the `vote()` method (never write `forum.post.vote` directly)174175```python176# Toggle semantics: calling vote(upvote=True) again removes the upvote; opposite vote flips it.177env["forum.post"].call(qid, "vote", kwargs={"upvote": True}) # → {"vote_count", "user_vote"}178env["forum.post"].call(aid, "vote", kwargs={"upvote": False}) # downvote179```180Gates the method enforces for you: you **cannot vote on your own post** (`UserError`);181upvoting needs `karma_upvote`, downvoting needs the (much higher) `karma_downvote` — unless you're182toggling off an existing opposite vote. Voting moves karma to the **post's author**183(`recipient_id`) per the forum's `karma_gen_*_upvote/downvote`. Writing a `forum.post.vote` row by184hand bypasses the own-post check, the karma gate, AND the karma award — don't.185186### 4. Tags: create + attach + retag187188```python189# Create a forum-scoped tag (costs karma_tag_create; unique per forum). Admin bypasses.190tag = env["forum.tag"].create({"name": "ssl", "forum_id": forum.id})191# Attach tags to a QUESTION (only questions carry tags). Retagging an existing post's tags192# needs karma_edit_retag if you're changing the set:193env["forum.post"].write([qid], {"tag_ids": [(6, 0, [tag.id])]}) # replace set194env["forum.post"].write([qid], {"tag_ids": [(4, tag.id)]}) # add one195# Read a forum's tags + usage:196env["forum.tag"].search_read([("forum_id", "=", forum.id)], ["name", "posts_count"])197```198`posts_count` only counts `state='active'` posts (it's in the m2m domain). The forum also199exposes `tag_most_used_ids` / `tag_unused_ids`.200201### 5. Moderate: validate a pending question, close/reopen, flag, mark offensive202203```python204# The moderation queues on the forum:205forum.read(["count_posts_waiting_validation", "count_flagged_posts"])206pending = env["forum.post"].search([("forum_id","=",forum.id), ("state","=","pending")])207flagged = env["forum.post"].search([("forum_id","=",forum.id), ("state","=","flagged")])208209# VALIDATE a pending question (approve it) — needs karma_moderate; awards the ask-karma that210# was withheld while pending, sets state='active', moderator_id=you:211env["forum.post"].call(pending.ids, "validate")212213# CLOSE a question with a reason (pass the reason RECORD id). Closing spam/offensive214# (reason_8 / reason_7) also DOCKS the author's karma (karma_gen_answer_flagged, ×10 on a215# first post). Only questions (parent_id unset) can be closed.216reason_off = env.ref("website_forum.reason_7").id # "offensive" (id 6 on connectdomain)217reason_spam = env.ref("website_forum.reason_8").id # "spam" (id 7)218reason_dupe = env.ref("website_forum.reason_1").id # "Duplicate post" (no karma penalty)219env["forum.post"].call(qid, "close", args=[reason_dupe]) # gate: can_close (own/all)220env["forum.post"].call(qid, "reopen") # reverses; refunds spam/offensive dock221222# FLAG a post as offensive (needs karma_flag) → state='flagged' for a moderator to review:223env["forum.post"].call(aid, "_flag")224# MARK OFFENSIVE (moderator, needs karma_moderate): archives + docks author karma:225env["forum.post"].call(aid, "_mark_as_offensive", args=[reason_off])226```227`close`/`reopen`/`validate`/`_flag`/`_mark_as_offensive` are model methods (use the `call` tool228with `confirm:true`). They each re-check the karma gate server-side and raise `AccessError` if the229acting user is short — so if you're a low-karma service user, moderate as an admin or grant230`karma_moderate` first.231232### 6. Grant a user forum karma (so they clear the gates)233234`res.users.karma` is a **stored computed** field backed by the karma ledger — writing it directly235works (it logs a tracking row via `res.users.write`), but the idiomatic path is `_add_karma`:236237```python238u = env["res.users"].browse(uid)239u.read(["login", "karma"])240# Idiomatic: append a ledger entry (also updates the stored karma):241env["res.users"].call([uid], "_add_karma", args=[1000], kwargs={"reason": "Forum bootstrap"})242# Or set an absolute value (write() diffs and logs the delta to gamification.karma.tracking):243env["res.users"].write([uid], {"karma": 1000})244```245Karma is **per-user, tenant-wide** (not per-forum) — it's a `res.users` field, so a user's karma246applies across every forum on that tenant. Grant enough to clear the specific `karma_*` gate the247task needs (e.g. `karma_moderate` to run recipe 5). Do **not** hand-edit248`gamification.karma.tracking` rows.249250### 7. Edit the PUBLIC forum pages (QWeb) — website_\* tools, not this ORM251252The `/forum` list, question, and tag pages are stock `website_forum` QWeb served by its253controllers. To restyle/copy-tweak them, go through the **website tools** (COW-safe — Odoo forks254the module view into a site-specific `ir.ui.view` so upgrades never clobber your edit):255256```257website_pages # find the /forum-related pages + their view ids258website_edit_page(url="/forum", arch=<full QWeb XML>) # read current arch FIRST; keep the idiom259website_publish(url="/forum", published=True, indexed=True)260website_menu(name="Community", url="/forum") # add a nav entry (there's already one)261```262On everjust the marketing pages are Tailwind-utility QWeb wrapped in263`<t t-call="website.layout">`; the forum templates are stock Bootstrap QWeb — either way, **read264the current arch before rewriting** and reuse the existing classes. Editing forum pages needs the265`website.group_website_designer` role (the everjust Administrator role includes it —266see [[everjust-platform]]). Never hand-write the `ir.ui.view` directly; use `website_edit_page`.267268## Pitfalls2692701. **An answer is a `forum.post` with `parent_id`, NOT a chatter comment.** The Q&A hierarchy is271 parent (question) → children (answers), all `forum.post`. `mail.message` comments exist on a272 post too, but they are *comments*, and only real answers count toward `child_count`,273 `has_validated_answer`, acceptance, and the QAPage schema. Don't model "answer" as274 `message_post` — and don't confuse this with the [[everjust-mail-ops]] webmail stack.2752762. **`is_correct` is the "accept," and self-acceptance grants no karma.** Setting277 `is_correct=True` on an answer awards `karma_gen_answer_accepted` to the author and278 `karma_gen_answer_accept` to the accepter — *unless the accepter is the author*, in which case279 no karma moves. Flipping it back reverses the award. Deleting an already-accepted answer also280 claws back the karma.2812823. **Every write is karma-gated against `env.user.karma` — read `can_*` first.** `create` on a283 post checks `can_ask`/`can_answer`; `write` checks `can_edit`/`can_close`/`can_accept`/284 `can_unlink`/retag; `vote`/`close`/`validate`/`_flag`/`_mark_as_offensive` each re-check. A285 low-karma service user gets `AccessError`. Either act as an admin (bypasses all gates via286 `env.is_admin()`) or grant the user the needed karma (recipe 6) — don't try to force the write.2872884. **Never write `forum.post.vote` directly — call `vote()`.** The own-post ban, the289 up/downvote karma gate, and the karma award to the author all live in the vote model's290 create/write and in the toggle logic. A raw row skips all three and corrupts the karma ledger.2912925. **`karma_post` decides pending vs active on a NEW question.** Below `karma_post` (default 100),293 a newly asked question is saved `state='pending'` and is invisible publicly until a moderator294 `validate()`s it (which then awards the withheld ask-karma). So "I created it but it's not on295 the site" usually means it's pending, not failed.2962976. **`close`/`reopen`/`mark offensive` take a reason RECORD id and can move karma.** Pass the298 `forum.post.reason` id (resolve via `env.ref("website_forum.reason_N")`). Closing with the299 spam (`reason_8`) or offensive (`reason_7`) reason **docks the author's karma**300 (`karma_gen_answer_flagged`, ×10 for a first post); `reopen` refunds it. Only root questions301 (`parent_id` unset) can be closed/reopened.3023037. **`res.users.karma` is a stored computed field over `gamification.karma.tracking`.** Writing304 `karma` works (it diffs and logs a tracking row) but never edit tracking rows by hand and never305 assume karma is a plain writable integer with no side-effects. Karma is **tenant-wide per306 user**, not per-forum.3073088. **`website_forum` is optional per tenant.** It's installed on `connectdomain`; on a tenant309 without it, `forum.forum`/`forum.post` raise `KeyError`. Check `"forum.forum" in env` (or310 `list_installed_modules`) before acting, and remember you're bounded by *your* Odoo user's311 permissions and karma on *this one* tenant DB (see [[everjust-agent-mcp]]).3123139. **`mode` is advisory at the ORM layer.** `questions` mode means "one answer" in the guided UI,314 but the ORM will let you create multiple child posts. If the task implies single-answer315 semantics, enforce it yourself (check existing `child_ids` before adding an answer).31631710. **Don't restyle the forum by writing `ir.ui.view`.** Use `website_edit_page` (COW-safe) so a318 module upgrade never clobbers the edit; read the page's current arch first and keep the319 existing QWeb/class idiom (recipe 7).