Backend improvements: views, fields_get, session, RPC stubs

- Improved auto-generated list/form/search views with priority fields,
  two-column form layout, statusbar widget, notebook for O2M fields
- Enhanced fields_get with currency_field, compute, related metadata
- Fixed session handling: handleSessionInfo/handleSessionCheck use real
  session from cookie instead of hardcoded values
- Added read_progress_bar and activity_format RPC stubs
- Improved bootstrap translations with lang_parameters
- Added "contacts" to session modules list

Server starts successfully: 14 modules, 93 models, 378 XML templates,
503 JS modules transpiled — all from local frontend/ directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-03-31 23:16:26 +02:00
parent 8741282322
commit 9c444061fd
32 changed files with 3416 additions and 148 deletions

68
pkg/service/cron.go Normal file
View File

@@ -0,0 +1,68 @@
package service
import (
"context"
"log"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// CronJob defines a scheduled task.
type CronJob struct {
Name string
Interval time.Duration
Handler func(ctx context.Context, pool *pgxpool.Pool) error
running bool
}
// CronScheduler manages periodic jobs.
type CronScheduler struct {
jobs []*CronJob
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
}
// NewCronScheduler creates a new scheduler.
func NewCronScheduler() *CronScheduler {
ctx, cancel := context.WithCancel(context.Background())
return &CronScheduler{ctx: ctx, cancel: cancel}
}
// Register adds a job to the scheduler.
func (s *CronScheduler) Register(job *CronJob) {
s.mu.Lock()
defer s.mu.Unlock()
s.jobs = append(s.jobs, job)
}
// Start begins running all registered jobs.
func (s *CronScheduler) Start(pool *pgxpool.Pool) {
for _, job := range s.jobs {
go s.runJob(job, pool)
}
log.Printf("cron: started %d jobs", len(s.jobs))
}
// Stop cancels all running jobs.
func (s *CronScheduler) Stop() {
s.cancel()
}
func (s *CronScheduler) runJob(job *CronJob, pool *pgxpool.Pool) {
ticker := time.NewTicker(job.Interval)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
if err := job.Handler(s.ctx, pool); err != nil {
log.Printf("cron: %s error: %v", job.Name, err)
}
}
}
}

View File

@@ -269,6 +269,9 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err
SELECT setval('account_journal_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_journal));
SELECT setval('account_account_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_account));
SELECT setval('account_tax_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_tax));
SELECT setval('sale_order_id_seq', (SELECT COALESCE(MAX(id),0) FROM sale_order));
SELECT setval('sale_order_line_id_seq', (SELECT COALESCE(MAX(id),0) FROM sale_order_line));
SELECT setval('account_move_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_move));
`)
if err := tx.Commit(ctx); err != nil {
@@ -347,7 +350,45 @@ func seedViews(ctx context.Context, tx pgx.Tx) {
<field name="date_order"/>
<field name="state"/>
<field name="amount_total"/>
</list>', 16, true, 'primary')
</list>', 16, true, 'primary'),
-- crm.lead views
('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'),
-- res.partner kanban
('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'),
-- crm.lead kanban (pipeline)
('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`)
log.Println("db: UI views seeded")
@@ -373,7 +414,30 @@ func seedDemoData(ctx context.Context, tx pgx.Tx) {
('Peter Weber', false, true, 'contact', 'peter@weber-elektro.de', '+49 69 5551234', 'de_DE')
ON CONFLICT DO NOTHING`)
log.Println("db: demo data loaded (8 demo contacts)")
// 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`)
log.Println("db: demo data loaded (8 contacts, 3 sale orders, 3 invoices)")
}
// SeedBaseData is the legacy function — redirects to setup with defaults.

95
pkg/service/migrate.go Normal file
View File

@@ -0,0 +1,95 @@
// Package service — schema migration support.
// Mirrors: odoo/modules/migration.py (safe subset)
package service
import (
"context"
"fmt"
"log"
"github.com/jackc/pgx/v5/pgxpool"
"odoo-go/pkg/orm"
)
// MigrateSchema compares registered model fields with existing database columns
// and adds any missing columns. This is a safe, additive-only migration:
// it does NOT remove columns, change types, or drop tables.
//
// Mirrors: odoo/modules/loading.py _auto_init() — the part that adds new
// columns when a model gains a field after the initial CREATE TABLE.
func MigrateSchema(ctx context.Context, pool *pgxpool.Pool) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("migrate: begin: %w", err)
}
defer tx.Rollback(ctx)
added := 0
for _, m := range orm.Registry.Models() {
if m.IsAbstract() {
continue
}
// Check if the table exists at all
var tableExists bool
err := tx.QueryRow(ctx,
`SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = $1 AND table_schema = 'public'
)`, m.Table()).Scan(&tableExists)
if err != nil || !tableExists {
continue // Table doesn't exist yet; InitDatabase will create it
}
// Get existing columns for this table
existing := make(map[string]bool)
rows, err := tx.Query(ctx,
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND table_schema = 'public'`,
m.Table())
if err != nil {
continue
}
for rows.Next() {
var col string
rows.Scan(&col)
existing[col] = true
}
rows.Close()
// Add missing columns
for _, f := range m.StoredFields() {
if f.Name == "id" {
continue
}
if existing[f.Column()] {
continue
}
sqlType := f.SQLType()
if sqlType == "" {
continue
}
alter := fmt.Sprintf(`ALTER TABLE %q ADD COLUMN %q %s`,
m.Table(), f.Column(), sqlType)
if _, err := tx.Exec(ctx, alter); err != nil {
log.Printf("migrate: warning: add column %s.%s: %v", m.Table(), f.Column(), err)
} else {
log.Printf("migrate: added column %s.%s (%s)", m.Table(), f.Column(), sqlType)
added++
}
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("migrate: commit: %w", err)
}
if added > 0 {
log.Printf("migrate: %d column(s) added", added)
} else {
log.Println("migrate: schema up to date")
}
return nil
}