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

View File

@@ -80,6 +80,8 @@ func initProjectTask() {
orm.Many2one("milestone_id", "project.milestone", orm.FieldOpts{String: "Milestone"}),
orm.Many2many("depend_ids", "project.task", orm.FieldOpts{String: "Depends On"}),
orm.Boolean("recurring_task", orm.FieldOpts{String: "Recurrent"}),
orm.Datetime("planned_date_start", orm.FieldOpts{String: "Planned Start Date"}),
orm.Datetime("planned_date_end", orm.FieldOpts{String: "Planned End Date"}),
orm.Selection("display_type", []orm.SelectionItem{
{Value: "", Label: ""},
{Value: "line_section", Label: "Section"},
@@ -100,38 +102,54 @@ func initProjectTask() {
task.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 project_task SET state = 'done' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'done' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: done %d: %w", id, err)
}
}
return true, nil
})
// action_cancel: mark task as cancelled
task.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'cancel' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'cancel' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: cancel %d: %w", id, err)
}
}
return true, nil
})
// action_reopen: reopen a cancelled/done task
task.RegisterMethod("action_reopen", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'open' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET state = 'open' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: reopen %d: %w", id, err)
}
}
return true, nil
})
// action_blocked: set kanban state to blocked
task.RegisterMethod("action_blocked", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id)
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET kanban_state = 'blocked' WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: blocked %d: %w", id, err)
}
}
return true, nil
})
task.RegisterMethod("toggle_active", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task SET active = NOT active WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task: toggle_active %d: %w", id, err)
}
}
return true, nil
})
@@ -185,3 +203,62 @@ func initProjectTags() {
orm.Integer("color", orm.FieldOpts{String: "Color Index"}),
)
}
// initProjectTaskChecklist registers the project.task.checklist model.
// Mirrors: odoo/addons/project/models/project_task_checklist.py
func initProjectTaskChecklist() {
m := orm.NewModel("project.task.checklist", orm.ModelOpts{
Description: "Task Checklist Item",
Order: "sequence, id",
})
m.AddFields(
orm.Many2one("task_id", "project.task", orm.FieldOpts{
String: "Task", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
orm.Boolean("is_done", orm.FieldOpts{String: "Done", Default: false}),
orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
)
// action_toggle_done: Toggle the checklist item done status.
m.RegisterMethod("action_toggle_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
env := rs.Env()
for _, id := range rs.IDs() {
if _, err := env.Tx().Exec(env.Ctx(),
`UPDATE project_task_checklist SET is_done = NOT is_done WHERE id = $1`, id); err != nil {
return nil, fmt.Errorf("project.task.checklist: toggle_done %d: %w", id, err)
}
}
return true, nil
})
}
// initProjectSharing registers the project.sharing model.
// Mirrors: odoo/addons/project/models/project_sharing.py
func initProjectSharing() {
m := orm.NewModel("project.sharing", orm.ModelOpts{
Description: "Project Sharing",
Order: "id",
})
m.AddFields(
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
String: "Partner", Required: true, Index: true,
}),
orm.Many2one("project_id", "project.project", orm.FieldOpts{
String: "Project", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
}),
orm.Selection("access_level", []orm.SelectionItem{
{Value: "read", Label: "Read"},
{Value: "edit", Label: "Edit"},
{Value: "admin", Label: "Admin"},
}, orm.FieldOpts{String: "Access Level", Required: true, Default: "read"}),
)
m.AddSQLConstraint(
"unique_partner_project",
"UNIQUE(partner_id, project_id)",
"A partner can only have one sharing entry per project.",
)
}