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:
Marc
2026-04-12 18:41:57 +02:00
parent 2c7c1e6c88
commit 66383adf06
87 changed files with 14696 additions and 654 deletions

160
pkg/service/automation.go Normal file
View File

@@ -0,0 +1,160 @@
package service
import (
"fmt"
"log"
"strings"
"github.com/jackc/pgx/v5"
"odoo-go/pkg/orm"
"odoo-go/pkg/tools"
)
// RunAutomatedActions checks and executes server actions triggered by Create/Write/Unlink.
// Called from the ORM after successful Create/Write/Unlink operations.
// Mirrors: odoo/addons/base_automation/models/base_automation.py
func RunAutomatedActions(env *orm.Environment, modelName, trigger string, recordIDs []int64) {
if len(recordIDs) == 0 {
return
}
// Look up the ir_model ID for this model
var modelID int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM ir_model WHERE model = $1`, modelName).Scan(&modelID)
if err != nil {
return // Model not in ir_model — no actions possible
}
// Find matching automated actions
rows, err := env.Tx().Query(env.Ctx(),
`SELECT id, state, COALESCE(update_field_id, ''), COALESCE(update_value, ''),
COALESCE(email_to, ''), COALESCE(email_subject, ''), COALESCE(email_body, ''),
COALESCE(filter_domain, '')
FROM ir_act_server
WHERE model_id = $1
AND active = true
AND trigger = $2
ORDER BY sequence, id`, modelID, trigger)
if err != nil {
log.Printf("automation: query error for %s/%s: %v", modelName, trigger, err)
return
}
defer rows.Close()
type action struct {
id int64
state string
updateField string
updateValue string
emailTo string
emailSubject string
emailBody string
filterDomain string
}
var actions []action
for rows.Next() {
var a action
if err := rows.Scan(&a.id, &a.state, &a.updateField, &a.updateValue,
&a.emailTo, &a.emailSubject, &a.emailBody, &a.filterDomain); err != nil {
continue
}
actions = append(actions, a)
}
if len(actions) == 0 {
return
}
for _, a := range actions {
switch a.state {
case "object_write":
executeObjectWrite(env, modelName, recordIDs, a.updateField, a.updateValue)
case "email":
executeEmailAction(env, modelName, recordIDs, a.emailTo, a.emailSubject, a.emailBody)
}
}
}
// executeObjectWrite updates a field on the triggered records.
func executeObjectWrite(env *orm.Environment, modelName string, recordIDs []int64, fieldName, value string) {
if fieldName == "" {
return
}
tableName := strings.ReplaceAll(modelName, ".", "_")
for _, id := range recordIDs {
_, err := env.Tx().Exec(env.Ctx(),
fmt.Sprintf(`UPDATE %s SET %s = $1 WHERE id = $2`,
pgx.Identifier{tableName}.Sanitize(),
pgx.Identifier{fieldName}.Sanitize()),
value, id)
if err != nil {
log.Printf("automation: object_write error %s.%s on %d: %v", modelName, fieldName, id, err)
}
}
}
// executeEmailAction sends an email for each triggered record.
func executeEmailAction(env *orm.Environment, modelName string, recordIDs []int64, emailToField, subject, bodyTemplate string) {
if emailToField == "" {
return
}
cfg := tools.LoadSMTPConfig()
if cfg.Host == "" {
return
}
tableName := strings.ReplaceAll(modelName, ".", "_")
for _, id := range recordIDs {
// Resolve email address from the record
var email string
err := env.Tx().QueryRow(env.Ctx(),
fmt.Sprintf(`SELECT COALESCE(%s, '') FROM %s WHERE id = $1`,
pgx.Identifier{emailToField}.Sanitize(),
pgx.Identifier{tableName}.Sanitize()),
id).Scan(&email)
if err != nil || email == "" {
continue
}
// Simple template: replace {{field}} with record values
body := bodyTemplate
if strings.Contains(body, "{{") {
body = resolveTemplate(env, tableName, id, body)
}
if err := tools.SendEmail(cfg, email, subject, body); err != nil {
log.Printf("automation: email error to %s for %s/%d: %v", email, modelName, id, err)
}
}
}
// resolveTemplate replaces {{field_name}} placeholders with actual record values.
func resolveTemplate(env *orm.Environment, tableName string, recordID int64, template string) string {
result := template
for {
start := strings.Index(result, "{{")
if start == -1 {
break
}
end := strings.Index(result[start:], "}}")
if end == -1 {
break
}
fieldName := strings.TrimSpace(result[start+2 : start+end])
var val string
err := env.Tx().QueryRow(env.Ctx(),
fmt.Sprintf(`SELECT COALESCE(CAST(%s AS TEXT), '') FROM %s WHERE id = $1`,
pgx.Identifier{fieldName}.Sanitize(),
pgx.Identifier{tableName}.Sanitize()),
recordID).Scan(&val)
if err != nil {
val = ""
}
result = result[:start] + val + result[start+end+2:]
}
return result
}