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>
This commit is contained in:
223
pkg/server/import.go
Normal file
223
pkg/server/import.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// handleImportCSV imports records from a CSV file into any model.
|
||||
// Accepts JSON body with: model, fields (mapping), csv_data (raw CSV string).
|
||||
// Mirrors: odoo/addons/base_import/controllers/main.py ImportController
|
||||
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var params struct {
|
||||
Model string `json:"model"`
|
||||
Fields []importFieldMap `json:"fields"`
|
||||
CSVData string `json:"csv_data"`
|
||||
HasHeader bool `json:"has_header"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
|
||||
return
|
||||
}
|
||||
|
||||
if params.Model == "" || len(params.Fields) == 0 || params.CSVData == "" {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "model, fields, and csv_data are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify model exists
|
||||
m := orm.Registry.Get(params.Model)
|
||||
if m == nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: fmt.Sprintf("Unknown model: %s", params.Model)})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse CSV
|
||||
reader := csv.NewReader(strings.NewReader(params.CSVData))
|
||||
reader.LazyQuotes = true
|
||||
reader.TrimLeadingSpace = true
|
||||
|
||||
var allRows [][]string
|
||||
for {
|
||||
row, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: fmt.Sprintf("CSV parse error: %v", err)})
|
||||
return
|
||||
}
|
||||
allRows = append(allRows, row)
|
||||
}
|
||||
|
||||
if len(allRows) == 0 {
|
||||
s.writeJSONRPC(w, req.ID, map[string]interface{}{"ids": []int64{}, "count": 0}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip header row if present
|
||||
dataRows := allRows
|
||||
if params.HasHeader && len(allRows) > 1 {
|
||||
dataRows = allRows[1:]
|
||||
}
|
||||
|
||||
// Build field mapping: CSV column index → ORM field name
|
||||
type colMapping struct {
|
||||
colIndex int
|
||||
fieldName string
|
||||
fieldType orm.FieldType
|
||||
}
|
||||
var mappings []colMapping
|
||||
for _, fm := range params.Fields {
|
||||
if fm.FieldName == "" || fm.ColumnIndex < 0 {
|
||||
continue
|
||||
}
|
||||
f := m.GetField(fm.FieldName)
|
||||
if f == nil {
|
||||
continue // skip unknown fields
|
||||
}
|
||||
mappings = append(mappings, colMapping{
|
||||
colIndex: fm.ColumnIndex,
|
||||
fieldName: fm.FieldName,
|
||||
fieldType: f.Type,
|
||||
})
|
||||
}
|
||||
|
||||
if len(mappings) == 0 {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "No valid field mappings"})
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: "Internal error"})
|
||||
return
|
||||
}
|
||||
defer env.Close()
|
||||
|
||||
rs := env.Model(params.Model)
|
||||
|
||||
var createdIDs []int64
|
||||
var errors []importError
|
||||
|
||||
for rowIdx, row := range dataRows {
|
||||
vals := make(orm.Values)
|
||||
for _, cm := range mappings {
|
||||
if cm.colIndex >= len(row) {
|
||||
continue
|
||||
}
|
||||
raw := strings.TrimSpace(row[cm.colIndex])
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
vals[cm.fieldName] = coerceImportValue(raw, cm.fieldType)
|
||||
}
|
||||
|
||||
if len(vals) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if params.DryRun {
|
||||
continue // validate only, don't create
|
||||
}
|
||||
|
||||
rec, err := rs.Create(vals)
|
||||
if err != nil {
|
||||
errors = append(errors, importError{
|
||||
Row: rowIdx + 1,
|
||||
Message: err.Error(),
|
||||
})
|
||||
log.Printf("import: row %d error: %v", rowIdx+1, err)
|
||||
continue
|
||||
}
|
||||
createdIDs = append(createdIDs, rec.ID())
|
||||
}
|
||||
|
||||
if err := env.Commit(); err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Commit error: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ids": createdIDs,
|
||||
"count": len(createdIDs),
|
||||
"errors": errors,
|
||||
"dry_run": params.DryRun,
|
||||
}
|
||||
s.writeJSONRPC(w, req.ID, result, nil)
|
||||
}
|
||||
|
||||
// importFieldMap maps a CSV column to an ORM field.
|
||||
type importFieldMap struct {
|
||||
ColumnIndex int `json:"column_index"`
|
||||
FieldName string `json:"field_name"`
|
||||
}
|
||||
|
||||
// importError describes a per-row import error.
|
||||
type importError struct {
|
||||
Row int `json:"row"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// coerceImportValue converts a raw CSV string to the appropriate Go type for ORM Create.
|
||||
func coerceImportValue(raw string, ft orm.FieldType) interface{} {
|
||||
switch ft {
|
||||
case orm.TypeInteger:
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
case orm.TypeFloat, orm.TypeMonetary:
|
||||
// Handle comma as decimal separator
|
||||
raw = strings.ReplaceAll(raw, ",", ".")
|
||||
v, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
case orm.TypeBoolean:
|
||||
lower := strings.ToLower(raw)
|
||||
return lower == "true" || lower == "1" || lower == "yes" || lower == "ja"
|
||||
case orm.TypeMany2one:
|
||||
// Try as integer ID first, then as name_search later
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return raw // pass as string, ORM may handle name_create
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user