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:
16
addons/mail/models/init.go
Normal file
16
addons/mail/models/init.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Package models registers all mail module models.
|
||||
package models
|
||||
|
||||
// Init registers all models for the mail module.
|
||||
// Called by the module loader in dependency order.
|
||||
func Init() {
|
||||
initMailMessage() // mail.message
|
||||
initMailFollowers() // mail.followers
|
||||
initMailActivityType() // mail.activity.type
|
||||
initMailActivity() // mail.activity
|
||||
initMailChannel() // mail.channel + mail.channel.member
|
||||
// Extensions (must come after base models are registered)
|
||||
initMailThread()
|
||||
initMailChannelExtensions()
|
||||
initDiscussBus()
|
||||
}
|
||||
62
addons/mail/models/mail_activity.go
Normal file
62
addons/mail/models/mail_activity.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initMailActivity registers the mail.activity model.
|
||||
// Mirrors: odoo/addons/mail/models/mail_activity.py MailActivity
|
||||
func initMailActivity() {
|
||||
m := orm.NewModel("mail.activity", orm.ModelOpts{
|
||||
Description: "Activity",
|
||||
Order: "date_deadline ASC",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("res_model", orm.FieldOpts{
|
||||
String: "Related Document Model",
|
||||
Required: true,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Integer("res_id", orm.FieldOpts{
|
||||
String: "Related Document ID",
|
||||
Required: true,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Many2one("activity_type_id", "mail.activity.type", orm.FieldOpts{
|
||||
String: "Activity Type",
|
||||
OnDelete: orm.OnDeleteRestrict,
|
||||
}),
|
||||
orm.Char("summary", orm.FieldOpts{String: "Summary"}),
|
||||
orm.Text("note", orm.FieldOpts{String: "Note"}),
|
||||
orm.Date("date_deadline", orm.FieldOpts{
|
||||
String: "Due Date",
|
||||
Required: true,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{
|
||||
String: "Assigned to",
|
||||
Required: true,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "overdue", Label: "Overdue"},
|
||||
{Value: "today", Label: "Today"},
|
||||
{Value: "planned", Label: "Planned"},
|
||||
}, orm.FieldOpts{String: "State", Default: "planned"}),
|
||||
orm.Boolean("done", orm.FieldOpts{String: "Done", Default: false}),
|
||||
// Odoo 19: deadline_range for flexible deadline display
|
||||
orm.Integer("deadline_range", orm.FieldOpts{
|
||||
String: "Deadline Range (Days)", Help: "Number of days before/after deadline for grouping",
|
||||
}),
|
||||
)
|
||||
|
||||
// action_done: mark activity as done
|
||||
// Mirrors: odoo/addons/mail/models/mail_activity.py action_done
|
||||
m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
for _, id := range rs.IDs() {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE mail_activity SET done = true WHERE id = $1`, id)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
39
addons/mail/models/mail_activity_type.go
Normal file
39
addons/mail/models/mail_activity_type.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initMailActivityType registers the mail.activity.type model.
|
||||
// Mirrors: odoo/addons/mail/models/mail_activity.py MailActivityType
|
||||
func initMailActivityType() {
|
||||
m := orm.NewModel("mail.activity.type", orm.ModelOpts{
|
||||
Description: "Activity Type",
|
||||
Order: "sequence, id",
|
||||
RecName: "name",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}),
|
||||
orm.Char("summary", orm.FieldOpts{String: "Default Summary"}),
|
||||
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
|
||||
orm.Char("res_model", orm.FieldOpts{
|
||||
String: "Document Model",
|
||||
Help: "Specify a model if this activity type is specific to a model, otherwise it is available for all models.",
|
||||
}),
|
||||
orm.Selection("category", []orm.SelectionItem{
|
||||
{Value: "default", Label: "Other"},
|
||||
{Value: "upload_file", Label: "Upload Document"},
|
||||
}, orm.FieldOpts{String: "Action", Default: "default"}),
|
||||
orm.Integer("delay_count", orm.FieldOpts{
|
||||
String: "Schedule",
|
||||
Default: 0,
|
||||
Help: "Number of days/weeks/months before executing the action.",
|
||||
}),
|
||||
orm.Selection("delay_unit", []orm.SelectionItem{
|
||||
{Value: "days", Label: "days"},
|
||||
{Value: "weeks", Label: "weeks"},
|
||||
{Value: "months", Label: "months"},
|
||||
}, orm.FieldOpts{String: "Delay units", Default: "days"}),
|
||||
orm.Char("icon", orm.FieldOpts{String: "Icon", Help: "Font awesome icon e.g. fa-tasks"}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
)
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
31
addons/mail/models/mail_followers.go
Normal file
31
addons/mail/models/mail_followers.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initMailFollowers registers the mail.followers model.
|
||||
// Mirrors: odoo/addons/mail/models/mail_followers.py
|
||||
func initMailFollowers() {
|
||||
m := orm.NewModel("mail.followers", orm.ModelOpts{
|
||||
Description: "Document Followers",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("res_model", orm.FieldOpts{
|
||||
String: "Related Document Model Name",
|
||||
Required: true,
|
||||
Index: true,
|
||||
}),
|
||||
orm.Integer("res_id", orm.FieldOpts{
|
||||
String: "Related Document ID",
|
||||
Required: true,
|
||||
Index: true,
|
||||
Help: "Id of the followed resource",
|
||||
}),
|
||||
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
|
||||
String: "Related Partner",
|
||||
Required: true,
|
||||
Index: true,
|
||||
OnDelete: orm.OnDeleteCascade,
|
||||
}),
|
||||
)
|
||||
}
|
||||
53
addons/mail/models/mail_message.go
Normal file
53
addons/mail/models/mail_message.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import "odoo-go/pkg/orm"
|
||||
|
||||
// initMailMessage registers the mail.message model.
|
||||
// Mirrors: odoo/addons/mail/models/mail_message.py
|
||||
func initMailMessage() {
|
||||
m := orm.NewModel("mail.message", orm.ModelOpts{
|
||||
Description: "Message",
|
||||
Order: "id desc",
|
||||
RecName: "subject",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("subject", orm.FieldOpts{String: "Subject"}),
|
||||
orm.Datetime("date", orm.FieldOpts{String: "Date"}),
|
||||
orm.Text("body", orm.FieldOpts{String: "Contents"}),
|
||||
orm.Selection("message_type", []orm.SelectionItem{
|
||||
{Value: "comment", Label: "Comment"},
|
||||
{Value: "notification", Label: "System notification"},
|
||||
{Value: "email", Label: "Email"},
|
||||
{Value: "user_notification", Label: "User Notification"},
|
||||
}, orm.FieldOpts{String: "Type", Required: true, Default: "comment"}),
|
||||
orm.Many2one("author_id", "res.partner", orm.FieldOpts{
|
||||
String: "Author",
|
||||
Index: true,
|
||||
Help: "Author of the message.",
|
||||
}),
|
||||
orm.Char("model", orm.FieldOpts{
|
||||
String: "Related Document Model",
|
||||
Index: true,
|
||||
}),
|
||||
orm.Integer("res_id", orm.FieldOpts{
|
||||
String: "Related Document ID",
|
||||
Index: true,
|
||||
}),
|
||||
orm.Many2one("parent_id", "mail.message", orm.FieldOpts{
|
||||
String: "Parent Message",
|
||||
OnDelete: orm.OnDeleteSetNull,
|
||||
}),
|
||||
orm.Boolean("starred", orm.FieldOpts{String: "Starred"}),
|
||||
orm.Char("email_from", orm.FieldOpts{String: "From", Help: "Email address of the sender."}),
|
||||
orm.Char("reply_to", orm.FieldOpts{String: "Reply To", Help: "Reply-To address."}),
|
||||
orm.Char("record_name", orm.FieldOpts{
|
||||
String: "Message Record Name",
|
||||
Help: "Name of the document the message is attached to.",
|
||||
}),
|
||||
orm.Many2many("attachment_ids", "ir.attachment", orm.FieldOpts{
|
||||
String: "Attachments",
|
||||
Help: "Attachments linked to this message.",
|
||||
}),
|
||||
)
|
||||
}
|
||||
208
addons/mail/models/mail_thread.go
Normal file
208
addons/mail/models/mail_thread.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
"odoo-go/pkg/tools"
|
||||
)
|
||||
|
||||
// initMailThread extends existing models with mail.thread functionality.
|
||||
// In Python Odoo, models inherit from mail.thread to get chatter support.
|
||||
// Here we use ExtendModel to add the message fields and methods.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py
|
||||
func initMailThread() {
|
||||
// Models that support mail.thread chatter
|
||||
threadModels := []string{
|
||||
"res.partner",
|
||||
"sale.order",
|
||||
"purchase.order",
|
||||
"account.move",
|
||||
"stock.picking",
|
||||
"crm.lead",
|
||||
"project.task",
|
||||
}
|
||||
|
||||
for _, modelName := range threadModels {
|
||||
// Check if the model is registered (module may not be loaded)
|
||||
if orm.Registry.Get(modelName) == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
m := orm.ExtendModel(modelName)
|
||||
|
||||
m.AddFields(
|
||||
orm.Integer("message_partner_ids_count", orm.FieldOpts{
|
||||
String: "Followers Count",
|
||||
Help: "Number of partners following this document.",
|
||||
}),
|
||||
)
|
||||
|
||||
// message_post: post a new message on the record's chatter.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py message_post()
|
||||
m.RegisterMethod("message_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
ids := rs.IDs()
|
||||
if len(ids) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Parse kwargs from args
|
||||
body := ""
|
||||
messageType := "comment"
|
||||
subject := ""
|
||||
var attachmentIDs []int64
|
||||
if len(args) > 0 {
|
||||
if kw, ok := args[0].(map[string]interface{}); ok {
|
||||
if v, ok := kw["body"].(string); ok {
|
||||
body = v
|
||||
}
|
||||
if v, ok := kw["message_type"].(string); ok {
|
||||
messageType = v
|
||||
}
|
||||
if v, ok := kw["subject"].(string); ok {
|
||||
subject = v
|
||||
}
|
||||
if v, ok := kw["attachment_ids"].([]interface{}); ok {
|
||||
for _, aid := range v {
|
||||
switch id := aid.(type) {
|
||||
case float64:
|
||||
attachmentIDs = append(attachmentIDs, int64(id))
|
||||
case int64:
|
||||
attachmentIDs = append(attachmentIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get author from current user's partner_id
|
||||
var authorID int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT partner_id FROM res_users WHERE id = $1`, env.UID(),
|
||||
).Scan(&authorID); err != nil {
|
||||
log.Printf("warning: mail_thread message_post author lookup failed: %v", err)
|
||||
}
|
||||
|
||||
// Create mail.message
|
||||
var msgID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, subject, date, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $7, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
rs.ModelDef().Name(), ids[0], body, messageType, authorID, subject, env.UID(),
|
||||
).Scan(&msgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Link attachments to the message via M2M
|
||||
for _, aid := range attachmentIDs {
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`INSERT INTO mail_message_ir_attachment_rel (mail_message_id, ir_attachment_id)
|
||||
VALUES ($1, $2) ON CONFLICT DO NOTHING`, msgID, aid)
|
||||
}
|
||||
|
||||
// Notify followers via email
|
||||
notifyFollowers(env, rs.ModelDef().Name(), ids[0], authorID, subject, body)
|
||||
|
||||
return msgID, nil
|
||||
})
|
||||
|
||||
// _message_get_thread: get messages for the record's chatter.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py _notify_thread()
|
||||
m.RegisterMethod("_message_get_thread", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
env := rs.Env()
|
||||
ids := rs.IDs()
|
||||
if len(ids) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT m.id, m.body, m.message_type, m.date,
|
||||
m.author_id, COALESCE(p.name, ''),
|
||||
COALESCE(m.subject, ''), COALESCE(m.email_from, '')
|
||||
FROM mail_message m
|
||||
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||
WHERE m.model = $1 AND m.res_id = $2
|
||||
ORDER BY m.id DESC`,
|
||||
rs.ModelDef().Name(), ids[0],
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var body, msgType, subject, emailFrom string
|
||||
var date interface{}
|
||||
var authorID int64
|
||||
var authorName string
|
||||
|
||||
if err := rows.Scan(&id, &body, &msgType, &date, &authorID, &authorName, &subject, &emailFrom); err != nil {
|
||||
continue
|
||||
}
|
||||
msg := map[string]interface{}{
|
||||
"id": id,
|
||||
"body": body,
|
||||
"message_type": msgType,
|
||||
"date": date,
|
||||
"subject": subject,
|
||||
"email_from": emailFrom,
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// notifyFollowers sends email notifications to followers of a document.
|
||||
// Skips the message author to avoid self-notifications.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py _notify_thread()
|
||||
func notifyFollowers(env *orm.Environment, modelName string, resID, authorID int64, subject, body string) {
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT DISTINCT p.email, p.name
|
||||
FROM mail_followers f
|
||||
JOIN res_partner p ON p.id = f.partner_id
|
||||
WHERE f.res_model = $1 AND f.res_id = $2
|
||||
AND f.partner_id != $3
|
||||
AND p.email IS NOT NULL AND p.email != ''`,
|
||||
modelName, resID, authorID)
|
||||
if err != nil {
|
||||
log.Printf("mail: follower lookup failed for %s/%d: %v", modelName, resID, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cfg := tools.LoadSMTPConfig()
|
||||
if cfg.Host == "" {
|
||||
return // SMTP not configured — skip silently
|
||||
}
|
||||
|
||||
emailSubject := subject
|
||||
if emailSubject == "" {
|
||||
emailSubject = fmt.Sprintf("New message on %s", modelName)
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var email, name string
|
||||
if err := rows.Scan(&email, &name); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := tools.SendEmail(cfg, email, emailSubject, body); err != nil {
|
||||
log.Printf("mail: failed to notify %s (%s): %v", name, email, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
addons/mail/module.go
Normal file
22
addons/mail/module.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Package mail implements Odoo's Mail/Chatter module.
|
||||
// Mirrors: odoo/addons/mail/__manifest__.py
|
||||
package mail
|
||||
|
||||
import (
|
||||
"odoo-go/addons/mail/models"
|
||||
"odoo-go/pkg/modules"
|
||||
)
|
||||
|
||||
func init() {
|
||||
modules.Register(&modules.Module{
|
||||
Name: "mail",
|
||||
Description: "Discuss",
|
||||
Version: "19.0.1.0.0",
|
||||
Category: "Productivity/Discuss",
|
||||
Depends: []string{"base"},
|
||||
Application: true,
|
||||
Installable: true,
|
||||
Sequence: 5,
|
||||
Init: models.Init,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user