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

@@ -330,6 +330,8 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err
VALUES (1, 1, true, 1, 1) ON CONFLICT (id) DO NOTHING`)
})
safeExec(ctx, tx, "base_groups", func() { seedBaseGroups(ctx, tx) })
safeExec(ctx, tx, "acl_rules", func() { seedACLRules(ctx, tx) })
safeExec(ctx, tx, "system_params", func() { seedSystemParams(ctx, tx) })
safeExec(ctx, tx, "languages", func() { seedLanguages(ctx, tx) })
safeExec(ctx, tx, "translations", func() { seedTranslations(ctx, tx) })
@@ -1676,3 +1678,136 @@ func generateUUID() string {
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// seedBaseGroups creates the base security groups and their XML IDs.
// Mirrors: odoo/addons/base/security/base_groups.xml
func seedBaseGroups(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding base security groups...")
type groupDef struct {
id int64
name string
xmlID string
}
groups := []groupDef{
{1, "Internal User", "group_user"},
{2, "Settings", "group_system"},
{3, "Access Rights", "group_erp_manager"},
{4, "Allow Export", "group_allow_export"},
{5, "Portal", "group_portal"},
{6, "Public", "group_public"},
}
for _, g := range groups {
tx.Exec(ctx, `INSERT INTO res_groups (id, name)
VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, g.id, g.name)
tx.Exec(ctx, `INSERT INTO ir_model_data (module, name, model, res_id)
VALUES ('base', $1, 'res.groups', $2) ON CONFLICT DO NOTHING`, g.xmlID, g.id)
}
// Add admin user (uid=1) to all groups
for _, g := range groups {
tx.Exec(ctx, `INSERT INTO res_groups_res_users_rel (res_groups_id, res_users_id)
VALUES ($1, 1) ON CONFLICT DO NOTHING`, g.id)
}
}
// seedACLRules creates access control entries for ALL registered models.
// Categorizes models into security tiers and assigns appropriate permissions.
// Mirrors: odoo/addons/base/security/ir.model.access.csv + per-module CSVs
func seedACLRules(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding ACL rules for all models...")
// Resolve group IDs
var groupSystem, groupUser int64
err := tx.QueryRow(ctx,
`SELECT g.id FROM res_groups g
JOIN ir_model_data imd ON imd.res_id = g.id AND imd.model = 'res.groups'
WHERE imd.module = 'base' AND imd.name = 'group_system'`).Scan(&groupSystem)
if err != nil {
log.Printf("db: cannot find group_system, skipping ACL seeding: %v", err)
return
}
err = tx.QueryRow(ctx,
`SELECT g.id FROM res_groups g
JOIN ir_model_data imd ON imd.res_id = g.id AND imd.model = 'res.groups'
WHERE imd.module = 'base' AND imd.name = 'group_user'`).Scan(&groupUser)
if err != nil {
log.Printf("db: cannot find group_user, skipping ACL seeding: %v", err)
return
}
// ── Security Tiers ──────────────────────────────────────────────
// Tier 1: System-only — only group_system gets full access
systemOnly := map[string]bool{
"ir.cron": true, "ir.rule": true, "ir.model.access": true,
}
// Tier 2: Admin-only — group_user=read, group_system=full
adminOnly := map[string]bool{
"ir.model": true, "ir.model.fields": true, "ir.model.data": true,
"ir.module.category": true, "ir.actions.server": true, "ir.sequence": true,
"ir.logging": true, "ir.config_parameter": true, "ir.default": true,
"ir.translation": true, "ir.actions.report": true, "report.paperformat": true,
"res.config.settings": true,
}
// Tier 3: Read-only for users — group_user=read, group_system=full
readOnly := map[string]bool{
"res.currency": true, "res.currency.rate": true,
"res.country": true, "res.country.state": true, "res.country.group": true,
"res.lang": true, "uom.category": true, "uom.uom": true,
"product.category": true, "product.removal": true,
"account.account.tag": true, "account.group": true,
"account.tax.group": true, "account.tax.repartition.line": true,
}
// Everything else → Tier 4: Standard user (group_user=full, group_system=full)
// Helper to insert an ACL rule
insertACL := func(modelID int64, modelName string, groupID int64, suffix string, read, write, create, unlink bool) {
aclName := "access_" + strings.ReplaceAll(modelName, ".", "_") + "_" + suffix
tx.Exec(ctx, `
INSERT INTO ir_model_access (name, model_id, group_id, perm_read, perm_write, perm_create, perm_unlink, active)
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
ON CONFLICT DO NOTHING`,
aclName, modelID, groupID, read, write, create, unlink)
}
// Iterate ALL registered models
allModels := orm.Registry.Models()
seeded := 0
for _, m := range allModels {
modelName := m.Name()
if m.IsAbstract() {
continue // Abstract models have no table → no ACL needed
}
// Look up ir_model ID
var modelID int64
err := tx.QueryRow(ctx,
"SELECT id FROM ir_model WHERE model = $1", modelName).Scan(&modelID)
if err != nil {
continue // Not yet in ir_model — will be seeded on next restart
}
if systemOnly[modelName] {
// Tier 1: only group_system full access
insertACL(modelID, modelName, groupSystem, "system", true, true, true, true)
} else if adminOnly[modelName] {
// Tier 2: group_user=read, group_system=full
insertACL(modelID, modelName, groupUser, "user_read", true, false, false, false)
insertACL(modelID, modelName, groupSystem, "system", true, true, true, true)
} else if readOnly[modelName] {
// Tier 3: group_user=read, group_system=full
insertACL(modelID, modelName, groupUser, "user_read", true, false, false, false)
insertACL(modelID, modelName, groupSystem, "system", true, true, true, true)
} else {
// Tier 4: group_user=full, group_system=full
insertACL(modelID, modelName, groupUser, "user", true, true, true, true)
insertACL(modelID, modelName, groupSystem, "system", true, true, true, true)
}
seeded++
}
log.Printf("db: seeded ACL rules for %d models", seeded)
}