Files
goodie/pkg/service/db.go
Marc b57176de2f 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>
2026-04-02 19:26:08 +02:00

1052 lines
40 KiB
Go

// Package service provides database and model services.
// Mirrors: odoo/service/db.py
package service
import (
"context"
"crypto/rand"
"fmt"
"log"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
l10n_de "odoo-go/addons/l10n_de"
"odoo-go/pkg/orm"
"odoo-go/pkg/tools"
)
// InitDatabase creates all tables for registered models.
// Mirrors: odoo/modules/loading.py load_module_graph() → model._setup_base()
func InitDatabase(ctx context.Context, pool *pgxpool.Pool) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("db: begin: %w", err)
}
defer tx.Rollback(ctx)
models := orm.Registry.Models()
// Phase 1: Create tables
for name, m := range models {
if m.IsAbstract() {
continue
}
sql := m.CreateTableSQL()
if sql == "" {
continue
}
log.Printf("db: creating table for %s", name)
if _, err := tx.Exec(ctx, sql); err != nil {
return fmt.Errorf("db: create table %s: %w", name, err)
}
}
// Phase 2: Add foreign keys (after all tables exist, each in savepoint)
for name, m := range models {
if m.IsAbstract() {
continue
}
for _, sql := range m.ForeignKeySQL() {
sp, spErr := tx.Begin(ctx)
if spErr != nil {
continue
}
wrappedSQL := fmt.Sprintf(
`DO $$ BEGIN %s; EXCEPTION WHEN duplicate_object THEN NULL; END $$`,
sql,
)
if _, err := sp.Exec(ctx, wrappedSQL); err != nil {
log.Printf("db: warning: FK for %s: %v", name, err)
sp.Rollback(ctx)
} else {
log.Printf("db: adding FK for %s", name)
sp.Commit(ctx)
}
}
}
// Phase 3: Create indexes (each in savepoint)
for _, m := range models {
if m.IsAbstract() {
continue
}
for _, sql := range m.IndexSQL() {
sp, spErr := tx.Begin(ctx)
if spErr != nil {
continue
}
if _, err := sp.Exec(ctx, sql); err != nil {
log.Printf("db: warning: index: %v", err)
sp.Rollback(ctx)
} else {
sp.Commit(ctx)
}
}
}
// Phase 4: Create Many2many junction tables (each in savepoint to avoid aborting tx)
for _, m := range models {
if m.IsAbstract() {
continue
}
for _, sql := range m.Many2manyTableSQL() {
sp, spErr := tx.Begin(ctx)
if spErr != nil {
continue
}
if _, err := sp.Exec(ctx, sql); err != nil {
log.Printf("db: warning: m2m table: %v", err)
sp.Rollback(ctx)
} else {
sp.Commit(ctx)
}
}
}
// 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)
}
log.Printf("db: schema initialized for %d models", len(models))
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
err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM res_company`).Scan(&count)
return err != nil || count == 0
}
// SetupConfig holds parameters for the setup wizard.
type SetupConfig struct {
CompanyName string
Street string
Zip string
City string
CountryCode string
CountryName string
PhoneCode string
Email string
Phone string
VAT string
Chart string // "skr03", "skr04", "none"
AdminLogin string
AdminPassword string // Already hashed with bcrypt
DemoData bool
}
// SeedWithSetup initializes the database with user-provided configuration.
func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("db: begin: %w", err)
}
defer tx.Rollback(ctx)
log.Printf("db: seeding database for %q...", cfg.CompanyName)
// 1. Currency (EUR)
_, err = tx.Exec(ctx, `
INSERT INTO res_currency (id, name, symbol, decimal_places, rounding, active, "position")
VALUES (1, 'EUR', '€', 2, 0.01, true, 'after')
ON CONFLICT (id) DO NOTHING`)
if err != nil {
return fmt.Errorf("db: seed currency: %w", err)
}
// 2. Country
_, err = tx.Exec(ctx, `
INSERT INTO res_country (id, name, code, phone_code)
VALUES (1, $1, $2, $3)
ON CONFLICT (id) DO NOTHING`, cfg.CountryName, cfg.CountryCode, cfg.PhoneCode)
if err != nil {
return fmt.Errorf("db: seed country: %w", err)
}
// 3. Company partner
_, err = tx.Exec(ctx, `
INSERT INTO res_partner (id, name, is_company, active, type, lang, email, phone, street, zip, city, country_id, vat)
VALUES (1, $1, true, true, 'contact', 'de_DE', $2, $3, $4, $5, $6, 1, $7)
ON CONFLICT (id) DO NOTHING`,
cfg.CompanyName, cfg.Email, cfg.Phone, cfg.Street, cfg.Zip, cfg.City, cfg.VAT)
if err != nil {
return fmt.Errorf("db: seed company partner: %w", err)
}
// 4. Company
_, err = tx.Exec(ctx, `
INSERT INTO res_company (id, name, partner_id, currency_id, country_id, active, sequence, street, zip, city, email, phone, vat)
VALUES (1, $1, 1, 1, 1, true, 10, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO NOTHING`,
cfg.CompanyName, cfg.Street, cfg.Zip, cfg.City, cfg.Email, cfg.Phone, cfg.VAT)
if err != nil {
return fmt.Errorf("db: seed company: %w", err)
}
// 5. Admin partner
adminName := "Administrator"
_, err = tx.Exec(ctx, `
INSERT INTO res_partner (id, name, is_company, active, type, email, lang)
VALUES (2, $1, false, true, 'contact', $2, 'de_DE')
ON CONFLICT (id) DO NOTHING`, adminName, cfg.AdminLogin)
if err != nil {
return fmt.Errorf("db: seed admin partner: %w", err)
}
// 6. Admin user
_, err = tx.Exec(ctx, `
INSERT INTO res_users (id, login, password, active, partner_id, company_id)
VALUES (1, $1, $2, true, 2, 1)
ON CONFLICT (id) DO NOTHING`, cfg.AdminLogin, cfg.AdminPassword)
if err != nil {
return fmt.Errorf("db: seed admin user: %w", err)
}
// 7. Journals
_, err = tx.Exec(ctx, `
INSERT INTO account_journal (id, name, code, type, company_id, active, sequence) VALUES
(1, 'Ausgangsrechnungen', 'INV', 'sale', 1, true, 10),
(2, 'Eingangsrechnungen', 'BILL', 'purchase', 1, true, 20),
(3, 'Bank', 'BNK1', 'bank', 1, true, 30),
(4, 'Kasse', 'CSH1', 'cash', 1, true, 40),
(5, 'Sonstige', 'MISC', 'general', 1, true, 50)
ON CONFLICT (id) DO NOTHING`)
if err != nil {
return fmt.Errorf("db: seed journals: %w", err)
}
// 8. Sequences
_, err = tx.Exec(ctx, `
INSERT INTO ir_sequence (id, name, code, prefix, padding, number_next, number_increment, active, implementation) VALUES
(1, 'Buchungssatz', 'account.move', 'MISC/', 4, 1, 1, true, 'standard'),
(2, 'Ausgangsrechnung', 'account.move.out_invoice', 'RE/%(year)s/', 4, 1, 1, true, 'standard'),
(3, 'Eingangsrechnung', 'account.move.in_invoice', 'ER/%(year)s/', 4, 1, 1, true, 'standard'),
(4, 'Angebot', 'sale.order', 'AG', 4, 1, 1, true, 'standard'),
(5, 'Bestellung', 'purchase.order', 'BE', 4, 1, 1, true, 'standard')
ON CONFLICT (id) DO NOTHING`)
if err != nil {
return fmt.Errorf("db: seed sequences: %w", err)
}
// 9. Chart of Accounts (if selected)
if cfg.Chart == "skr03" || cfg.Chart == "skr04" {
// Currently only SKR03 is implemented
for _, acc := range l10n_de.SKR03Accounts {
tx.Exec(ctx, `
INSERT INTO account_account (code, name, account_type, company_id, reconcile)
VALUES ($1, $2, $3, 1, $4) ON CONFLICT DO NOTHING`,
acc.Code, acc.Name, acc.AccountType, acc.Reconcile)
}
log.Printf("db: seeded %d SKR03 accounts", len(l10n_de.SKR03Accounts))
// Taxes
for _, tax := range l10n_de.SKR03Taxes {
tx.Exec(ctx, `
INSERT INTO account_tax (name, amount, type_tax_use, amount_type, company_id, active, sequence, is_base_affected)
VALUES ($1, $2, $3, 'percent', 1, true, 1, true) ON CONFLICT DO NOTHING`,
tax.Name, tax.Amount, tax.TypeUse)
}
log.Printf("db: seeded %d German tax definitions", len(l10n_de.SKR03Taxes))
}
// 10. Stock reference data (locations, picking types, warehouse)
seedStockData(ctx, tx)
// 11. UI Views for key models
seedViews(ctx, tx)
// 12. Actions (ir_act_window + ir_model_data for XML IDs)
seedActions(ctx, tx)
// 13. Menus (ir_ui_menu + ir_model_data for XML IDs)
seedMenus(ctx, tx)
// 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)
}
// 15. Reset sequences (each individually — pgx doesn't support multi-statement)
seqs := []string{
"res_currency", "res_country", "res_partner", "res_company",
"res_users", "ir_sequence", "account_journal", "account_account",
"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(
`SELECT setval('%s_id_seq', GREATEST((SELECT COALESCE(MAX(id),0) FROM %q), 1))`,
table, table))
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("db: commit seed: %w", err)
}
log.Printf("db: database seeded successfully for %q", cfg.CompanyName)
return nil
}
// seedStockData creates stock locations, picking types, and a default warehouse.
// Mirrors: odoo/addons/stock/data/stock_data.xml
func seedStockData(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding stock reference data...")
// Stock locations (hierarchical, mirroring Odoo's stock_data.xml)
tx.Exec(ctx, `
INSERT INTO stock_location (id, name, complete_name, usage, active, location_id, company_id) VALUES
(1, 'Physical Locations', 'Physical Locations', 'view', true, NULL, 1),
(2, 'Partner Locations', 'Partner Locations', 'view', true, NULL, 1),
(3, 'Virtual Locations', 'Virtual Locations', 'view', true, NULL, 1),
(4, 'WH', 'Physical Locations/WH', 'internal', true, 1, 1),
(5, 'Customers', 'Partner Locations/Customers', 'customer', true, 2, 1),
(6, 'Vendors', 'Partner Locations/Vendors', 'supplier', true, 2, 1)
ON CONFLICT (id) DO NOTHING`)
// Default warehouse (must come before picking types due to FK)
tx.Exec(ctx, `
INSERT INTO stock_warehouse (id, name, code, active, company_id, lot_stock_id, sequence)
VALUES (1, 'Main Warehouse', 'WH', true, 1, 4, 10)
ON CONFLICT (id) DO NOTHING`)
// Stock picking types (operation types for receipts, deliveries, internal transfers)
tx.Exec(ctx, `
INSERT INTO stock_picking_type (id, name, code, sequence_code, active, warehouse_id, company_id, sequence,
default_location_src_id, default_location_dest_id) VALUES
(1, 'Receipts', 'incoming', 'IN', true, 1, 1, 1, 6, 4),
(2, 'Delivery Orders', 'outgoing', 'OUT', true, 1, 1, 2, 4, 5),
(3, 'Internal Transfers', 'internal', 'INT', true, 1, 1, 3, 4, 4)
ON CONFLICT (id) DO NOTHING`)
log.Println("db: stock reference data seeded (6 locations, 3 picking types, 1 warehouse)")
}
// seedViews creates UI views for key models.
func seedViews(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding UI views...")
tx.Exec(ctx, `INSERT INTO ir_ui_view (name, model, type, arch, priority, active, mode) VALUES
('partner.list', 'res.partner', 'list', '<list>
<field name="name"/>
<field name="email"/>
<field name="phone"/>
<field name="city"/>
<field name="country_id"/>
<field name="is_company"/>
</list>', 16, true, 'primary'),
('partner.form', 'res.partner', 'form', '<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="is_company"/>
<field name="type"/>
<field name="email"/>
<field name="phone"/>
<field name="mobile"/>
<field name="website"/>
</group>
<group>
<field name="street"/>
<field name="street2"/>
<field name="zip"/>
<field name="city"/>
<field name="country_id"/>
<field name="vat"/>
<field name="lang"/>
</group>
</group>
<notebook>
<page string="Internal Notes">
<field name="comment"/>
</page>
</notebook>
</sheet>
</form>', 16, true, 'primary'),
('partner.search', 'res.partner', 'search', '<search>
<field name="name"/>
<field name="email"/>
<field name="phone"/>
<field name="city"/>
</search>', 16, true, 'primary'),
('invoice.list', 'account.move', 'list', '<list>
<field name="name"/>
<field name="partner_id"/>
<field name="invoice_date"/>
<field name="date"/>
<field name="move_type"/>
<field name="state"/>
<field name="amount_total"/>
</list>', 16, true, 'primary'),
('sale.list', 'sale.order', 'list', '<list>
<field name="name"/>
<field name="partner_id"/>
<field name="date_order"/>
<field name="state"/>
<field name="amount_total"/>
</list>', 16, true, 'primary'),
('lead.list', 'crm.lead', 'list', '<list>
<field name="name"/>
<field name="partner_name"/>
<field name="email_from"/>
<field name="phone"/>
<field name="stage_id"/>
<field name="expected_revenue"/>
<field name="user_id"/>
</list>', 16, true, 'primary'),
('partner.kanban', 'res.partner', 'kanban', '<kanban>
<templates>
<t t-name="card">
<div class="oe_kanban_global_click">
<strong><field name="name"/></strong>
<div><field name="email"/></div>
<div><field name="phone"/></div>
<div><field name="city"/></div>
</div>
</t>
</templates>
</kanban>', 16, true, 'primary'),
('lead.kanban', 'crm.lead', 'kanban', '<kanban default_group_by="stage_id">
<templates>
<t t-name="card">
<div class="oe_kanban_global_click">
<strong><field name="name"/></strong>
<div><field name="partner_name"/></div>
<div>Revenue: <field name="expected_revenue"/></div>
</div>
</t>
</templates>
</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")
}
// seedActions inserts action records into ir_act_window and their XML IDs into ir_model_data.
// Mirrors: odoo/addons/base/data/ir_actions_data.xml, contacts/data/..., account/data/..., etc.
func seedActions(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding actions...")
// Action definitions: id, name, res_model, view_mode, domain, context, target, limit, res_id
type actionDef struct {
ID int
Name string
ResModel string
ViewMode string
Domain string
Context string
Target string
Limit int
ResID int
// XML ID parts
Module string
XMLName string
}
actions := []actionDef{
{1, "Contacts", "res.partner", "list,kanban,form", "[]", "{}", "current", 80, 0, "contacts", "action_contacts"},
{2, "Invoices", "account.move", "list,form", `[("move_type","in",["out_invoice","out_refund"])]`, "{}", "current", 80, 0, "account", "action_move_out_invoice_type"},
{3, "Sale Orders", "sale.order", "list,form", "[]", "{}", "current", 80, 0, "sale", "action_quotations_with_onboarding"},
{4, "CRM Pipeline", "crm.lead", "kanban,list,form", "[]", "{}", "current", 80, 0, "crm", "crm_lead_all_pipeline"},
{5, "Transfers", "stock.picking", "list,form", "[]", "{}", "current", 80, 0, "stock", "action_picking_tree_all"},
{6, "Products", "product.template", "list,form", "[]", "{}", "current", 80, 0, "stock", "action_product_template"},
{7, "Purchase Orders", "purchase.order", "list,form", "[]", "{}", "current", 80, 0, "purchase", "action_purchase_orders"},
{8, "Employees", "hr.employee", "list,form", "[]", "{}", "current", 80, 0, "hr", "action_hr_employee"},
{9, "Departments", "hr.department", "list,form", "[]", "{}", "current", 80, 0, "hr", "action_hr_department"},
{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.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 {
tx.Exec(ctx, `
INSERT INTO ir_act_window (id, name, type, res_model, view_mode, res_id, domain, context, target, "limit")
VALUES ($1, $2, 'ir.actions.act_window', $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO NOTHING`,
a.ID, a.Name, a.ResModel, a.ViewMode, a.ResID, a.Domain, a.Context, a.Target, a.Limit)
tx.Exec(ctx, `
INSERT INTO ir_model_data (module, name, model, res_id, noupdate)
VALUES ($1, $2, 'ir.actions.act_window', $3, true)
ON CONFLICT DO NOTHING`,
a.Module, a.XMLName, a.ID)
}
log.Printf("db: seeded %d actions with XML IDs", len(actions))
}
// seedMenus creates menu records in ir_ui_menu and their XML IDs in ir_model_data.
// Mirrors: odoo/addons/*/data/*_menu.xml — menus are loaded from XML data files.
//
// The action field stores Odoo reference format: "ir.actions.act_window,<id>"
// pointing to the action IDs seeded by seedActions().
// parent_id is NULL for top-level app menus, or references the parent menu ID.
func seedMenus(ctx context.Context, tx pgx.Tx) {
log.Println("db: seeding menus...")
type menuDef struct {
ID int
Name string
ParentID *int // nil = top-level
Sequence int
Action string // "ir.actions.act_window,<id>" or ""
WebIcon string // FontAwesome icon for top-level menus
// XML ID parts for ir_model_data
Module string
XMLName string
}
// Helper to create a pointer to int
p := func(id int) *int { return &id }
menus := []menuDef{
// ── Contacts ──────────────────────────────────────────────
{1, "Contacts", nil, 10, "ir.actions.act_window,1", "fa-address-book,#71639e,#FFFFFF", "contacts", "menu_contacts"},
{10, "Contacts", p(1), 10, "ir.actions.act_window,1", "", "contacts", "menu_contacts_list"},
// ── Invoicing ────────────────────────────────────────────
{2, "Invoicing", nil, 20, "ir.actions.act_window,2", "fa-book,#71639e,#FFFFFF", "account", "menu_finance"},
{20, "Invoices", p(2), 10, "ir.actions.act_window,2", "", "account", "menu_finance_invoices"},
// ── Sales ────────────────────────────────────────────────
{3, "Sales", nil, 30, "ir.actions.act_window,3", "fa-bar-chart,#71639e,#FFFFFF", "sale", "menu_sale_root"},
{30, "Orders", p(3), 10, "ir.actions.act_window,3", "", "sale", "menu_sale_orders"},
// ── CRM ──────────────────────────────────────────────────
{4, "CRM", nil, 40, "ir.actions.act_window,4", "fa-star,#71639e,#FFFFFF", "crm", "menu_crm_root"},
{40, "Pipeline", p(4), 10, "ir.actions.act_window,4", "", "crm", "menu_crm_pipeline"},
// ── Inventory ────────────────────────────────────────────
{5, "Inventory", nil, 50, "ir.actions.act_window,5", "fa-cubes,#71639e,#FFFFFF", "stock", "menu_stock_root"},
{50, "Transfers", p(5), 10, "ir.actions.act_window,5", "", "stock", "menu_stock_transfers"},
{51, "Products", p(5), 20, "ir.actions.act_window,6", "", "stock", "menu_stock_products"},
// ── Purchase ─────────────────────────────────────────────
{6, "Purchase", nil, 60, "ir.actions.act_window,7", "fa-shopping-cart,#71639e,#FFFFFF", "purchase", "menu_purchase_root"},
{60, "Purchase Orders", p(6), 10, "ir.actions.act_window,7", "", "purchase", "menu_purchase_orders"},
// ── Employees / HR ───────────────────────────────────────
{7, "Employees", nil, 70, "ir.actions.act_window,8", "fa-users,#71639e,#FFFFFF", "hr", "menu_hr_root"},
{70, "Employees", p(7), 10, "ir.actions.act_window,8", "", "hr", "menu_hr_employees"},
{71, "Departments", p(7), 20, "ir.actions.act_window,9", "", "hr", "menu_hr_departments"},
// ── Project ──────────────────────────────────────────────
{8, "Project", nil, 80, "ir.actions.act_window,10", "fa-puzzle-piece,#71639e,#FFFFFF", "project", "menu_project_root"},
{80, "Projects", p(8), 10, "ir.actions.act_window,10", "", "project", "menu_projects"},
{81, "Tasks", p(8), 20, "ir.actions.act_window,11", "", "project", "menu_project_tasks"},
// ── Fleet ────────────────────────────────────────────────
{9, "Fleet", nil, 90, "ir.actions.act_window,12", "fa-car,#71639e,#FFFFFF", "fleet", "menu_fleet_root"},
{90, "Vehicles", p(9), 10, "ir.actions.act_window,12", "", "fleet", "menu_fleet_vehicles"},
// ── Settings ─────────────────────────────────────────────
{100, "Settings", nil, 100, "ir.actions.act_window,100", "fa-cog,#71639e,#FFFFFF", "base", "menu_administration"},
{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 {
// Insert the menu record
var actionVal *string
if m.Action != "" {
actionVal = &m.Action
}
var webIconVal *string
if m.WebIcon != "" {
webIconVal = &m.WebIcon
}
tx.Exec(ctx, `
INSERT INTO ir_ui_menu (id, name, parent_id, sequence, action, web_icon, active)
VALUES ($1, $2, $3, $4, $5, $6, true)
ON CONFLICT (id) DO NOTHING`,
m.ID, m.Name, m.ParentID, m.Sequence, actionVal, webIconVal)
// Insert the XML ID into ir_model_data
tx.Exec(ctx, `
INSERT INTO ir_model_data (module, name, model, res_id, noupdate)
VALUES ($1, $2, 'ir.ui.menu', $3, true)
ON CONFLICT DO NOTHING`,
m.Module, m.XMLName, m.ID)
}
log.Printf("db: seeded %d menus with XML IDs", len(menus))
}
// seedDemoData creates example records for testing.
func seedDemoData(ctx context.Context, tx pgx.Tx) {
log.Println("db: loading demo data...")
// Demo customers
tx.Exec(ctx, `INSERT INTO res_partner (name, is_company, active, type, email, city, lang) VALUES
('Müller Bau GmbH', true, true, 'contact', 'info@mueller-bau.de', 'München', 'de_DE'),
('Schmidt & Söhne KG', true, true, 'contact', 'kontakt@schmidt-soehne.de', 'Hamburg', 'de_DE'),
('Weber Elektro AG', true, true, 'contact', 'info@weber-elektro.de', 'Frankfurt', 'de_DE'),
('Fischer Metallbau', true, true, 'contact', 'office@fischer-metall.de', 'Stuttgart', 'de_DE'),
('Hoffmann IT Services', true, true, 'contact', 'hello@hoffmann-it.de', 'Berlin', 'de_DE')
ON CONFLICT DO NOTHING`)
// Demo contacts (employees of customers)
tx.Exec(ctx, `INSERT INTO res_partner (name, is_company, active, type, email, phone, lang) VALUES
('Thomas Müller', false, true, 'contact', 'thomas@mueller-bau.de', '+49 89 1234567', 'de_DE'),
('Anna Schmidt', false, true, 'contact', 'anna@schmidt-soehne.de', '+49 40 9876543', 'de_DE'),
('Peter Weber', false, true, 'contact', 'peter@weber-elektro.de', '+49 69 5551234', 'de_DE')
ON CONFLICT DO NOTHING`)
// Demo sale orders
tx.Exec(ctx, `INSERT INTO sale_order (name, partner_id, company_id, currency_id, state, date_order, amount_untaxed, amount_total) VALUES
('AG0001', 3, 1, 1, 'sale', '2026-03-15 10:00:00', 18100, 21539),
('AG0002', 4, 1, 1, 'draft', '2026-03-20 14:30:00', 6000, 7140),
('AG0003', 5, 1, 1, 'sale', '2026-03-25 09:15:00', 11700, 13923)
ON CONFLICT DO NOTHING`)
// Demo sale order lines
tx.Exec(ctx, `INSERT INTO sale_order_line (order_id, name, product_uom_qty, price_unit, sequence) VALUES
((SELECT id FROM sale_order WHERE name='AG0001'), 'Baustelleneinrichtung', 1, 12500, 10),
((SELECT id FROM sale_order WHERE name='AG0001'), 'Erdarbeiten', 3, 2800, 20),
((SELECT id FROM sale_order WHERE name='AG0002'), 'Beratung IT-Infrastruktur', 40, 150, 10),
((SELECT id FROM sale_order WHERE name='AG0003'), 'Elektroinstallation', 1, 8500, 10),
((SELECT id FROM sale_order WHERE name='AG0003'), 'Material Kabel/Dosen', 1, 3200, 20)
ON CONFLICT DO NOTHING`)
// Demo invoices (account.move)
tx.Exec(ctx, `INSERT INTO account_move (name, move_type, state, date, invoice_date, partner_id, journal_id, company_id, currency_id, amount_total, amount_untaxed) VALUES
('RE/2026/0001', 'out_invoice', 'posted', '2026-03-10', '2026-03-10', 3, 1, 1, 1, 14875, 12500),
('RE/2026/0002', 'out_invoice', 'draft', '2026-03-20', '2026-03-20', 4, 1, 1, 1, 7140, 6000),
('RE/2026/0003', 'out_invoice', 'posted', '2026-03-25', '2026-03-25', 5, 1, 1, 1, 13923, 11700)
ON CONFLICT DO NOTHING`)
// 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.
// Used when running without the setup wizard (e.g., Docker auto-start).
func SeedBaseData(ctx context.Context, pool *pgxpool.Pool) error {
if !NeedsSetup(ctx, pool) {
log.Println("db: base data already exists, skipping seed")
return nil
}
adminHash, _ := tools.HashPassword("admin")
return SeedWithSetup(ctx, pool, SetupConfig{
CompanyName: "My Company",
Street: "",
Zip: "",
City: "",
CountryCode: "DE",
CountryName: "Germany",
PhoneCode: "49",
Email: "admin@example.com",
Chart: "skr03",
AdminLogin: "admin",
AdminPassword: adminHash,
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])
}