Bring odoo-go to ~70%: read_group, record rules, admin, sessions

Phase 1: read_group/web_read_group with SQL GROUP BY, aggregates
  (sum/avg/min/max/count/array_agg/sum_currency), date granularity,
  M2O groupby resolution to [id, display_name].

Phase 2: Record rules with domain_force parsing (Python literal parser),
  global AND + group OR merging. Domain operators: child_of, parent_of,
  any, not any compiled to SQL hierarchy/EXISTS queries.

Phase 3: Button dispatch via /web/dataset/call_button, method return
  values interpreted as actions. Payment register wizard
  (account.payment.register) for sale→invoice→pay flow.

Phase 4: ir.filters, ir.default, product fields expanded, SO line
  product_id onchange, ir_model+ir_model_fields DB seeding.

Phase 5: CSV export (/web/export/csv), attachment upload/download
  via ir.attachment, fields_get with aggregator hints.

Admin/System: Session persistence (PostgreSQL-backed), ir.config_parameter
  with get_param/set_param, ir.cron, ir.logging, res.lang, res.config.settings
  with company-related fields, Settings form view. Technical menu with
  Views/Actions/Parameters/Security/Logging sub-menus. User change_password,
  preferences. Password never exposed in UI/API.

Bugfixes: false→nil for varchar/int fields, int32 in toInt64, call_button
  route with trailing slash, create_invoices returns action, search view
  always included, get_formview_action, name_create, ir.http stub.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-02 19:26:08 +02:00
parent 06e49c878a
commit b57176de2f
29 changed files with 3243 additions and 111 deletions

View File

