Files
goodie/pkg/server/export.go
Marc 66383adf06 feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support
- Email Inbound: IMAP polling (go-imap/v2), thread matching
- Discuss: mail.channel, long-polling bus, DM, unread count
- Cron: ir.cron runner (goroutine scheduler)
- Bank Import, CSV/Excel Import
- Automation (ir.actions.server)
- Fetchmail service
- HR Payroll model
- Various fixes across account, sale, stock, purchase, crm, hr, project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:41:57 +02:00

202 lines
5.0 KiB
Go

package server
import (
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"github.com/xuri/excelize/v2"
"odoo-go/pkg/orm"
)
// exportField describes a field in an export request.
type exportField struct {
Name string `json:"name"`
Label string `json:"label"`
}
// exportData holds the parsed and fetched data for an export operation.
type exportData struct {
Model string
FieldNames []string
Headers []string
Records []orm.Values
}
// parseExportRequest parses the common request/params/env/search logic shared by CSV and XLSX export.
func (s *Server) parseExportRequest(w http.ResponseWriter, r *http.Request) (*exportData, error) {
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
return nil, err
}
var params struct {
Data struct {
Model string `json:"model"`
Fields []exportField `json:"fields"`
Domain []interface{} `json:"domain"`
IDs []float64 `json:"ids"`
} `json:"data"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
return nil, err
}
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
}
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool, UID: uid, CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return nil, err
}
defer env.Close()
rs := env.Model(params.Data.Model)
var ids []int64
if len(params.Data.IDs) > 0 {
for _, id := range params.Data.IDs {
ids = append(ids, int64(id))
}
} else {
domain := parseDomain([]interface{}{params.Data.Domain})
found, err := rs.Search(domain, orm.SearchOpts{Limit: 10000})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, err
}
ids = found.IDs()
}
var fieldNames []string
var headers []string
for _, f := range params.Data.Fields {
fieldNames = append(fieldNames, f.Name)
label := f.Label
if label == "" {
label = f.Name
}
headers = append(headers, label)
}
var records []orm.Values
if len(ids) > 0 {
records, err = rs.Browse(ids...).Read(fieldNames)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, err
}
}
if err := env.Commit(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, err
}
return &exportData{
Model: params.Data.Model, FieldNames: fieldNames, Headers: headers, Records: records,
}, nil
}
// handleExportCSV exports records as CSV.
// Mirrors: odoo/addons/web/controllers/export.py ExportController
func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
data, err := s.parseExportRequest(w, r)
if err != nil {
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", data.Model))
writer := csv.NewWriter(w)
defer writer.Flush()
writer.Write(data.Headers)
for _, rec := range data.Records {
row := make([]string, len(data.FieldNames))
for i, fname := range data.FieldNames {
row[i] = formatCSVValue(rec[fname])
}
writer.Write(row)
}
}
// handleExportXLSX exports records as XLSX (Excel).
// Mirrors: odoo/addons/web/controllers/export.py ExportXlsxController
func (s *Server) handleExportXLSX(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
data, err := s.parseExportRequest(w, r)
if err != nil {
return
}
f := excelize.NewFile()
sheet := "Sheet1"
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true},
})
for i, h := range data.Headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
f.SetCellValue(sheet, cell, h)
f.SetCellStyle(sheet, cell, cell, headerStyle)
}
for rowIdx, rec := range data.Records {
for colIdx, fname := range data.FieldNames {
cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
f.SetCellValue(sheet, cell, formatCSVValue(rec[fname]))
}
}
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.xlsx", data.Model))
f.Write(w)
}
// formatCSVValue converts a field value to a CSV string.
func formatCSVValue(v interface{}) string {
if v == nil || v == false {
return ""
}
switch val := v.(type) {
case string:
return val
case bool:
if val {
return "True"
}
return "False"
case []interface{}:
// M2O: [id, "name"] → "name"
if len(val) == 2 {
if name, ok := val[1].(string); ok {
return name
}
}
return fmt.Sprintf("%v", val)
default:
return fmt.Sprintf("%v", val)
}
}