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>
This commit is contained in:
424
addons/mail/models/mail_channel.go
Normal file
424
addons/mail/models/mail_channel.go
Normal file
@@ -0,0 +1,424 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user