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:
68
pkg/service/cron.go
Normal file
68
pkg/service/cron.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
95
pkg/service/migrate.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user