Files
goodie/addons/mail/models/mail_channel.go
Marc 66383adf06 feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support
- Email Inbound: IMAP polling (go-imap/v2), thread matching
- Discuss: mail.channel, long-polling bus, DM, unread count
- Cron: ir.cron runner (goroutine scheduler)
- Bank Import, CSV/Excel Import
- Automation (ir.actions.server)
- Fetchmail service
- HR Payroll model
- Various fixes across account, sale, stock, purchase, crm, hr, project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:41:57 +02:00

425 lines
13 KiB
Go

package models
import (
"fmt"
"time"
"odoo-go/pkg/orm"
)
// initMailChannel registers mail.channel and mail.channel.member models.
// Mirrors: odoo/addons/mail/models/discuss_channel.py
func initMailChannel() {
m := orm.NewModel("mail.channel", orm.ModelOpts{
Description: "Discussion Channel",
Order: "name",
})
m.AddFields(
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Selection("channel_type", []orm.SelectionItem{
{Value: "channel", Label: "Channel"},
{Value: "chat", Label: "Direct Message"},
{Value: "group", Label: "Group"},
}, orm.FieldOpts{String: "Type", Default: "channel", Required: true}),
orm.Text("description", orm.FieldOpts{String: "Description"}),
orm.Many2one("create_uid", "res.users", orm.FieldOpts{String: "Created By", Readonly: true}),
orm.Boolean("public", orm.FieldOpts{String: "Public", Default: true,
Help: "If true, any internal user can join. If false, invitation only."}),
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
orm.Many2one("group_id", "res.groups", orm.FieldOpts{String: "Authorized Group"}),
orm.One2many("member_ids", "mail.channel.member", "channel_id", orm.FieldOpts{String: "Members"}),
orm.Integer("member_count", orm.FieldOpts{
String: "Member Count", Compute: "_compute_member_count",
}),
orm.Many2one("last_message_id", "mail.message", orm.FieldOpts{String: "Last Message"}),
orm.Datetime("last_message_date", orm.FieldOpts{String: "Last Message Date"}),
)
m.RegisterCompute("member_count", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
id := rs.IDs()[0]
var count int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM mail_channel_member WHERE channel_id = $1`, id,
).Scan(&count); err != nil {
return orm.Values{"member_count": int64(0)}, nil
}
return orm.Values{"member_count": count}, nil
})
// action_join: Current user joins the channel.
m.RegisterMethod("action_join", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
channelID := rs.IDs()[0]
// Get current user's partner
var partnerID int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&partnerID); err != nil || partnerID == 0 {
return nil, fmt.Errorf("mail.channel: cannot find partner for user %d", env.UID())
}
// Check not already member
var exists bool
env.Tx().QueryRow(env.Ctx(),
`SELECT EXISTS(SELECT 1 FROM mail_channel_member
WHERE channel_id = $1 AND partner_id = $2)`, channelID, partnerID,
).Scan(&exists)
if exists {
return true, nil // Already a member
}
memberRS := env.Model("mail.channel.member")
if _, err := memberRS.Create(orm.Values{
"channel_id": channelID,
"partner_id": partnerID,
}); err != nil {
return nil, fmt.Errorf("mail.channel: join %d: %w", channelID, err)
}
return true, nil
})
// action_leave: Current user leaves the channel.
m.RegisterMethod("action_leave", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
channelID := rs.IDs()[0]
var partnerID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&partnerID)
if _, err := env.Tx().Exec(env.Ctx(),
`DELETE FROM mail_channel_member WHERE channel_id = $1 AND partner_id = $2`,
channelID, partnerID); err != nil {
return nil, fmt.Errorf("mail.channel: leave %d: %w", channelID, err)
}
return true, nil
})
// message_post: Post a message to the channel.
m.RegisterMethod("message_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
channelID := rs.IDs()[0]
body := ""
if len(args) > 0 {
if kw, ok := args[0].(map[string]interface{}); ok {
if v, ok := kw["body"].(string); ok {
body = v
}
}
}
var authorID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&authorID)
var msgID int64
err := env.Tx().QueryRow(env.Ctx(),
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, date, create_uid, write_uid, create_date, write_date)
VALUES ('mail.channel', $1, $2, 'comment', $3, NOW(), $4, $4, NOW(), NOW())
RETURNING id`,
channelID, body, authorID, env.UID(),
).Scan(&msgID)
if err != nil {
return nil, fmt.Errorf("mail.channel: post message: %w", err)
}
// Update channel last message
env.Tx().Exec(env.Ctx(),
`UPDATE mail_channel SET last_message_id = $1, last_message_date = NOW() WHERE id = $2`,
msgID, channelID)
return msgID, nil
})
// get_messages: Get messages for a channel.
m.RegisterMethod("get_messages", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
channelID := rs.IDs()[0]
limit := 50
if len(args) > 0 {
if kw, ok := args[0].(map[string]interface{}); ok {
if v, ok := kw["limit"].(float64); ok && v > 0 {
limit = int(v)
}
}
}
rows, err := env.Tx().Query(env.Ctx(),
`SELECT m.id, m.body, m.date, m.author_id, COALESCE(p.name, '')
FROM mail_message m
LEFT JOIN res_partner p ON p.id = m.author_id
WHERE m.model = 'mail.channel' AND m.res_id = $1
ORDER BY m.id DESC LIMIT $2`, channelID, limit)
if err != nil {
return nil, fmt.Errorf("mail.channel: get_messages: %w", err)
}
defer rows.Close()
var messages []map[string]interface{}
for rows.Next() {
var id, authorID int64
var body, authorName string
var date interface{}
if err := rows.Scan(&id, &body, &date, &authorID, &authorName); err != nil {
continue
}
msg := map[string]interface{}{
"id": id,
"body": body,
"date": date,
}
if authorID > 0 {
msg["author_id"] = []interface{}{authorID, authorName}
} else {
msg["author_id"] = false
}
messages = append(messages, msg)
}
if messages == nil {
messages = []map[string]interface{}{}
}
return messages, nil
})
// channel_get: Get or create a direct message channel between current user and partner.
// Mirrors: odoo/addons/mail/models/discuss_channel.py channel_get()
m.RegisterMethod("channel_get", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
if len(args) < 1 {
return nil, fmt.Errorf("mail.channel: channel_get requires partner_ids")
}
var partnerIDs []int64
if kw, ok := args[0].(map[string]interface{}); ok {
if pids, ok := kw["partner_ids"].([]interface{}); ok {
for _, pid := range pids {
switch v := pid.(type) {
case float64:
partnerIDs = append(partnerIDs, int64(v))
case int64:
partnerIDs = append(partnerIDs, v)
}
}
}
}
// Add current user's partner
var myPartnerID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&myPartnerID)
if myPartnerID > 0 {
partnerIDs = append(partnerIDs, myPartnerID)
}
if len(partnerIDs) < 2 {
return nil, fmt.Errorf("mail.channel: need at least 2 partners for DM")
}
// Check if DM channel already exists between these partners
var existingID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT c.id FROM mail_channel c
WHERE c.channel_type = 'chat'
AND (SELECT COUNT(*) FROM mail_channel_member m WHERE m.channel_id = c.id) = $1
AND NOT EXISTS (
SELECT 1 FROM mail_channel_member m
WHERE m.channel_id = c.id AND m.partner_id != ALL($2)
)
LIMIT 1`, len(partnerIDs), partnerIDs,
).Scan(&existingID)
if existingID > 0 {
return map[string]interface{}{"id": existingID}, nil
}
// Create new DM channel
var partnerName string
for _, pid := range partnerIDs {
if pid != myPartnerID {
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(name, '') FROM res_partner WHERE id = $1`, pid,
).Scan(&partnerName)
break
}
}
channelRS := env.Model("mail.channel")
channel, err := channelRS.Create(orm.Values{
"name": partnerName,
"channel_type": "chat",
"public": false,
})
if err != nil {
return nil, fmt.Errorf("mail.channel: create DM: %w", err)
}
channelID := channel.ID()
// Add members
memberRS := env.Model("mail.channel.member")
for _, pid := range partnerIDs {
memberRS.Create(orm.Values{
"channel_id": channelID,
"partner_id": pid,
})
}
return map[string]interface{}{"id": channelID}, nil
})
// -- mail.channel.member --
initMailChannelMember()
}
func initMailChannelMember() {
m := orm.NewModel("mail.channel.member", orm.ModelOpts{
Description: "Channel Member",
Order: "id",
})
m.AddFields(
orm.Many2one("channel_id", "mail.channel", orm.FieldOpts{
String: "Channel", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Partner", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Datetime("last_seen_dt", orm.FieldOpts{String: "Last Seen"}),
orm.Many2one("last_seen_message_id", "mail.message", orm.FieldOpts{String: "Last Seen Message"}),
orm.Boolean("is_pinned", orm.FieldOpts{String: "Pinned", Default: true}),
orm.Boolean("is_muted", orm.FieldOpts{String: "Muted", Default: false}),
)
m.AddSQLConstraint(
"unique_channel_partner",
"UNIQUE(channel_id, partner_id)",
"A partner can only be a member of a channel once.",
)
// mark_as_read: Update last seen timestamp and message.
m.RegisterMethod("mark_as_read", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE mail_channel_member SET last_seen_dt = NOW(),
last_seen_message_id = (
SELECT MAX(m.id) FROM mail_message m
WHERE m.model = 'mail.channel'
AND m.res_id = (SELECT channel_id FROM mail_channel_member WHERE id = $1)
) WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("mail.channel.member: mark_as_read %d: %w", id, err)
}
}
return true, nil
})
}
// initMailChannelExtensions adds unread count compute after message model is registered.
func initMailChannelExtensions() {
ch := orm.ExtendModel("mail.channel")
ch.AddFields(
orm.Integer("message_unread_count", orm.FieldOpts{
String: "Unread Messages", Compute: "_compute_message_unread_count",
}),
)
ch.RegisterCompute("message_unread_count", func(rs *orm.Recordset) (orm.Values, error) {
env := rs.Env()
channelID := rs.IDs()[0]
var partnerID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&partnerID)
if partnerID == 0 {
return orm.Values{"message_unread_count": int64(0)}, nil
}
var lastSeenID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(last_seen_message_id, 0) FROM mail_channel_member
WHERE channel_id = $1 AND partner_id = $2`, channelID, partnerID,
).Scan(&lastSeenID)
var count int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM mail_message
WHERE model = 'mail.channel' AND res_id = $1 AND id > $2`,
channelID, lastSeenID,
).Scan(&count)
return orm.Values{"message_unread_count": count}, nil
})
}
// initDiscussBus registers the message bus polling endpoint logic.
func initDiscussBus() {
ch := orm.ExtendModel("mail.channel")
// channel_fetch_preview: Get channel list with last message for discuss sidebar.
// Mirrors: odoo/addons/mail/models/discuss_channel.py channel_fetch_preview()
ch.RegisterMethod("channel_fetch_preview", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
var partnerID int64
env.Tx().QueryRow(env.Ctx(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(),
).Scan(&partnerID)
if partnerID == 0 {
return []map[string]interface{}{}, nil
}
rows, err := env.Tx().Query(env.Ctx(),
`SELECT c.id, c.name, c.channel_type, c.last_message_date,
COALESCE(m.body, ''), COALESCE(p.name, ''),
(SELECT COUNT(*) FROM mail_message msg
WHERE msg.model = 'mail.channel' AND msg.res_id = c.id
AND msg.id > COALESCE(cm.last_seen_message_id, 0)) AS unread
FROM mail_channel c
JOIN mail_channel_member cm ON cm.channel_id = c.id AND cm.partner_id = $1
LEFT JOIN mail_message m ON m.id = c.last_message_id
LEFT JOIN res_partner p ON p.id = m.author_id
WHERE c.active = true AND cm.is_pinned = true
ORDER BY c.last_message_date DESC NULLS LAST`, partnerID)
if err != nil {
return nil, fmt.Errorf("mail.channel: fetch_preview: %w", err)
}
defer rows.Close()
var channels []map[string]interface{}
for rows.Next() {
var id int64
var name, channelType, lastBody, lastAuthor string
var lastDate *time.Time
var unread int64
if err := rows.Scan(&id, &name, &channelType, &lastDate, &lastBody, &lastAuthor, &unread); err != nil {
continue
}
channels = append(channels, map[string]interface{}{
"id": id,
"name": name,
"channel_type": channelType,
"last_message": lastBody,
"last_author": lastAuthor,
"last_date": lastDate,
"unread_count": unread,
})
}
if channels == nil {
channels = []map[string]interface{}{}
}
return channels, nil
})
}