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

@@ -60,6 +60,21 @@ func handleGetViews(env *orm.Environment, model string, params CallKWParams) (in
}
}
// Always include search view (client expects it)
if _, hasSearch := views["search"]; !hasSearch {
arch := loadViewArch(env, model, "search")
if arch == "" {
arch = generateDefaultView(model, "search")
}
views["search"] = map[string]interface{}{
"arch": arch,
"type": "search",
"model": model,
"view_id": 0,
"field_parent": false,
}
}
// Build models dict with field metadata
models := map[string]interface{}{
model: map[string]interface{}{
@@ -133,6 +148,7 @@ func generateDefaultListView(m *orm.Model) string {
if added[f.Name] || f.Name == "id" || !f.IsStored() ||
f.Name == "create_uid" || f.Name == "write_uid" ||
f.Name == "create_date" || f.Name == "write_date" ||
f.Name == "password" || f.Name == "password_crypt" ||
f.Type == orm.TypeBinary || f.Type == orm.TypeText || f.Type == orm.TypeHTML {
continue
}
@@ -147,13 +163,47 @@ func generateDefaultFormView(m *orm.Model) string {
skip := map[string]bool{
"id": true, "create_uid": true, "write_uid": true,
"create_date": true, "write_date": true,
"password": true, "password_crypt": true,
}
// Header with state widget if state field exists
// Header with action buttons and state widget
// Mirrors: odoo form views with <header><button .../><field name="state" widget="statusbar"/></header>
var header string
if f := m.GetField("state"); f != nil && f.Type == orm.TypeSelection {
header = ` <header>
<field name="state" widget="statusbar"/>
var buttons []string
// Generate buttons from registered methods that look like actions
if m.Methods != nil {
actionMethods := []struct{ method, label, stateFilter string }{
{"action_confirm", "Confirm", "draft"},
{"action_post", "Post", "draft"},
{"action_done", "Done", "confirmed"},
{"action_cancel", "Cancel", ""},
{"button_cancel", "Cancel", ""},
{"button_draft", "Reset to Draft", "cancel"},
{"action_send", "Send", "posted"},
{"create_invoices", "Create Invoice", "sale"},
}
for _, am := range actionMethods {
if _, ok := m.Methods[am.method]; ok {
attrs := ""
if am.stateFilter != "" {
attrs = fmt.Sprintf(` invisible="state != '%s'"`, am.stateFilter)
}
btnClass := "btn-secondary"
if am.method == "action_confirm" || am.method == "action_post" {
btnClass = "btn-primary"
}
buttons = append(buttons, fmt.Sprintf(
` <button name="%s" string="%s" type="object" class="%s"%s/>`,
am.method, am.label, btnClass, attrs))
}
}
}
header = " <header>\n"
for _, btn := range buttons {
header += btn + "\n"
}
header += ` <field name="state" widget="statusbar" clickable="1"/>
</header>
`
}
@@ -306,11 +356,44 @@ func generateDefaultKanbanView(m *orm.Model) string {
if f := m.GetField("name"); f == nil {
nameField = "id"
}
// Build a richer card with available fields
var cardFields []string
// Title
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s" class="fw-bold fs-5"/>`, nameField))
// Partner/customer
if f := m.GetField("partner_id"); f != nil {
cardFields = append(cardFields, ` <field name="partner_id"/>`)
}
// Revenue/amount
for _, amtField := range []string{"expected_revenue", "amount_total", "amount_untaxed"} {
if f := m.GetField(amtField); f != nil {
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s"/>`, amtField))
break
}
}
// Date
for _, dateField := range []string{"date_order", "date", "date_deadline"} {
if f := m.GetField(dateField); f != nil {
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s"/>`, dateField))
break
}
}
// User/assignee
if f := m.GetField("user_id"); f != nil {
cardFields = append(cardFields, ` <field name="user_id" widget="many2one_avatar_user"/>`)
}
return fmt.Sprintf(`<kanban>
<templates>
<t t-name="card">
<field name="%s"/>
%s
</t>
</templates>
</kanban>`, nameField)
</kanban>`, strings.Join(cardFields, "\n"))
}