@@ -4,6 +4,7 @@ package service
import (
"context"
"crypto/rand"
"fmt"
"log"
@@ -103,6 +104,22 @@ func InitDatabase(ctx context.Context, pool *pgxpool.Pool) error {
}
}
// Phase 4b: Add unique constraint on ir_config_parameter.key for ON CONFLICT support.
// Mirrors: odoo/addons/base/models/ir_config_parameter.py _sql_constraints
sp, spErr := tx.Begin(ctx)
if spErr == nil {
if _, err := sp.Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS ir_config_parameter_key_uniq ON ir_config_parameter (key)`); err != nil {
sp.Rollback(ctx)
} else {
sp.Commit(ctx)
}
}
// Phase 5: Seed ir_model and ir_model_fields with model metadata.
// This is critical because ir.rule joins through ir_model to find rules for a model.
// Mirrors: odoo/modules/loading.py load_module_graph() → _setup_base()
seedIrModelMetadata(ctx, tx, models)
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("db: commit: %w", err)
}
@@ -111,6 +128,121 @@ func InitDatabase(ctx context.Context, pool *pgxpool.Pool) error {
return nil
}
// seedIrModelMetadata populates ir_model and ir_model_fields for all registered models.
// Each model gets a row in ir_model; each field gets a row in ir_model_fields.
// Uses ON CONFLICT DO NOTHING so it's safe to call on every startup.
func seedIrModelMetadata(ctx context.Context, tx pgx.Tx, models map[string]*orm.Model) {
// Check if ir_model table exists (it should, but guard against ordering issues)
var exists bool
err := tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ir_model')`,
).Scan(&exists)
if err != nil || !exists {
log.Println("db: ir_model table does not exist yet, skipping metadata seed")
return
}
// Also check ir_model_fields
var fieldsExists bool
err = tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ir_model_fields')`,
).Scan(&fieldsExists)
if err != nil || !fieldsExists {
log.Println("db: ir_model_fields table does not exist yet, skipping metadata seed")
return
}
modelCount := 0
fieldCount := 0
for name, m := range models {
if m.IsAbstract() {
continue
}
// Check if this model already exists in ir_model
var modelID int64
sp, spErr := tx.Begin(ctx)
if spErr != nil {
continue
}
err := sp.QueryRow(ctx,
`SELECT id FROM ir_model WHERE model = $1`, name,
).Scan(&modelID)
if err != nil {
// Model doesn't exist yet — insert it
sp.Rollback(ctx)
sp2, spErr2 := tx.Begin(ctx)
if spErr2 != nil {
continue
}
err = sp2.QueryRow(ctx,
`INSERT INTO ir_model (model, name, info, state, transient)
VALUES ($1, $2, $3, 'base', $4)
RETURNING id`,
name, m.Description(), m.Description(), m.IsTransient(),
).Scan(&modelID)
if err != nil {
sp2.Rollback(ctx)
continue
}
sp2.Commit(ctx)
modelCount++
} else {
sp.Commit(ctx)
}
if modelID == 0 {
continue
}
// INSERT into ir_model_fields for each field
for fieldName, field := range m.Fields() {
if fieldName == "id" || fieldName == "display_name" {
continue
}
// Check if this field already exists
sp, spErr := tx.Begin(ctx)
if spErr != nil {
continue
}
var fieldExists bool
err := sp.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM ir_model_fields WHERE model_id = $1 AND name = $2)`,
modelID, fieldName,
).Scan(&fieldExists)
if err != nil {
sp.Rollback(ctx)
continue
}
if fieldExists {
sp.Commit(ctx)
continue
}
sp.Commit(ctx)
sp2, spErr2 := tx.Begin(ctx)
if spErr2 != nil {
continue
}
_, err = sp2.Exec(ctx,
`INSERT INTO ir_model_fields (model_id, name, field_description, ttype, state, store)
VALUES ($1, $2, $3, $4, 'base', $5)`,
modelID, fieldName, field.String, field.Type.String(), field.IsStored(),
)
if err != nil {
sp2.Rollback(ctx)
} else {
sp2.Commit(ctx)
fieldCount++
}
}
}
log.Printf("db: seeded ir_model metadata: %d models, %d fields", modelCount, fieldCount)
}
// NeedsSetup checks if the database requires initial setup.
func NeedsSetup(ctx context.Context, pool *pgxpool.Pool) bool {
var count int
@@ -262,7 +394,14 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err
// 13. Menus (ir_ui_menu + ir_model_data for XML IDs)
seedMenus(ctx, tx)
// 14. Demo data
// 14. Settings record (res.config.settings needs at least one record to display)
tx.Exec(ctx, `INSERT INTO res_config_settings (id, company_id, show_effect, create_uid, write_uid)
VALUES (1, 1, true, 1, 1) ON CONFLICT (id) DO NOTHING`)
// 14b. System parameters (ir.config_parameter)
seedSystemParams(ctx, tx)
// 15. Demo data
if cfg.DemoData {
seedDemoData(ctx, tx)
}
@@ -274,6 +413,8 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err
"account_tax", "sale_order", "sale_order_line", "account_move",
"ir_act_window", "ir_model_data", "ir_ui_menu",
"stock_location", "stock_picking_type", "stock_warehouse",
"crm_stage", "crm_lead",
"ir_config_parameter",
}
for _, table := range seqs {
tx.Exec(ctx, fmt.Sprintf(
@@ -426,6 +567,161 @@ func seedViews(ctx context.Context, tx pgx.Tx) {
</kanban>', 16, true, 'primary')
ON CONFLICT DO NOTHING`)
// Settings form view
tx.Exec(ctx, `INSERT INTO ir_ui_view (name, model, type, arch, priority, active, mode) VALUES
('res.config.settings.form', 'res.config.settings', 'form', '<form string="Settings" class="oe_form_configuration">
<header>
<button name="execute" string="Save" type="object" class="btn-primary"/>
<button string="Discard" special="cancel" class="btn-secondary"/>
</header>
<div class="o_setting_container">
<div class="settings">
<div class="app_settings_block">
<h2>Company</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_name"/>
<div class="text-muted">Your company name</div>
<field name="company_name"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_email"/>
<field name="company_email"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_phone"/>
<field name="company_phone"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_website"/>
<field name="company_website"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_vat"/>
<div class="text-muted">Tax ID / VAT number</div>
<field name="company_vat"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_currency_id"/>
<field name="company_currency_id"/>
</div>
</div>
</div>
<h2>Address</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_street"/>
<field name="company_street"/>
<field name="company_street2"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_zip"/>
<field name="company_zip"/>
<label for="company_city"/>
<field name="company_city"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="company_country_id"/>
<field name="company_country_id"/>
</div>
</div>
</div>
<h2>Features</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="group_multi_company"/>
</div>
<div class="o_setting_right_pane">
<label for="group_multi_company"/>
<div class="text-muted">Manage multiple companies</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="module_base_import"/>
</div>
<div class="o_setting_right_pane">
<label for="module_base_import"/>
<div class="text-muted">Import records from CSV/Excel files</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="show_effect"/>
</div>
<div class="o_setting_right_pane">
<label for="show_effect"/>
<div class="text-muted">Show animation effects</div>
</div>
</div>
</div>
</div>
</div>
</div>
<field name="company_id" invisible="1"/>
</form>', 10, true, 'primary')
ON CONFLICT DO NOTHING`)
// Admin list views for Settings > Technical
tx.Exec(ctx, `INSERT INTO ir_ui_view (name, model, type, arch, priority, active, mode) VALUES
('company.list', 'res.company', 'list', '<list>
<field name="name"/>
<field name="partner_id"/>
<field name="currency_id"/>
<field name="email"/>
<field name="phone"/>
</list>', 16, true, 'primary'),
('users.list', 'res.users', 'list', '<list>
<field name="name"/>
<field name="login"/>
<field name="company_id"/>
<field name="active"/>
</list>', 16, true, 'primary'),
('config_parameter.list', 'ir.config_parameter', 'list', '<list>
<field name="key"/>
<field name="value"/>
</list>', 16, true, 'primary'),
('ui_view.list', 'ir.ui.view', 'list', '<list>
<field name="name"/>
<field name="model"/>
<field name="type"/>
<field name="priority"/>
<field name="active"/>
</list>', 16, true, 'primary'),
('ui_menu.list', 'ir.ui.menu', 'list', '<list>
<field name="name"/>
<field name="parent_id"/>
<field name="sequence"/>
<field name="action"/>
</list>', 16, true, 'primary')
ON CONFLICT DO NOTHING`)
log.Println("db: UI views seeded")
}
@@ -463,10 +759,20 @@ func seedActions(ctx context.Context, tx pgx.Tx) {
{10, "Projects", "project.project", "list,form", "[]", "{}", "current", 80, 0, "project", "action_project"},
{11, "Tasks", "project.task", "list,form", "[]", "{}", "current", 80, 0, "project", "action_project_task"},
{12, "Vehicles", "fleet.vehicle", "list,form", "[]", "{}", "current", 80, 0, "fleet", "action_fleet_vehicle"},
{100, "Settings", "res.company", "form", "[]", "{}", "current", 80, 1, "base", "action_res_company_form"},
{100, "Settings", "res.config.settings", "form", "[]", "{}", "current", 80, 1, "base", "action_general_configuration"},
{104, "Companies", "res.company", "list,form", "[]", "{}", "current", 80, 0, "base", "action_res_company_form"},
{101, "Users", "res.users", "list,form", "[]", "{}", "current", 80, 0, "base", "action_res_users"},
{102, "Sequences", "ir.sequence", "list,form", "[]", "{}", "current", 80, 0, "base", "ir_sequence_form"},
{103, "Change My Preferences", "res.users", "form", "[]", "{}", "new", 80, 0, "base", "action_res_users_my"},
{105, "Groups", "res.groups", "list,form", "[]", "{}", "current", 80, 0, "base", "action_res_groups"},
{106, "Logging", "ir.logging", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_logging"},
{107, "System Parameters", "ir.config_parameter", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_config_parameter"},
{108, "Scheduled Actions", "ir.cron", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_cron"},
{109, "Views", "ir.ui.view", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_ui_view"},
{110, "Actions", "ir.actions.act_window", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_act_window"},
{111, "Menus", "ir.ui.menu", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_ui_menu"},
{112, "Access Rights", "ir.model.access", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_model_access"},
{113, "Record Rules", "ir.rule", "list,form", "[]", "{}", "current", 80, 0, "base", "action_ir_rule"},
}
for _, a := range actions {
@@ -552,8 +858,40 @@ func seedMenus(ctx context.Context, tx pgx.Tx) {
// ── Settings ─────────────────────────────────────────────
{100, "Settings", nil, 100, "ir.actions.act_window,100", "fa-cog,#71639e,#FFFFFF", "base", "menu_administration"},
{101, "Users & Companies", p(100), 10, "ir.actions.act_window,101", "", "base", "menu_users"},
{102, "Technical", p(100), 20, "ir.actions.act_window,102", "", "base", "menu_custom"},
{101, "Users & Companies", p(100), 10, "", "", "base", "menu_users"},
{110, "Users", p(101), 10, "ir.actions.act_window,101", "", "base", "menu_action_res_users"},
{111, "Companies", p(101), 20, "ir.actions.act_window,104", "", "base", "menu_action_res_company_form"},
{112, "Groups", p(101), 30, "ir.actions.act_window,105", "", "base", "menu_action_res_groups"},
{102, "Technical", p(100), 20, "", "", "base", "menu_custom"},
// Database Structure
{120, "Database Structure", p(102), 10, "", "", "base", "menu_custom_database_structure"},
// Sequences & Identifiers
{122, "Sequences", p(102), 15, "ir.actions.act_window,102", "", "base", "menu_custom_sequences"},
// Parameters
{125, "Parameters", p(102), 20, "", "", "base", "menu_custom_parameters"},
{126, "System Parameters", p(125), 10, "ir.actions.act_window,107", "", "base", "menu_ir_config_parameter"},
// Scheduled Actions
{128, "Automation", p(102), 25, "", "", "base", "menu_custom_automation"},
{129, "Scheduled Actions", p(128), 10, "ir.actions.act_window,108", "", "base", "menu_ir_cron"},
// User Interface
{130, "User Interface", p(102), 30, "", "", "base", "menu_custom_user_interface"},
{131, "Views", p(130), 10, "ir.actions.act_window,109", "", "base", "menu_ir_ui_view"},
{132, "Actions", p(130), 20, "ir.actions.act_window,110", "", "base", "menu_ir_act_window"},
{133, "Menus", p(130), 30, "ir.actions.act_window,111", "", "base", "menu_ir_ui_menu"},
// Security
{135, "Security", p(102), 40, "", "", "base", "menu_custom_security"},
{136, "Access Rights", p(135), 10, "ir.actions.act_window,112", "", "base", "menu_ir_model_access"},
{137, "Record Rules", p(135), 20, "ir.actions.act_window,113", "", "base", "menu_ir_rule"},
// Logging
{140, "Logging", p(102), 50, "ir.actions.act_window,106", "", "base", "menu_ir_logging"},
}
for _, m := range menus {
@@ -627,7 +965,22 @@ func seedDemoData(ctx context.Context, tx pgx.Tx) {
('RE/2026/0003', 'out_invoice', 'posted', '2026-03-25', '2026-03-25', 5, 1, 1, 1, 13923, 11700)
ON CONFLICT DO NOTHING`)
log.Println("db: demo data loaded (8 contacts, 3 sale orders, 3 invoices)")
// CRM pipeline stages
tx.Exec(ctx, `INSERT INTO crm_stage (id, name, sequence, fold, is_won) VALUES
(1, 'New', 1, false, false),
(2, 'Qualified', 2, false, false),
(3, 'Proposition', 3, false, false),
(4, 'Won', 4, false, true)
ON CONFLICT (id) DO NOTHING`)
// CRM demo leads (partner IDs 3-5 are the first three demo companies seeded above)
tx.Exec(ctx, `INSERT INTO crm_lead (name, type, stage_id, partner_id, expected_revenue, company_id, currency_id, active, state, priority) VALUES
('Website Redesign', 'opportunity', 1, 3, 15000, 1, 1, true, 'open', '0'),
('ERP Implementation', 'opportunity', 2, 4, 45000, 1, 1, true, 'open', '0'),
('Cloud Migration', 'opportunity', 3, 5, 28000, 1, 1, true, 'open', '0')
ON CONFLICT DO NOTHING`)
log.Println("db: demo data loaded (8 contacts, 3 sale orders, 3 invoices, 4 CRM stages, 3 CRM leads)")
}
// SeedBaseData is the legacy function — redirects to setup with defaults.
@@ -654,3 +1007,45 @@ func SeedBaseData(ctx context.Context, pool *pgxpool.Pool) error {
DemoData: false,
})
}
// seedSystemParams inserts default system parameters into ir_config_parameter.
// Mirrors: odoo/addons/base/data/ir_config_parameter_data.xml
func seedSystemParams(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding system parameters...")
// Ensure unique constraint on key column for ON CONFLICT to work
tx.Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS ir_config_parameter_key_uniq ON ir_config_parameter (key)`)
// Generate a random UUID for database.uuid
dbUUID := generateUUID()
params := []struct {
key string
value string
}{
{"web.base.url", "http://localhost:8069"},
{"database.uuid", dbUUID},
{"base.login_cooldown_after", "10"},
{"base.login_cooldown_duration", "60"},
}
for _, p := range params {
tx.Exec(ctx,
`INSERT INTO ir_config_parameter (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING`, p.key, p.value)
}
log.Printf("db: seeded %d system parameters", len(params))
}
// generateUUID creates a random UUID v4 string.
func generateUUID() string {
b := make([]byte, 16)
rand.Read(b)
// Set UUID version 4 bits
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}