PDF reports + View inheritance + Pivot/Graph views
PDF Reports: - /report/pdf/ endpoint with graceful degradation chain: wkhtmltopdf → headless Chrome/Chromium → HTML with window.print() - Print-friendly CSS (@page A4, margins, page-break rules) - ir.actions.report dispatch for client Print button View Inheritance: - loadViewArch now loads base + inheriting views (inherit_id) - applyViewInheritance with XPath support: //field[@name='X'] and <field name="X" position="..."> - Positions: after, before, replace, inside - XML parser for inherit directives using encoding/xml New View Types: - Pivot view auto-generation (numeric fields as measures) - Graph view auto-generation (M2O/Selection + numeric measure) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,10 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
@@ -134,15 +136,42 @@ func handleGetViews(env *orm.Environment, model string, params CallKWParams) (in
|
||||
}
|
||||
|
||||
// loadViewArch tries to load a view from the ir_ui_view table.
|
||||
// After loading the base view, it queries for inheriting views and merges them.
|
||||
// Mirrors: odoo/addons/base/models/ir_ui_view.py _get_combined_arch()
|
||||
func loadViewArch(env *orm.Environment, model, viewType string) string {
|
||||
// Load base view (no parent — inherit_id IS NULL)
|
||||
var arch string
|
||||
var viewID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT arch FROM ir_ui_view WHERE model = $1 AND type = $2 AND active = true ORDER BY priority LIMIT 1`,
|
||||
`SELECT id, arch FROM ir_ui_view
|
||||
WHERE model = $1 AND type = $2 AND active = true AND inherit_id IS NULL
|
||||
ORDER BY priority LIMIT 1`,
|
||||
model, viewType,
|
||||
).Scan(&arch)
|
||||
).Scan(&viewID, &arch)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Load inheriting views and apply them in priority order
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT arch FROM ir_ui_view
|
||||
WHERE inherit_id = $1 AND active = true
|
||||
ORDER BY priority`,
|
||||
viewID,
|
||||
)
|
||||
if err != nil {
|
||||
return arch
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var inheritArch string
|
||||
if err := rows.Scan(&inheritArch); err != nil {
|
||||
continue
|
||||
}
|
||||
arch = applyViewInheritance(arch, inheritArch)
|
||||
}
|
||||
|
||||
return arch
|
||||
}
|
||||
|
||||
@@ -162,6 +191,10 @@ func generateDefaultView(modelName, viewType string) string {
|
||||
return generateDefaultSearchView(m)
|
||||
case "kanban":
|
||||
return generateDefaultKanbanView(m)
|
||||
case "pivot":
|
||||
return generateDefaultPivotView(m)
|
||||
case "graph":
|
||||
return generateDefaultGraphView(m)
|
||||
default:
|
||||
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
|
||||
}
|
||||
@@ -442,3 +475,418 @@ func generateDefaultKanbanView(m *orm.Model) string {
|
||||
</templates>
|
||||
</kanban>`, strings.Join(cardFields, "\n"))
|
||||
}
|
||||
|
||||
// generateDefaultPivotView creates a minimal pivot view with numeric measure fields.
|
||||
// Mirrors: odoo/addons/web/models/ir_ui_view.py default_view()
|
||||
func generateDefaultPivotView(m *orm.Model) string {
|
||||
var measures []string
|
||||
for _, name := range sortedFieldNames(m) {
|
||||
f := m.GetField(name)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
if (f.Type == orm.TypeFloat || f.Type == orm.TypeInteger || f.Type == orm.TypeMonetary) && f.IsStored() && f.Name != "id" {
|
||||
measures = append(measures, fmt.Sprintf(`<field name="%s" type="measure"/>`, f.Name))
|
||||
if len(measures) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(measures) == 0 {
|
||||
measures = append(measures, `<field name="id" type="measure"/>`)
|
||||
}
|
||||
return fmt.Sprintf("<pivot>\n %s\n</pivot>", strings.Join(measures, "\n "))
|
||||
}
|
||||
|
||||
// generateDefaultGraphView creates a minimal graph view with a dimension and a measure.
|
||||
// Mirrors: odoo/addons/web/models/ir_ui_view.py default_view()
|
||||
func generateDefaultGraphView(m *orm.Model) string {
|
||||
var fields []string
|
||||
// First groupable field as dimension
|
||||
for _, name := range sortedFieldNames(m) {
|
||||
f := m.GetField(name)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
if f.IsStored() && (f.Type == orm.TypeMany2one || f.Type == orm.TypeSelection) {
|
||||
fields = append(fields, fmt.Sprintf(`<field name="%s"/>`, f.Name))
|
||||
break
|
||||
}
|
||||
}
|
||||
// First numeric field as measure
|
||||
for _, name := range sortedFieldNames(m) {
|
||||
f := m.GetField(name)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
if (f.Type == orm.TypeFloat || f.Type == orm.TypeInteger || f.Type == orm.TypeMonetary) && f.IsStored() && f.Name != "id" {
|
||||
fields = append(fields, fmt.Sprintf(`<field name="%s" type="measure"/>`, f.Name))
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
fields = append(fields, `<field name="id" type="measure"/>`)
|
||||
}
|
||||
return fmt.Sprintf("<graph>\n %s\n</graph>", strings.Join(fields, "\n "))
|
||||
}
|
||||
|
||||
// sortedFieldNames returns field names in alphabetical order for deterministic output.
|
||||
func sortedFieldNames(m *orm.Model) []string {
|
||||
fields := m.Fields()
|
||||
names := make([]string, 0, len(fields))
|
||||
for name := range fields {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// --- View Inheritance ---
|
||||
|
||||
// applyViewInheritance merges an inheriting view's arch into a base view.
|
||||
// Supports:
|
||||
// - <xpath expr="//field[@name='X']" position="after|before|replace|inside">
|
||||
// - <field name="X" position="after|before|replace|inside"> (shorthand)
|
||||
//
|
||||
// Mirrors (simplified): odoo/addons/base/models/ir_ui_view.py apply_inheritance_specs()
|
||||
func applyViewInheritance(baseArch, inheritArch string) string {
|
||||
directives := parseInheritDirectives(inheritArch)
|
||||
if len(directives) == 0 {
|
||||
return baseArch
|
||||
}
|
||||
|
||||
for _, d := range directives {
|
||||
baseArch = applyDirective(baseArch, d)
|
||||
}
|
||||
return baseArch
|
||||
}
|
||||
|
||||
// inheritDirective represents a single modification instruction from an inheriting view.
|
||||
type inheritDirective struct {
|
||||
// target identifies what to match: tag name + attribute match
|
||||
targetTag string // e.g., "field", "group", "page", "notebook", etc.
|
||||
targetAttr string // attribute name to match, e.g., "name"
|
||||
targetVal string // attribute value to match, e.g., "partner_id"
|
||||
position string // "after", "before", "replace", "inside", "attributes"
|
||||
content string // inner XML content to insert/replace
|
||||
}
|
||||
|
||||
// parseInheritDirectives extracts modification directives from an inheriting view arch.
|
||||
func parseInheritDirectives(inheritArch string) []inheritDirective {
|
||||
// Wrap in a root element so the XML decoder can parse fragments
|
||||
wrapped := "<_root>" + strings.TrimSpace(inheritArch) + "</_root>"
|
||||
|
||||
decoder := xml.NewDecoder(strings.NewReader(wrapped))
|
||||
var directives []inheritDirective
|
||||
|
||||
// We need to find the top-level children of _root.
|
||||
// Skip the outer <data> or root element if present.
|
||||
// The actual directives are either <xpath> or <field> etc. with position attr.
|
||||
|
||||
type element struct {
|
||||
name xml.Name
|
||||
attrs []xml.Attr
|
||||
}
|
||||
|
||||
depth := 0
|
||||
var stack []element
|
||||
var contentBuilder strings.Builder
|
||||
captureDepth := -1
|
||||
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
|
||||
// If we're capturing content for a directive, write this element
|
||||
if captureDepth >= 0 && depth > captureDepth+1 {
|
||||
contentBuilder.WriteString(startElementToString(t))
|
||||
}
|
||||
|
||||
stack = append(stack, element{name: t.Name, attrs: t.Attr})
|
||||
|
||||
// Skip the wrapper _root and optional <data> root element
|
||||
if depth == 1 && t.Name.Local == "_root" {
|
||||
continue
|
||||
}
|
||||
if depth == 2 && t.Name.Local == "data" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a directive element (xpath or element with position)
|
||||
pos := attrVal(t.Attr, "position")
|
||||
if pos != "" && captureDepth < 0 {
|
||||
d := inheritDirective{position: pos}
|
||||
|
||||
if t.Name.Local == "xpath" {
|
||||
// Parse expr attribute: //field[@name='partner_id']
|
||||
expr := attrVal(t.Attr, "expr")
|
||||
d.targetTag, d.targetAttr, d.targetVal = parseXPathExpr(expr)
|
||||
} else {
|
||||
// Shorthand: <field name="X" position="after">
|
||||
d.targetTag = t.Name.Local
|
||||
d.targetAttr = "name"
|
||||
d.targetVal = attrVal(t.Attr, "name")
|
||||
}
|
||||
|
||||
if d.targetTag != "" {
|
||||
directives = append(directives, d)
|
||||
captureDepth = depth
|
||||
contentBuilder.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
case xml.EndElement:
|
||||
if captureDepth >= 0 {
|
||||
if depth == captureDepth {
|
||||
// We've closed the directive element, store captured content
|
||||
if len(directives) > 0 {
|
||||
directives[len(directives)-1].content = strings.TrimSpace(contentBuilder.String())
|
||||
}
|
||||
captureDepth = -1
|
||||
contentBuilder.Reset()
|
||||
} else if depth > captureDepth {
|
||||
contentBuilder.WriteString(fmt.Sprintf("</%s>", t.Name.Local))
|
||||
}
|
||||
}
|
||||
|
||||
depth--
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
|
||||
case xml.CharData:
|
||||
if captureDepth >= 0 && depth > captureDepth {
|
||||
contentBuilder.Write(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return directives
|
||||
}
|
||||
|
||||
// startElementToString serializes an xml.StartElement back to string form.
|
||||
func startElementToString(el xml.StartElement) string {
|
||||
var buf strings.Builder
|
||||
buf.WriteString("<")
|
||||
buf.WriteString(el.Name.Local)
|
||||
for _, a := range el.Attr {
|
||||
buf.WriteString(fmt.Sprintf(` %s="%s"`, a.Name.Local, a.Value))
|
||||
}
|
||||
buf.WriteString(">")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// attrVal returns the value of a named attribute, or "".
|
||||
func attrVal(attrs []xml.Attr, name string) string {
|
||||
for _, a := range attrs {
|
||||
if a.Name.Local == name {
|
||||
return a.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseXPathExpr parses a simple XPath expression like //field[@name='partner_id'].
|
||||
// Returns (tag, attrName, attrValue). Only supports the //tag[@attr='val'] pattern.
|
||||
func parseXPathExpr(expr string) (string, string, string) {
|
||||
// Pattern: //tag[@attr='val'] or //tag[@attr="val"]
|
||||
expr = strings.TrimSpace(expr)
|
||||
|
||||
// Strip leading //
|
||||
expr = strings.TrimPrefix(expr, "//")
|
||||
|
||||
// Find tag[
|
||||
bracketIdx := strings.Index(expr, "[")
|
||||
if bracketIdx < 0 {
|
||||
// Simple case: //tag — match by tag name alone
|
||||
tag := strings.TrimSpace(expr)
|
||||
if tag != "" {
|
||||
return tag, "", ""
|
||||
}
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
tag := expr[:bracketIdx]
|
||||
|
||||
// Extract @attr='val'
|
||||
inner := expr[bracketIdx+1:]
|
||||
inner = strings.TrimSuffix(inner, "]")
|
||||
inner = strings.TrimPrefix(inner, "@")
|
||||
|
||||
eqIdx := strings.Index(inner, "=")
|
||||
if eqIdx < 0 {
|
||||
return tag, "", ""
|
||||
}
|
||||
|
||||
attrName := inner[:eqIdx]
|
||||
attrValue := inner[eqIdx+1:]
|
||||
// Remove quotes
|
||||
attrValue = strings.Trim(attrValue, "'\"")
|
||||
|
||||
return tag, attrName, attrValue
|
||||
}
|
||||
|
||||
// applyDirective applies a single inheritance directive to the base arch.
|
||||
func applyDirective(baseArch string, d inheritDirective) string {
|
||||
// Find the target element in baseArch
|
||||
targetStart, targetEnd := findElement(baseArch, d.targetTag, d.targetAttr, d.targetVal)
|
||||
if targetStart < 0 {
|
||||
return baseArch
|
||||
}
|
||||
|
||||
switch d.position {
|
||||
case "after":
|
||||
// Insert content after the target element
|
||||
return baseArch[:targetEnd] + "\n" + d.content + baseArch[targetEnd:]
|
||||
|
||||
case "before":
|
||||
// Insert content before the target element
|
||||
return baseArch[:targetStart] + d.content + "\n" + baseArch[targetStart:]
|
||||
|
||||
case "replace":
|
||||
// Replace the target element with content
|
||||
if d.content == "" {
|
||||
// Empty replace = remove the element
|
||||
return baseArch[:targetStart] + baseArch[targetEnd:]
|
||||
}
|
||||
return baseArch[:targetStart] + d.content + baseArch[targetEnd:]
|
||||
|
||||
case "inside":
|
||||
// Insert content inside the target element (before closing tag)
|
||||
// Need to find the closing tag of the target
|
||||
closingTag := fmt.Sprintf("</%s>", d.targetTag)
|
||||
closeIdx := strings.LastIndex(baseArch[targetStart:targetEnd], closingTag)
|
||||
if closeIdx >= 0 {
|
||||
insertPos := targetStart + closeIdx
|
||||
return baseArch[:insertPos] + "\n" + d.content + "\n" + baseArch[insertPos:]
|
||||
}
|
||||
// Self-closing element — convert to open/close with content
|
||||
selfClose := strings.LastIndex(baseArch[targetStart:targetEnd], "/>")
|
||||
if selfClose >= 0 {
|
||||
insertPos := targetStart + selfClose
|
||||
return baseArch[:insertPos] + ">\n" + d.content + "\n" + fmt.Sprintf("</%s>", d.targetTag) + baseArch[insertPos+2:]
|
||||
}
|
||||
return baseArch
|
||||
|
||||
case "attributes":
|
||||
// Modify attributes of the target element — not implemented yet
|
||||
return baseArch
|
||||
|
||||
default:
|
||||
return baseArch
|
||||
}
|
||||
}
|
||||
|
||||
// findElement finds a target element in the XML string by tag name and optional attribute match.
|
||||
// Returns (startIndex, endIndex) of the full element (including closing tag if present).
|
||||
// Returns (-1, -1) if not found.
|
||||
func findElement(xmlStr, tag, attrName, attrVal string) (int, int) {
|
||||
searchFrom := 0
|
||||
for searchFrom < len(xmlStr) {
|
||||
// Find next occurrence of <tag
|
||||
tagOpen := "<" + tag
|
||||
idx := strings.Index(xmlStr[searchFrom:], tagOpen)
|
||||
if idx < 0 {
|
||||
return -1, -1
|
||||
}
|
||||
idx += searchFrom
|
||||
|
||||
// Ensure this is actually a tag start (not e.g. <fields inside <fieldset)
|
||||
afterTag := idx + len(tagOpen)
|
||||
if afterTag < len(xmlStr) {
|
||||
ch := xmlStr[afterTag]
|
||||
if ch != ' ' && ch != '/' && ch != '>' && ch != '\n' && ch != '\t' && ch != '\r' {
|
||||
searchFrom = afterTag
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Find end of opening tag
|
||||
openEnd := strings.Index(xmlStr[idx:], ">")
|
||||
if openEnd < 0 {
|
||||
return -1, -1
|
||||
}
|
||||
openEnd += idx
|
||||
|
||||
openingTag := xmlStr[idx : openEnd+1]
|
||||
|
||||
// Check attribute match if required
|
||||
if attrName != "" && attrVal != "" {
|
||||
if !matchAttribute(openingTag, attrName, attrVal) {
|
||||
searchFrom = openEnd + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check if self-closing
|
||||
if strings.HasSuffix(strings.TrimSpace(openingTag), "/>") {
|
||||
return idx, openEnd + 1
|
||||
}
|
||||
|
||||
// Find matching closing tag
|
||||
closeTag := fmt.Sprintf("</%s>", tag)
|
||||
// Simple approach: find the next closing tag at the same nesting level
|
||||
closeIdx := findMatchingClose(xmlStr[openEnd+1:], tag)
|
||||
if closeIdx < 0 {
|
||||
// No closing tag — treat as self-contained up to >
|
||||
return idx, openEnd + 1
|
||||
}
|
||||
endIdx := openEnd + 1 + closeIdx + len(closeTag)
|
||||
return idx, endIdx
|
||||
}
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// findMatchingClose finds the matching closing tag, handling nesting.
|
||||
// Returns the offset from the start of s where the closing tag begins,
|
||||
// or -1 if not found.
|
||||
func findMatchingClose(s, tag string) int {
|
||||
openTag := "<" + tag
|
||||
closeTag := "</" + tag + ">"
|
||||
depth := 1
|
||||
pos := 0
|
||||
|
||||
for pos < len(s) {
|
||||
nextOpen := strings.Index(s[pos:], openTag)
|
||||
nextClose := strings.Index(s[pos:], closeTag)
|
||||
|
||||
if nextClose < 0 {
|
||||
return -1
|
||||
}
|
||||
|
||||
// If there's a nested open before the close, increase depth
|
||||
if nextOpen >= 0 && nextOpen < nextClose {
|
||||
// Verify it's actually a tag start (not a prefix of another tag)
|
||||
afterIdx := pos + nextOpen + len(openTag)
|
||||
if afterIdx < len(s) {
|
||||
ch := s[afterIdx]
|
||||
if ch == ' ' || ch == '/' || ch == '>' || ch == '\n' || ch == '\t' || ch == '\r' {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
pos += nextOpen + len(openTag)
|
||||
continue
|
||||
}
|
||||
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return pos + nextClose
|
||||
}
|
||||
pos += nextClose + len(closeTag)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// matchAttribute checks if an opening tag string contains attr="val" or attr='val'.
|
||||
func matchAttribute(openingTag, attrName, attrValue string) bool {
|
||||
// Try both quote styles
|
||||
pattern1 := fmt.Sprintf(`%s="%s"`, attrName, attrValue)
|
||||
pattern2 := fmt.Sprintf(`%s='%s'`, attrName, attrValue)
|
||||
return strings.Contains(openingTag, pattern1) || strings.Contains(openingTag, pattern2)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user