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:
Marc
2026-04-12 18:41:57 +02:00
parent 2c7c1e6c88
commit 66383adf06
87 changed files with 14696 additions and 654 deletions

View File

@@ -3,6 +3,7 @@ package server
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
)
@@ -145,10 +146,12 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
// Look up xml_id from ir_model_data
xmlID := ""
_ = s.pool.QueryRow(ctx,
if err := s.pool.QueryRow(ctx,
`SELECT module || '.' || name FROM ir_model_data
WHERE model = 'ir.actions.act_window' AND res_id = $1
LIMIT 1`, id).Scan(&xmlID)
LIMIT 1`, id).Scan(&xmlID); err != nil {
log.Printf("warning: action xml_id lookup failed for id=%d: %v", id, err)
}
// Build views array from view_mode string (e.g. "list,kanban,form" → [[nil,"list"],[nil,"kanban"],[nil,"form"]])
views := buildViewsFromMode(viewMode)

292
pkg/server/bank_import.go Normal file
View File

@@ -0,0 +1,292 @@
package server
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"odoo-go/pkg/orm"
)
// handleBankStatementImport imports bank statement lines from CSV data.
// Accepts JSON body with: journal_id, csv_data, column_mapping, has_header.
// After import, optionally triggers auto-matching against open invoices.
// Mirrors: odoo/addons/account/wizard/account_bank_statement_import.py
func (s *Server) handleBankStatementImport(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 {
JournalID int64 `json:"journal_id"`
CSVData string `json:"csv_data"`
HasHeader bool `json:"has_header"`
ColumnMapping bankColumnMapping `json:"column_mapping"`
AutoMatch bool `json:"auto_match"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
return
}
if params.JournalID == 0 || params.CSVData == "" {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "journal_id and csv_data are required"})
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()
// Parse CSV
reader := csv.NewReader(strings.NewReader(params.CSVData))
reader.LazyQuotes = true
reader.TrimLeadingSpace = true
// Try semicolon separator (common in European bank exports)
reader.Comma = detectDelimiter(params.CSVData)
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)
}
dataRows := allRows
if params.HasHeader && len(allRows) > 1 {
dataRows = allRows[1:]
}
// Create a bank statement header
statementRS := env.Model("account.bank.statement")
stmt, err := statementRS.Create(orm.Values{
"name": fmt.Sprintf("Import %s", time.Now().Format("2006-01-02 15:04")),
"journal_id": params.JournalID,
"company_id": companyID,
"date": time.Now().Format("2006-01-02"),
})
if err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Create statement: %v", err)})
return
}
stmtID := stmt.ID()
// Default column mapping
cm := params.ColumnMapping
if cm.Date < 0 {
cm.Date = 0
}
if cm.Amount < 0 {
cm.Amount = 1
}
if cm.Label < 0 {
cm.Label = 2
}
// Import lines
lineRS := env.Model("account.bank.statement.line")
var importedIDs []int64
var errors []importError
for rowIdx, row := range dataRows {
// Parse date
dateStr := safeCol(row, cm.Date)
date := parseFlexDate(dateStr)
if date == "" {
date = time.Now().Format("2006-01-02")
}
// Parse amount
amountStr := safeCol(row, cm.Amount)
amount := parseAmount(amountStr)
if amount == 0 {
continue // skip zero-amount rows
}
// Parse label/reference
label := safeCol(row, cm.Label)
if label == "" {
label = fmt.Sprintf("Line %d", rowIdx+1)
}
// Parse optional columns
partnerName := safeCol(row, cm.PartnerName)
accountNumber := safeCol(row, cm.AccountNumber)
vals := orm.Values{
"statement_id": stmtID,
"journal_id": params.JournalID,
"company_id": companyID,
"date": date,
"amount": amount,
"payment_ref": label,
"partner_name": partnerName,
"account_number": accountNumber,
"sequence": rowIdx + 1,
}
rec, err := lineRS.Create(vals)
if err != nil {
errors = append(errors, importError{Row: rowIdx + 1, Message: err.Error()})
log.Printf("bank_import: row %d error: %v", rowIdx+1, err)
continue
}
importedIDs = append(importedIDs, rec.ID())
}
// Auto-match against open invoices
matchCount := 0
if params.AutoMatch && len(importedIDs) > 0 {
stLineModel := orm.Registry.Get("account.bank.statement.line")
if stLineModel != nil {
if matchMethod, ok := stLineModel.Methods["button_match"]; ok {
matchRS := env.Model("account.bank.statement.line").Browse(importedIDs...)
if _, err := matchMethod(matchRS); err != nil {
log.Printf("bank_import: auto-match error: %v", err)
}
}
}
// Count how many were matched
env.Tx().QueryRow(env.Ctx(),
`SELECT COUNT(*) FROM account_bank_statement_line WHERE id = ANY($1) AND is_reconciled = true`,
importedIDs).Scan(&matchCount)
}
if err := env.Commit(); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Commit: %v", err)})
return
}
s.writeJSONRPC(w, req.ID, map[string]interface{}{
"statement_id": stmtID,
"imported": len(importedIDs),
"matched": matchCount,
"errors": errors,
}, nil)
}
// bankColumnMapping maps CSV columns to bank statement fields.
type bankColumnMapping struct {
Date int `json:"date"` // column index for date
Amount int `json:"amount"` // column index for amount
Label int `json:"label"` // column index for label/reference
PartnerName int `json:"partner_name"` // column index for partner name (-1 = skip)
AccountNumber int `json:"account_number"` // column index for account number (-1 = skip)
}
// detectDelimiter guesses the CSV delimiter (comma, semicolon, or tab).
func detectDelimiter(data string) rune {
firstLine := data
if idx := strings.IndexByte(data, '\n'); idx > 0 {
firstLine = data[:idx]
}
semicolons := strings.Count(firstLine, ";")
commas := strings.Count(firstLine, ",")
tabs := strings.Count(firstLine, "\t")
if semicolons > commas && semicolons > tabs {
return ';'
}
if tabs > commas {
return '\t'
}
return ','
}
// safeCol returns the value at index i, or "" if out of bounds.
func safeCol(row []string, i int) string {
if i < 0 || i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
// parseFlexDate tries multiple date formats and returns YYYY-MM-DD.
func parseFlexDate(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
formats := []string{
"2006-01-02",
"02.01.2006", // DD.MM.YYYY (common in EU)
"01/02/2006", // MM/DD/YYYY
"02/01/2006", // DD/MM/YYYY
"2006/01/02",
"Jan 2, 2006",
"2 Jan 2006",
"02-01-2006",
"01-02-2006",
time.RFC3339,
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t.Format("2006-01-02")
}
}
return ""
}
// parseAmount parses a monetary amount string, handling comma/dot decimals and negative formats.
func parseAmount(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
// Remove currency symbols and whitespace
s = strings.NewReplacer("€", "", "$", "", "£", "", " ", "", "\u00a0", "").Replace(s)
// Handle European format: 1.234,56 → 1234.56
if strings.Contains(s, ",") && strings.Contains(s, ".") {
if strings.LastIndex(s, ",") > strings.LastIndex(s, ".") {
// comma is decimal: 1.234,56
s = strings.ReplaceAll(s, ".", "")
s = strings.ReplaceAll(s, ",", ".")
} else {
// dot is decimal: 1,234.56
s = strings.ReplaceAll(s, ",", "")
}
} else if strings.Contains(s, ",") {
// Only comma: assume decimal separator
s = strings.ReplaceAll(s, ",", ".")
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return v
}

241
pkg/server/bus.go Normal file
View File

@@ -0,0 +1,241 @@
package server
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)
// Bus implements a simple long-polling message bus for Discuss.
// Mirrors: odoo/addons/bus/models/bus.py ImBus
//
// Channels subscribe to notifications. A long-poll request blocks until
// a notification arrives or the timeout expires.
type Bus struct {
mu sync.Mutex
channels map[int64][]chan busNotification
lastID int64
}
type busNotification struct {
ID int64 `json:"id"`
Channel string `json:"channel"`
Message interface{} `json:"message"`
}
// NewBus creates a new message bus.
func NewBus() *Bus {
return &Bus{
channels: make(map[int64][]chan busNotification),
}
}
// Notify sends a notification to all subscribers of a channel.
func (b *Bus) Notify(channelID int64, channel string, message interface{}) {
b.mu.Lock()
b.lastID++
notif := busNotification{
ID: b.lastID,
Channel: channel,
Message: message,
}
subs := b.channels[channelID]
b.mu.Unlock()
for _, ch := range subs {
select {
case ch <- notif:
default:
// subscriber buffer full, skip
}
}
}
// Subscribe creates a subscription for a partner's channels.
func (b *Bus) Subscribe(partnerID int64) chan busNotification {
ch := make(chan busNotification, 10)
b.mu.Lock()
b.channels[partnerID] = append(b.channels[partnerID], ch)
b.mu.Unlock()
return ch
}
// Unsubscribe removes a subscription.
func (b *Bus) Unsubscribe(partnerID int64, ch chan busNotification) {
b.mu.Lock()
defer b.mu.Unlock()
subs := b.channels[partnerID]
for i, s := range subs {
if s == ch {
b.channels[partnerID] = append(subs[:i], subs[i+1:]...)
close(ch)
return
}
}
}
// registerBusRoutes adds the long-polling endpoint.
func (s *Server) registerBusRoutes() {
if s.bus == nil {
s.bus = NewBus()
}
s.mux.HandleFunc("/longpolling/poll", s.handleBusPoll)
s.mux.HandleFunc("/discuss/channel/messages", s.handleDiscussMessages)
s.mux.HandleFunc("/discuss/channel/list", s.handleDiscussChannelList)
}
// handleBusPoll implements long-polling for real-time notifications.
// Mirrors: odoo/addons/bus/controllers/main.py poll()
func (s *Server) handleBusPoll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
sess := GetSession(r)
if sess == nil {
writeJSON(w, []interface{}{})
return
}
// Get partner ID
var partnerID int64
s.pool.QueryRow(r.Context(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, sess.UID,
).Scan(&partnerID)
if partnerID == 0 {
writeJSON(w, []interface{}{})
return
}
// Subscribe and wait for notifications (max 30s)
ch := s.bus.Subscribe(partnerID)
defer s.bus.Unsubscribe(partnerID, ch)
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
select {
case notif := <-ch:
writeJSON(w, []busNotification{notif})
case <-ctx.Done():
writeJSON(w, []interface{}{}) // timeout, empty response
}
}
// handleDiscussMessages fetches messages for a channel via JSON-RPC.
func (s *Server) handleDiscussMessages(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
sess := GetSession(r)
if sess == nil {
s.writeJSONRPC(w, nil, nil, &RPCError{Code: 100, Message: "Not authenticated"})
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 {
ChannelID int64 `json:"channel_id"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
return
}
if params.Limit <= 0 {
params.Limit = 50
}
rows, err := s.pool.Query(r.Context(),
`SELECT m.id, m.body, m.date, m.author_id, COALESCE(p.name, '')
FROM mail_message m
LEFT JOIN res_partner p ON p.id = m.author_id
WHERE m.model = 'mail.channel' AND m.res_id = $1
ORDER BY m.id DESC LIMIT $2`, params.ChannelID, params.Limit)
if err != nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Query: %v", err)})
return
}
defer rows.Close()
var messages []map[string]interface{}
for rows.Next() {
var id, authorID int64
var body, authorName string
var date interface{}
if err := rows.Scan(&id, &body, &date, &authorID, &authorName); err != nil {
continue
}
msg := map[string]interface{}{
"id": id, "body": body, "date": date,
}
if authorID > 0 {
msg["author_id"] = []interface{}{authorID, authorName}
} else {
msg["author_id"] = false
}
messages = append(messages, msg)
}
if messages == nil {
messages = []map[string]interface{}{}
}
s.writeJSONRPC(w, req.ID, messages, nil)
}
// handleDiscussChannelList returns channels the current user is member of.
func (s *Server) handleDiscussChannelList(w http.ResponseWriter, r *http.Request) {
sess := GetSession(r)
if sess == nil {
s.writeJSONRPC(w, nil, nil, &RPCError{Code: 100, Message: "Not authenticated"})
return
}
var partnerID int64
s.pool.QueryRow(r.Context(),
`SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, sess.UID,
).Scan(&partnerID)
rows, err := s.pool.Query(r.Context(),
`SELECT c.id, c.name, c.channel_type,
(SELECT COUNT(*) FROM mail_channel_member WHERE channel_id = c.id) AS members
FROM mail_channel c
JOIN mail_channel_member cm ON cm.channel_id = c.id AND cm.partner_id = $1
WHERE c.active = true
ORDER BY c.last_message_date DESC NULLS LAST`, partnerID)
if err != nil {
log.Printf("discuss: channel list error: %v", err)
writeJSON(w, []interface{}{})
return
}
defer rows.Close()
var channels []map[string]interface{}
for rows.Next() {
var id int64
var name, channelType string
var members int64
if err := rows.Scan(&id, &name, &channelType, &members); err != nil {
continue
}
channels = append(channels, map[string]interface{}{
"id": id, "name": name, "channel_type": channelType, "member_count": members,
})
}
if channels == nil {
channels = []map[string]interface{}{}
}
writeJSON(w, channels)
}

View File

@@ -6,37 +6,45 @@ import (
"fmt"
"net/http"
"github.com/xuri/excelize/v2"
"odoo-go/pkg/orm"
)
// 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
}
// 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
return nil, err
}
var params struct {
Data struct {
Model string `json:"model"`
Fields []exportField `json:"fields"`
Domain []interface{} `json:"domain"`
IDs []float64 `json:"ids"`
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
return nil, err
}
// Extract UID from session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
@@ -45,42 +53,31 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
}
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
Pool: s.pool,
UID: uid,
CompanyID: companyID,
Pool: s.pool, UID: uid, CompanyID: companyID,
})
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
return nil, err
}
defer env.Close()
rs := env.Model(params.Data.Model)
// Determine which record IDs to export
var ids []int64
if len(params.Data.IDs) > 0 {
for _, id := range params.Data.IDs {
ids = append(ids, int64(id))
}
} else {
// Search with domain
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
return nil, err
}
ids = found.IDs()
}
if len(ids) == 0 {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", params.Data.Model))
return
}
// Extract field names
var fieldNames []string
var headers []string
for _, f := range params.Data.Fields {
@@ -92,42 +89,89 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
headers = append(headers, label)
}
// Read records
records, err := rs.Browse(ids...).Read(fieldNames)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
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
}
// Write CSV
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", params.Data.Model))
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", data.Model))
writer := csv.NewWriter(w)
defer writer.Flush()
// Header row
writer.Write(headers)
// Data rows
for _, rec := range records {
row := make([]string, len(fieldNames))
for i, fname := range fieldNames {
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)
}
}
// exportField describes a field in an export request.
type exportField struct {
Name string `json:"name"`
Label string `json:"label"`
// 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.

View File

@@ -2,6 +2,7 @@ package server
import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
@@ -55,9 +56,11 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
table := m.Table()
var data []byte
ctx := r.Context()
_ = s.pool.QueryRow(ctx,
if err := s.pool.QueryRow(ctx,
fmt.Sprintf(`SELECT "%s" FROM "%s" WHERE id = $1`, f.Column(), table), id,
).Scan(&data)
).Scan(&data); err != nil {
log.Printf("warning: image query failed for %s.%s id=%d: %v", model, field, id, err)
}
if len(data) > 0 {
// Detect content type
contentType := http.DetectContentType(data)
@@ -76,9 +79,11 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
m := orm.Registry.Get(model)
if m != nil {
var name string
_ = s.pool.QueryRow(r.Context(),
if err := s.pool.QueryRow(r.Context(),
fmt.Sprintf(`SELECT COALESCE(name, '') FROM "%s" WHERE id = $1`, m.Table()), id,
).Scan(&name)
).Scan(&name); err != nil {
log.Printf("warning: image name lookup failed for %s id=%d: %v", model, id, err)
}
if len(name) > 0 {
initial = strings.ToUpper(name[:1])
}

223
pkg/server/import.go Normal file
View 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, &params); 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
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"log"
"net/http"
"path/filepath"
"strings"
"time"
)
@@ -43,13 +44,19 @@ func (w *statusWriter) WriteHeader(code int) {
func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Public endpoints (no auth required)
path := r.URL.Path
path := filepath.Clean(r.URL.Path)
if path == "/health" ||
path == "/web/login" ||
path == "/web/session/authenticate" ||
path == "/web/session/logout" ||
strings.HasPrefix(path, "/web/database/") ||
path == "/web/database/manager" ||
path == "/web/database/create" ||
path == "/web/database/list" ||
path == "/web/webclient/version_info" ||
path == "/web/setup/wizard" ||
path == "/web/setup/wizard/save" ||
path == "/web/portal/signup" ||
path == "/web/portal/reset_password" ||
strings.Contains(path, "/static/") {
next.ServeHTTP(w, r)
return
@@ -58,8 +65,14 @@ func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
// Check session cookie
cookie, err := r.Cookie("session_id")
if err != nil || cookie.Value == "" {
// Also check JSON-RPC params for session_id (Odoo sends it both ways)
next.ServeHTTP(w, r) // For now, allow through — UID defaults to 1
// No session cookie — reject protected endpoints
if r.Header.Get("Content-Type") == "application/json" ||
strings.HasPrefix(path, "/web/dataset/") ||
strings.HasPrefix(path, "/jsonrpc") {
http.Error(w, `{"jsonrpc":"2.0","error":{"code":100,"message":"Session expired"}}`, http.StatusUnauthorized)
} else {
http.Redirect(w, r, "/web/login", http.StatusFound)
}
return
}

379
pkg/server/portal.go Normal file
View File

@@ -0,0 +1,379 @@
// Package server — Portal controllers for external (customer/supplier) access.
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal
package server
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
// registerPortalRoutes registers all /my/* portal endpoints.
func (s *Server) registerPortalRoutes() {
s.mux.HandleFunc("/my", s.handlePortalHome)
s.mux.HandleFunc("/my/", s.handlePortalDispatch)
s.mux.HandleFunc("/my/home", s.handlePortalHome)
s.mux.HandleFunc("/my/invoices", s.handlePortalInvoices)
s.mux.HandleFunc("/my/orders", s.handlePortalOrders)
s.mux.HandleFunc("/my/pickings", s.handlePortalPickings)
s.mux.HandleFunc("/my/account", s.handlePortalAccount)
}
// handlePortalDispatch routes /my/* sub-paths to the correct handler.
func (s *Server) handlePortalDispatch(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/my/home":
s.handlePortalHome(w, r)
case "/my/invoices":
s.handlePortalInvoices(w, r)
case "/my/orders":
s.handlePortalOrders(w, r)
case "/my/pickings":
s.handlePortalPickings(w, r)
case "/my/account":
s.handlePortalAccount(w, r)
default:
s.handlePortalHome(w, r)
}
}
// portalPartnerID resolves the partner_id of the currently logged-in portal user.
// Returns (partnerID, error). If session is missing, writes an error response and returns 0.
func (s *Server) portalPartnerID(w http.ResponseWriter, r *http.Request) (int64, bool) {
sess := GetSession(r)
if sess == nil {
writePortalError(w, http.StatusUnauthorized, "Not authenticated")
return 0, false
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
var partnerID int64
err := s.pool.QueryRow(ctx,
`SELECT partner_id FROM res_users WHERE id = $1 AND active = true`,
sess.UID).Scan(&partnerID)
if err != nil {
log.Printf("portal: cannot resolve partner_id for uid=%d: %v", sess.UID, err)
writePortalError(w, http.StatusForbidden, "User not found")
return 0, false
}
return partnerID, true
}
// handlePortalHome returns the portal dashboard with document counts.
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.home()
func (s *Server) handlePortalHome(w http.ResponseWriter, r *http.Request) {
partnerID, ok := s.portalPartnerID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
var invoiceCount, orderCount, pickingCount int64
// Count invoices (account.move with move_type in ('out_invoice','out_refund'))
err := s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM account_move
WHERE partner_id = $1 AND move_type IN ('out_invoice','out_refund')
AND state = 'posted'`, partnerID).Scan(&invoiceCount)
if err != nil {
log.Printf("portal: invoice count error: %v", err)
}
// Count sale orders (confirmed or done)
err = s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM sale_order
WHERE partner_id = $1 AND state IN ('sale','done')`, partnerID).Scan(&orderCount)
if err != nil {
log.Printf("portal: order count error: %v", err)
}
// Count pickings (stock.picking)
err = s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM stock_picking
WHERE partner_id = $1 AND state != 'cancel'`, partnerID).Scan(&pickingCount)
if err != nil {
log.Printf("portal: picking count error: %v", err)
}
writePortalJSON(w, map[string]interface{}{
"counters": map[string]int64{
"invoice_count": invoiceCount,
"order_count": orderCount,
"picking_count": pickingCount,
},
})
}
// handlePortalInvoices lists invoices for the current portal user.
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_invoices()
func (s *Server) handlePortalInvoices(w http.ResponseWriter, r *http.Request) {
partnerID, ok := s.portalPartnerID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
rows, err := s.pool.Query(ctx,
`SELECT m.id, m.name, m.move_type, m.state, m.date,
m.amount_total::float8, m.amount_residual::float8,
m.payment_state, COALESCE(m.ref, '')
FROM account_move m
WHERE m.partner_id = $1
AND m.move_type IN ('out_invoice','out_refund')
AND m.state = 'posted'
ORDER BY m.date DESC
LIMIT 80`, partnerID)
if err != nil {
log.Printf("portal: invoice query error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Failed to load invoices")
return
}
defer rows.Close()
var invoices []map[string]interface{}
for rows.Next() {
var id int64
var name, moveType, state, paymentState, ref string
var date time.Time
var amountTotal, amountResidual float64
if err := rows.Scan(&id, &name, &moveType, &state, &date,
&amountTotal, &amountResidual, &paymentState, &ref); err != nil {
log.Printf("portal: invoice scan error: %v", err)
continue
}
invoices = append(invoices, map[string]interface{}{
"id": id,
"name": name,
"move_type": moveType,
"state": state,
"date": date.Format("2006-01-02"),
"amount_total": amountTotal,
"amount_residual": amountResidual,
"payment_state": paymentState,
"ref": ref,
})
}
if invoices == nil {
invoices = []map[string]interface{}{}
}
writePortalJSON(w, map[string]interface{}{"invoices": invoices})
}
// handlePortalOrders lists sale orders for the current portal user.
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_orders()
func (s *Server) handlePortalOrders(w http.ResponseWriter, r *http.Request) {
partnerID, ok := s.portalPartnerID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
rows, err := s.pool.Query(ctx,
`SELECT so.id, so.name, so.state, so.date_order,
so.amount_total::float8, COALESCE(so.invoice_status, ''),
COALESCE(so.delivery_status, '')
FROM sale_order so
WHERE so.partner_id = $1
AND so.state IN ('sale','done')
ORDER BY so.date_order DESC
LIMIT 80`, partnerID)
if err != nil {
log.Printf("portal: order query error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Failed to load orders")
return
}
defer rows.Close()
var orders []map[string]interface{}
for rows.Next() {
var id int64
var name, state, invoiceStatus, deliveryStatus string
var dateOrder time.Time
var amountTotal float64
if err := rows.Scan(&id, &name, &state, &dateOrder,
&amountTotal, &invoiceStatus, &deliveryStatus); err != nil {
log.Printf("portal: order scan error: %v", err)
continue
}
orders = append(orders, map[string]interface{}{
"id": id,
"name": name,
"state": state,
"date_order": dateOrder.Format("2006-01-02 15:04:05"),
"amount_total": amountTotal,
"invoice_status": invoiceStatus,
"delivery_status": deliveryStatus,
})
}
if orders == nil {
orders = []map[string]interface{}{}
}
writePortalJSON(w, map[string]interface{}{"orders": orders})
}
// handlePortalPickings lists stock pickings for the current portal user.
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_pickings()
func (s *Server) handlePortalPickings(w http.ResponseWriter, r *http.Request) {
partnerID, ok := s.portalPartnerID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
rows, err := s.pool.Query(ctx,
`SELECT sp.id, sp.name, sp.state, sp.scheduled_date,
COALESCE(sp.origin, ''),
COALESCE(spt.name, '') AS picking_type_name
FROM stock_picking sp
LEFT JOIN stock_picking_type spt ON spt.id = sp.picking_type_id
WHERE sp.partner_id = $1
AND sp.state != 'cancel'
ORDER BY sp.scheduled_date DESC
LIMIT 80`, partnerID)
if err != nil {
log.Printf("portal: picking query error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Failed to load pickings")
return
}
defer rows.Close()
var pickings []map[string]interface{}
for rows.Next() {
var id int64
var name, state, origin, pickingTypeName string
var scheduledDate time.Time
if err := rows.Scan(&id, &name, &state, &scheduledDate,
&origin, &pickingTypeName); err != nil {
log.Printf("portal: picking scan error: %v", err)
continue
}
pickings = append(pickings, map[string]interface{}{
"id": id,
"name": name,
"state": state,
"scheduled_date": scheduledDate.Format("2006-01-02 15:04:05"),
"origin": origin,
"picking_type_name": pickingTypeName,
})
}
if pickings == nil {
pickings = []map[string]interface{}{}
}
writePortalJSON(w, map[string]interface{}{"pickings": pickings})
}
// handlePortalAccount returns/updates the portal user's profile.
// GET: returns user profile. POST: updates name/email/phone/street/city/zip.
// Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.account()
func (s *Server) handlePortalAccount(w http.ResponseWriter, r *http.Request) {
partnerID, ok := s.portalPartnerID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if r.Method == http.MethodPost {
// Update profile
var body struct {
Name *string `json:"name"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Street *string `json:"street"`
City *string `json:"city"`
Zip *string `json:"zip"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writePortalError(w, http.StatusBadRequest, "Invalid JSON")
return
}
// Build SET clause dynamically with parameterized placeholders
sets := make([]string, 0, 6)
args := make([]interface{}, 0, 7)
idx := 1
addField := func(col string, val *string) {
if val != nil {
sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, *val)
idx++
}
}
addField("name", body.Name)
addField("email", body.Email)
addField("phone", body.Phone)
addField("street", body.Street)
addField("city", body.City)
addField("zip", body.Zip)
if len(sets) > 0 {
args = append(args, partnerID)
query := "UPDATE res_partner SET "
for j, set := range sets {
if j > 0 {
query += ", "
}
query += set
}
query += fmt.Sprintf(" WHERE id = $%d", idx)
if _, err := s.pool.Exec(ctx, query, args...); err != nil {
log.Printf("portal: account update error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Update failed")
return
}
}
writePortalJSON(w, map[string]interface{}{"success": true})
return
}
// GET — return profile
var name, email, phone, street, city, zip string
err := s.pool.QueryRow(ctx,
`SELECT COALESCE(name,''), COALESCE(email,''), COALESCE(phone,''),
COALESCE(street,''), COALESCE(city,''), COALESCE(zip,'')
FROM res_partner WHERE id = $1`, partnerID).Scan(
&name, &email, &phone, &street, &city, &zip)
if err != nil {
log.Printf("portal: account read error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Failed to load profile")
return
}
writePortalJSON(w, map[string]interface{}{
"name": name,
"email": email,
"phone": phone,
"street": street,
"city": city,
"zip": zip,
})
}
// --- Helpers ---
func writePortalJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(data)
}
func writePortalError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}

313
pkg/server/portal_signup.go Normal file
View File

@@ -0,0 +1,313 @@
// Package server — Portal signup and password reset.
// Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome
package server
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"odoo-go/pkg/tools"
)
// registerPortalSignupRoutes registers /web/portal/* public endpoints.
func (s *Server) registerPortalSignupRoutes() {
s.mux.HandleFunc("/web/portal/signup", s.handlePortalSignup)
s.mux.HandleFunc("/web/portal/reset_password", s.handlePortalResetPassword)
}
// handlePortalSignup creates a new portal user with share=true and a matching res.partner.
// Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome.web_auth_signup()
func (s *Server) handlePortalSignup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writePortalError(w, http.StatusMethodNotAllowed, "POST required")
return
}
var body struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writePortalError(w, http.StatusBadRequest, "Invalid JSON")
return
}
// Validate required fields
body.Name = strings.TrimSpace(body.Name)
body.Email = strings.TrimSpace(body.Email)
if body.Name == "" || body.Email == "" || body.Password == "" {
writePortalError(w, http.StatusBadRequest, "Name, email, and password are required")
return
}
if len(body.Password) < 8 {
writePortalError(w, http.StatusBadRequest, "Password must be at least 8 characters")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Check if login already exists
var exists bool
err := s.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM res_users WHERE login = $1)`, body.Email).Scan(&exists)
if err != nil {
log.Printf("portal signup: check existing user error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Internal error")
return
}
if exists {
writePortalError(w, http.StatusConflict, "An account with this email already exists")
return
}
// Hash password
hashedPw, err := tools.HashPassword(body.Password)
if err != nil {
log.Printf("portal signup: hash password error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Internal error")
return
}
// Get default company
var companyID int64
err = s.pool.QueryRow(ctx,
`SELECT id FROM res_company WHERE active = true ORDER BY id LIMIT 1`).Scan(&companyID)
if err != nil {
log.Printf("portal signup: get company error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Internal error")
return
}
// Begin transaction — create partner + user atomically
tx, err := s.pool.Begin(ctx)
if err != nil {
log.Printf("portal signup: begin tx error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Internal error")
return
}
defer tx.Rollback(ctx)
// Create res.partner
var partnerID int64
err = tx.QueryRow(ctx,
`INSERT INTO res_partner (name, email, active, company_id, customer_rank)
VALUES ($1, $2, true, $3, 1)
RETURNING id`, body.Name, body.Email, companyID).Scan(&partnerID)
if err != nil {
log.Printf("portal signup: create partner error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
return
}
// Create res.users with share=true
var userID int64
err = tx.QueryRow(ctx,
`INSERT INTO res_users (login, password, active, partner_id, company_id, share)
VALUES ($1, $2, true, $3, $4, true)
RETURNING id`, body.Email, hashedPw, partnerID, companyID).Scan(&userID)
if err != nil {
log.Printf("portal signup: create user error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
return
}
// Add user to group_portal (not group_user)
var groupPortalID int64
err = tx.QueryRow(ctx,
`SELECT g.id FROM res_groups g
JOIN ir_model_data imd ON imd.res_id = g.id AND imd.model = 'res.groups'
WHERE imd.module = 'base' AND imd.name = 'group_portal'`).Scan(&groupPortalID)
if err != nil {
// group_portal might not exist yet — create it
err = tx.QueryRow(ctx,
`INSERT INTO res_groups (name) VALUES ('Portal') RETURNING id`).Scan(&groupPortalID)
if err != nil {
log.Printf("portal signup: create group_portal error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
return
}
_, err = tx.Exec(ctx,
`INSERT INTO ir_model_data (module, name, model, res_id)
VALUES ('base', 'group_portal', 'res.groups', $1)
ON CONFLICT DO NOTHING`, groupPortalID)
if err != nil {
log.Printf("portal signup: create group_portal xmlid error: %v", err)
}
}
_, err = tx.Exec(ctx,
`INSERT INTO res_groups_res_users_rel (res_groups_id, res_users_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`, groupPortalID, userID)
if err != nil {
log.Printf("portal signup: add user to group_portal error: %v", err)
}
if err := tx.Commit(ctx); err != nil {
log.Printf("portal signup: commit error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Failed to create account")
return
}
log.Printf("portal signup: created portal user id=%d login=%s partner_id=%d",
userID, body.Email, partnerID)
writePortalJSON(w, map[string]interface{}{
"success": true,
"user_id": userID,
"partner_id": partnerID,
"message": "Account created successfully",
})
}
// handlePortalResetPassword handles password reset requests.
// POST with {"email":"..."}: generates a reset token and sends an email.
// POST with {"token":"...","password":"..."}: resets the password.
// Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome.web_auth_reset_password()
func (s *Server) handlePortalResetPassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writePortalError(w, http.StatusMethodNotAllowed, "POST required")
return
}
var body struct {
Email string `json:"email"`
Token string `json:"token"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writePortalError(w, http.StatusBadRequest, "Invalid JSON")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Phase 2: Token + new password → reset
if body.Token != "" && body.Password != "" {
s.handleResetWithToken(w, ctx, body.Token, body.Password)
return
}
// Phase 1: Email → generate token + send email
if body.Email == "" {
writePortalError(w, http.StatusBadRequest, "Email is required")
return
}
s.handleResetRequest(w, ctx, strings.TrimSpace(body.Email))
}
// handleResetRequest generates a reset token and sends it via email.
func (s *Server) handleResetRequest(w http.ResponseWriter, ctx context.Context, email string) {
// Look up user
var uid int64
err := s.pool.QueryRow(ctx,
`SELECT id FROM res_users WHERE login = $1 AND active = true`, email).Scan(&uid)
if err != nil {
// Don't reveal whether the email exists — always return success
writePortalJSON(w, map[string]interface{}{
"success": true,
"message": "If an account exists with this email, a reset link has been sent",
})
return
}
// Generate token
tokenBytes := make([]byte, 32)
rand.Read(tokenBytes)
token := hex.EncodeToString(tokenBytes)
expiration := time.Now().Add(24 * time.Hour)
// Store token
_, err = s.pool.Exec(ctx,
`UPDATE res_users SET signup_token = $1, signup_expiration = $2 WHERE id = $3`,
token, expiration, uid)
if err != nil {
log.Printf("portal reset: store token error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Internal error")
return
}
// Send email with reset link
smtpCfg := tools.LoadSMTPConfig()
resetURL := fmt.Sprintf("/web/portal/reset_password?token=%s", token)
emailBody := fmt.Sprintf(`<html><body>
<p>A password reset was requested for your account.</p>
<p>Click the link below to set a new password:</p>
<p><a href="%s">Reset Password</a></p>
<p>This link expires in 24 hours.</p>
<p>If you did not request this, you can ignore this email.</p>
</body></html>`, resetURL)
if err := tools.SendEmail(smtpCfg, email, "Password Reset", emailBody); err != nil {
log.Printf("portal reset: send email error: %v", err)
// Don't expose email sending errors to the user
}
writePortalJSON(w, map[string]interface{}{
"success": true,
"message": "If an account exists with this email, a reset link has been sent",
})
}
// handleResetWithToken validates the token and sets the new password.
func (s *Server) handleResetWithToken(w http.ResponseWriter, ctx context.Context, token, password string) {
if len(password) < 8 {
writePortalError(w, http.StatusBadRequest, "Password must be at least 8 characters")
return
}
// Look up user by token
var uid int64
var expiration time.Time
err := s.pool.QueryRow(ctx,
`SELECT id, signup_expiration FROM res_users
WHERE signup_token = $1 AND active = true`, token).Scan(&uid, &expiration)
if err != nil {
writePortalError(w, http.StatusBadRequest, "Invalid or expired reset token")
return
}
// Check expiration
if time.Now().After(expiration) {
// Clear expired token
s.pool.Exec(ctx,
`UPDATE res_users SET signup_token = NULL, signup_expiration = NULL WHERE id = $1`, uid)
writePortalError(w, http.StatusBadRequest, "Reset token has expired")
return
}
// Hash new password
hashedPw, err := tools.HashPassword(password)
if err != nil {
log.Printf("portal reset: hash password error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Internal error")
return
}
// Update password and clear token
_, err = s.pool.Exec(ctx,
`UPDATE res_users SET password = $1, signup_token = NULL, signup_expiration = NULL
WHERE id = $2`, hashedPw, uid)
if err != nil {
log.Printf("portal reset: update password error: %v", err)
writePortalError(w, http.StatusInternalServerError, "Failed to reset password")
return
}
log.Printf("portal reset: password reset for uid=%d", uid)
writePortalJSON(w, map[string]interface{}{
"success": true,
"message": "Password has been reset successfully",
})
}

View File

@@ -9,6 +9,7 @@ import (
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -35,6 +36,8 @@ type Server struct {
// all JS files (except module_loader.js) plus the XML template bundle,
// served as a single file to avoid hundreds of individual HTTP requests.
jsBundle string
bus *Bus // Message bus for Discuss long-polling
}
// New creates a new server instance.
@@ -128,6 +131,17 @@ func (s *Server) registerRoutes() {
// CSV export
s.mux.HandleFunc("/web/export/csv", s.handleExportCSV)
s.mux.HandleFunc("/web/export/xlsx", s.handleExportXLSX)
// Import
s.mux.HandleFunc("/web/import/csv", s.handleImportCSV)
// Post-setup wizard
s.mux.HandleFunc("/web/setup/wizard", s.handleSetupWizard)
s.mux.HandleFunc("/web/setup/wizard/save", s.handleSetupWizardSave)
// Bank statement import
s.mux.HandleFunc("/web/bank_statement/import", s.handleBankStatementImport)
// Reports (HTML and PDF report rendering)
s.mux.HandleFunc("/report/", s.handleReport)
@@ -137,10 +151,16 @@ func (s *Server) registerRoutes() {
// Logout & Account
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
s.mux.HandleFunc("/web/session/account", s.handleSessionAccount)
s.mux.HandleFunc("/web/session/switch_company", s.handleSwitchCompany)
// Health check
s.mux.HandleFunc("/health", s.handleHealth)
// Portal routes (external user access)
s.registerPortalRoutes()
s.registerPortalSignupRoutes()
s.registerBusRoutes()
// Static files (catch-all for /<addon>/static/...)
// NOTE: must be last since it's a broad pattern
}
@@ -255,13 +275,14 @@ func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
return
}
// Extract UID from session, default to 1 (admin) if no session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
// Extract UID from session — reject if no session (defense in depth)
sess := GetSession(r)
if sess == nil {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: 100, Message: "Session expired"})
return
}
uid := sess.UID
companyID := sess.CompanyID
// Create environment for this request
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
@@ -294,6 +315,36 @@ func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
s.writeJSONRPC(w, req.ID, result, nil)
}
// sensitiveFields lists fields that only admin (uid=1) may write to.
// Prevents privilege escalation via field manipulation.
var sensitiveFields = map[string]map[string]bool{
"ir.cron": {"user_id": true, "model_name": true, "method_name": true},
"ir.model.access": {"group_id": true, "perm_read": true, "perm_write": true, "perm_create": true, "perm_unlink": true},
"ir.rule": {"domain_force": true, "groups": true, "perm_read": true, "perm_write": true, "perm_create": true, "perm_unlink": true},
"res.users": {"groups_id": true},
"res.groups": {"users": true},
}
// checkSensitiveFields blocks non-admin users from writing protected fields.
func checkSensitiveFields(env *orm.Environment, model string, vals orm.Values) *RPCError {
if env.UID() == 1 || env.IsSuperuser() {
return nil
}
fields, ok := sensitiveFields[model]
if !ok {
return nil
}
for field := range vals {
if fields[field] {
return &RPCError{
Code: 403,
Message: fmt.Sprintf("Access Denied: field %q on %s is admin-only", field, model),
}
}
}
return nil
}
// checkAccess verifies the current user has permission for the operation.
// Mirrors: odoo/addons/base/models/ir_model.py IrModelAccess.check()
func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCError {
@@ -317,8 +368,22 @@ func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCErr
`SELECT COUNT(*) FROM ir_model_access a
JOIN ir_model m ON m.id = a.model_id
WHERE m.model = $1`, model).Scan(&count)
if err != nil || count == 0 {
return nil // No ACLs definedopen access (like Odoo superuser mode)
if err != nil {
// DB errordeny access (fail-closed)
log.Printf("access: DB error checking ACL for model %s: %v", model, err)
return &RPCError{
Code: 403,
Message: fmt.Sprintf("Access Denied: %s on %s (internal error)", method, model),
}
}
if count == 0 {
// No ACL rules defined for this model → deny (fail-closed).
// All models should have ACL seed data via seedACLRules().
log.Printf("access: no ACL for model %s, denying (fail-closed)", model)
return &RPCError{
Code: 403,
Message: fmt.Sprintf("Access Denied: no ACL rules for %s", model),
}
}
// Check if user's groups grant permission
@@ -334,7 +399,11 @@ func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCErr
AND (a.group_id IS NULL OR gu.res_users_id = $2)
)`, perm), model, env.UID()).Scan(&granted)
if err != nil {
return nil // On error, allow (fail-open for now)
log.Printf("access: DB error checking ACL grant for model %s: %v", model, err)
return &RPCError{
Code: 403,
Message: fmt.Sprintf("Access Denied: %s on %s (internal error)", method, model),
}
}
if !granted {
return &RPCError{
@@ -379,10 +448,57 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
switch params.Method {
case "has_group":
// Always return true for admin user, stub for now
return true, nil
// Check if current user belongs to the given group.
// Mirrors: odoo/orm/models.py BaseModel.user_has_groups()
groupXMLID := ""
if len(params.Args) > 0 {
groupXMLID, _ = params.Args[0].(string)
}
if groupXMLID == "" {
return false, nil
}
// Admin always has all groups
if env.UID() == 1 {
return true, nil
}
// Parse "module.xml_id" format
parts := strings.SplitN(groupXMLID, ".", 2)
if len(parts) != 2 {
return false, nil
}
// Query: does user belong to this group?
var exists bool
err := env.Tx().QueryRow(env.Ctx(),
`SELECT EXISTS(
SELECT 1 FROM res_groups_res_users_rel gur
JOIN ir_model_data imd ON imd.res_id = gur.res_groups_id AND imd.model = 'res.groups'
WHERE gur.res_users_id = $1 AND imd.module = $2 AND imd.name = $3
)`, env.UID(), parts[0], parts[1]).Scan(&exists)
if err != nil {
return false, nil
}
return exists, nil
case "check_access_rights":
// Check if current user has the given access right on this model.
// Mirrors: odoo/orm/models.py BaseModel.check_access_rights()
operation := "read"
if len(params.Args) > 0 {
if op, ok := params.Args[0].(string); ok {
operation = op
}
}
raiseException := true
if v, ok := params.KW["raise_exception"].(bool); ok {
raiseException = v
}
accessErr := s.checkAccess(env, params.Model, operation)
if accessErr != nil {
if raiseException {
return nil, accessErr
}
return false, nil
}
return true, nil
case "fields_get":
@@ -404,6 +520,11 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
vals := parseValuesAt(params.Args, 1)
spec, _ := params.KW["specification"].(map[string]interface{})
// Field-level access control
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
return nil, err
}
if len(ids) > 0 && ids[0] > 0 {
// Update existing record(s)
err := rs.Browse(ids...).Write(vals)
@@ -513,6 +634,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
case "create":
vals := parseValues(params.Args)
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
return nil, err
}
record, err := rs.Create(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
@@ -522,6 +646,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
case "write":
ids := parseIDs(params.Args)
vals := parseValuesAt(params.Args, 1)
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
return nil, err
}
err := rs.Browse(ids...).Write(vals)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
@@ -645,9 +772,33 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
}, nil
case "get_formview_id":
return false, nil
// Return the default form view ID for this model.
// Mirrors: odoo/orm/models.py BaseModel.get_formview_id()
var viewID *int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT id FROM ir_ui_view
WHERE model = $1 AND type = 'form' AND active = true
ORDER BY priority, id LIMIT 1`,
params.Model).Scan(&viewID)
if err != nil || viewID == nil {
return false, nil
}
return *viewID, nil
case "action_get":
// Try registered method first (e.g. res.users has its own action_get).
// Mirrors: odoo/addons/base/models/res_users.py action_get()
model := orm.Registry.Get(params.Model)
if model != nil && model.Methods != nil {
if method, ok := model.Methods["action_get"]; ok {
ids := parseIDs(params.Args)
result, err := method(rs.Browse(ids...), params.Args[1:]...)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return result, nil
}
}
return false, nil
case "name_create":
@@ -665,10 +816,48 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
return []interface{}{created.ID(), nameStr}, nil
case "read_progress_bar":
return map[string]interface{}{}, nil
return s.handleReadProgressBar(rs, params)
case "activity_format":
return []interface{}{}, nil
ids := parseIDs(params.Args)
if len(ids) == 0 {
return []interface{}{}, nil
}
// Search activities for this model/record
actRS := env.Model("mail.activity")
var allActivities []orm.Values
for _, id := range ids {
domain := orm.And(
orm.Leaf("res_model", "=", params.Model),
orm.Leaf("res_id", "=", id),
orm.Leaf("done", "=", false),
)
found, err := actRS.Search(domain, orm.SearchOpts{Order: "date_deadline"})
if err != nil || found.IsEmpty() {
continue
}
records, err := found.Read([]string{"id", "res_model", "res_id", "activity_type_id", "summary", "note", "date_deadline", "user_id", "state"})
if err != nil {
continue
}
allActivities = append(allActivities, records...)
}
if allActivities == nil {
return []interface{}{}, nil
}
// Format M2O fields
actSpec := map[string]interface{}{
"activity_type_id": map[string]interface{}{},
"user_id": map[string]interface{}{},
}
formatM2OFields(env, "mail.activity", allActivities, actSpec)
formatDateFields("mail.activity", allActivities)
normalizeNullFields("mail.activity", allActivities)
actResult := make([]interface{}, len(allActivities))
for i, a := range allActivities {
actResult[i] = a
}
return actResult, nil
case "action_archive":
ids := parseIDs(params.Args)
@@ -697,6 +886,199 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
}
return created.ID(), nil
case "web_resequence":
// Resequence records by their IDs (drag&drop reordering).
// Mirrors: odoo/addons/web/models/models.py web_resequence()
ids := parseIDs(params.Args)
if len(ids) == 0 {
return []orm.Values{}, nil
}
// Parse field_name (default "sequence")
fieldName := "sequence"
if v, ok := params.KW["field_name"].(string); ok {
fieldName = v
}
// Parse offset (default 0)
offset := 0
if v, ok := params.KW["offset"].(float64); ok {
offset = int(v)
}
// Check if field exists on the model
model := orm.Registry.Get(params.Model)
if model == nil || model.GetField(fieldName) == nil {
return []orm.Values{}, nil
}
// Update sequence for each record in order
for i, id := range ids {
if err := rs.Browse(id).Write(orm.Values{fieldName: offset + i}); err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
}
// Return records via web_read
spec, _ := params.KW["specification"].(map[string]interface{})
readParams := CallKWParams{
Model: params.Model,
Method: "web_read",
Args: []interface{}{ids},
KW: map[string]interface{}{"specification": spec},
}
return handleWebRead(env, params.Model, readParams)
case "message_post":
// Post a message on the record's chatter.
// Mirrors: odoo/addons/mail/models/mail_thread.py message_post()
ids := parseIDs(params.Args)
if len(ids) == 0 {
return false, nil
}
body, _ := params.KW["body"].(string)
messageType := "comment"
if v, _ := params.KW["message_type"].(string); v != "" {
messageType = v
}
// Get author from current user's partner_id
var authorID int64
if err := env.Tx().QueryRow(env.Ctx(),
`SELECT partner_id FROM res_users WHERE id = $1`, env.UID(),
).Scan(&authorID); err != nil {
log.Printf("warning: message_post author lookup failed: %v", err)
}
// Create mail.message linked to the current model/record
var msgID int64
err := env.Tx().QueryRow(env.Ctx(),
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, date, create_uid, write_uid, create_date, write_date)
VALUES ($1, $2, $3, $4, $5, NOW(), $6, $6, NOW(), NOW())
RETURNING id`,
params.Model, ids[0], body, messageType, authorID, env.UID(),
).Scan(&msgID)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
return msgID, nil
case "_message_get_thread":
// Get messages for a record's chatter.
// Mirrors: odoo/addons/mail/models/mail_thread.py
ids := parseIDs(params.Args)
if len(ids) == 0 {
return []interface{}{}, nil
}
rows, err := env.Tx().Query(env.Ctx(),
`SELECT m.id, m.body, m.message_type, m.date,
m.author_id, COALESCE(p.name, ''),
COALESCE(m.subject, ''), COALESCE(m.email_from, '')
FROM mail_message m
LEFT JOIN res_partner p ON p.id = m.author_id
WHERE m.model = $1 AND m.res_id = $2
ORDER BY m.id DESC`,
params.Model, ids[0],
)
if err != nil {
return nil, &RPCError{Code: -32000, Message: err.Error()}
}
defer rows.Close()
var messages []map[string]interface{}
for rows.Next() {
var id int64
var body, msgType, subject, emailFrom string
var date interface{}
var authorID int64
var authorName string
if scanErr := rows.Scan(&id, &body, &msgType, &date, &authorID, &authorName, &subject, &emailFrom); scanErr != nil {
continue
}
msg := map[string]interface{}{
"id": id,
"body": body,
"message_type": msgType,
"date": date,
"subject": subject,
"email_from": emailFrom,
}
if authorID > 0 {
msg["author_id"] = []interface{}{authorID, authorName}
} else {
msg["author_id"] = false
}
messages = append(messages, msg)
}
if messages == nil {
messages = []map[string]interface{}{}
}
return messages, nil
case "read_followers":
ids := parseIDs(params.Args)
if len(ids) == 0 {
return []interface{}{}, nil
}
// Search followers for this model/record
followerRS := env.Model("mail.followers")
domain := orm.And(
orm.Leaf("res_model", "=", params.Model),
orm.Leaf("res_id", "in", ids),
)
found, err := followerRS.Search(domain, orm.SearchOpts{Limit: 100})
if err != nil || found.IsEmpty() {
return []interface{}{}, nil
}
followerRecords, err := found.Read([]string{"id", "res_model", "res_id", "partner_id"})
if err != nil {
return []interface{}{}, nil
}
followerSpec := map[string]interface{}{"partner_id": map[string]interface{}{}}
formatM2OFields(env, "mail.followers", followerRecords, followerSpec)
normalizeNullFields("mail.followers", followerRecords)
followerResult := make([]interface{}, len(followerRecords))
for i, r := range followerRecords {
followerResult[i] = r
}
return followerResult, nil
case "get_activity_data":
// Return activity summary data for records.
// Mirrors: odoo/addons/mail/models/mail_activity_mixin.py
emptyResult := map[string]interface{}{
"activity_types": []interface{}{},
"activity_res_ids": map[string]interface{}{},
"grouped_activities": map[string]interface{}{},
}
ids := parseIDs(params.Args)
if len(ids) == 0 {
return emptyResult, nil
}
// Get activity types
typeRS := env.Model("mail.activity.type")
types, err := typeRS.Search(nil, orm.SearchOpts{Order: "sequence, id"})
if err != nil || types.IsEmpty() {
return emptyResult, nil
}
typeRecords, _ := types.Read([]string{"id", "name"})
typeList := make([]interface{}, len(typeRecords))
for i, t := range typeRecords {
typeList[i] = t
}
return map[string]interface{}{
"activity_types": typeList,
"activity_res_ids": map[string]interface{}{},
"grouped_activities": map[string]interface{}{},
}, nil
default:
// Try registered business methods on the model.
// Mirrors: odoo/service/model.py call_kw() + odoo/addons/web/controllers/dataset.py call_button()
@@ -732,6 +1114,58 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
// --- Session / Auth Endpoints ---
// loginAttemptInfo tracks login attempts for rate limiting.
type loginAttemptInfo struct {
Count int
LastTime time.Time
}
var (
loginAttempts = make(map[string]loginAttemptInfo)
loginAttemptsMu sync.Mutex
)
// checkLoginRateLimit returns false if the login is rate-limited (too many attempts).
func (s *Server) checkLoginRateLimit(login string) bool {
loginAttemptsMu.Lock()
defer loginAttemptsMu.Unlock()
now := time.Now()
// Periodic cleanup: evict stale entries (>15 min old) to prevent unbounded growth
if len(loginAttempts) > 100 {
for k, v := range loginAttempts {
if now.Sub(v.LastTime) > 15*time.Minute {
delete(loginAttempts, k)
}
}
}
info := loginAttempts[login]
// Reset after 15 minutes
if now.Sub(info.LastTime) > 15*time.Minute {
info = loginAttemptInfo{}
}
// Max 10 attempts per 15 minutes
if info.Count >= 10 {
return false // Rate limited
}
info.Count++
info.LastTime = now
loginAttempts[login] = info
return true
}
// resetLoginRateLimit clears the rate limit counter on successful login.
func (s *Server) resetLoginRateLimit(login string) {
loginAttemptsMu.Lock()
defer loginAttemptsMu.Unlock()
delete(loginAttempts, login)
}
func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -754,6 +1188,14 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
return
}
// Rate limit login attempts
if !s.checkLoginRateLimit(params.Login) {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: 429, Message: "Too many login attempts. Please try again later.",
})
return
}
// Query user by login
var uid int64
var companyID int64
@@ -776,16 +1218,40 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
return
}
// Check password (support both bcrypt and plaintext for migration)
if !tools.CheckPassword(hashedPw, params.Password) && hashedPw != params.Password {
// Check password (bcrypt only — no plaintext fallback)
if !tools.CheckPassword(hashedPw, params.Password) {
s.writeJSONRPC(w, req.ID, nil, &RPCError{
Code: 100, Message: "Access Denied: invalid login or password",
})
return
}
// Successful login reset rate limiter
s.resetLoginRateLimit(params.Login)
// Query allowed companies for the user
allowedCompanyIDs := []int64{companyID}
rows, err := s.pool.Query(r.Context(),
`SELECT DISTINCT c.id FROM res_company c
WHERE c.active = true
ORDER BY c.id`)
if err == nil {
defer rows.Close()
var ids []int64
for rows.Next() {
var cid int64
if rows.Scan(&cid) == nil {
ids = append(ids, cid)
}
}
if len(ids) > 0 {
allowedCompanyIDs = ids
}
}
// Create session
sess := s.sessions.New(uid, companyID, params.Login)
sess.AllowedCompanyIDs = allowedCompanyIDs
// Set session cookie
http.SetCookie(w, &http.Cookie{
@@ -793,6 +1259,7 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
Value: sess.ID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
@@ -857,6 +1324,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
})
http.Redirect(w, r, "/web/login", http.StatusFound)
}

View File

@@ -13,12 +13,14 @@ import (
// Session represents an authenticated user session.
type Session struct {
ID string
UID int64
CompanyID int64
Login string
CreatedAt time.Time
LastActivity time.Time
ID string
UID int64
CompanyID int64
AllowedCompanyIDs []int64
Login string
CSRFToken string
CreatedAt time.Time
LastActivity time.Time
}
// SessionStore is a session store with an in-memory cache backed by PostgreSQL.
@@ -47,10 +49,15 @@ func InitSessionTable(ctx context.Context, pool *pgxpool.Pool) error {
uid INT8 NOT NULL,
company_id INT8 NOT NULL,
login VARCHAR(255),
csrf_token VARCHAR(64) DEFAULT '',
created_at TIMESTAMP DEFAULT NOW(),
last_seen TIMESTAMP DEFAULT NOW()
)
`)
if err == nil {
// Add csrf_token column if table already exists without it
pool.Exec(ctx, `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS csrf_token VARCHAR(64) DEFAULT ''`)
}
if err != nil {
return err
}
@@ -67,6 +74,7 @@ func (s *SessionStore) New(uid, companyID int64, login string) *Session {
UID: uid,
CompanyID: companyID,
Login: login,
CSRFToken: generateToken(),
CreatedAt: now,
LastActivity: now,
}
@@ -81,10 +89,10 @@ func (s *SessionStore) New(uid, companyID int64, login string) *Session {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := s.pool.Exec(ctx,
`INSERT INTO sessions (id, uid, company_id, login, created_at, last_seen)
VALUES ($1, $2, $3, $4, $5, $6)
`INSERT INTO sessions (id, uid, company_id, login, csrf_token, created_at, last_seen)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO NOTHING`,
token, uid, companyID, login, now, now)
token, uid, companyID, login, sess.CSRFToken, now, now)
if err != nil {
log.Printf("session: failed to persist session to DB: %v", err)
}
@@ -106,20 +114,23 @@ func (s *SessionStore) Get(id string) *Session {
s.Delete(id)
return nil
}
// Update last activity
now := time.Now()
needsDBUpdate := time.Since(sess.LastActivity) > 30*time.Second
// Update last activity in memory
s.mu.Lock()
sess.LastActivity = now
s.mu.Unlock()
// Update last_seen in DB asynchronously
if s.pool != nil {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.pool.Exec(ctx,
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
}()
// Throttle DB writes: only persist every 30s to avoid per-request overhead
if needsDBUpdate && s.pool != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := s.pool.Exec(ctx,
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id); err != nil {
log.Printf("session: failed to update last_seen in DB: %v", err)
}
}
return sess
@@ -134,14 +145,20 @@ func (s *SessionStore) Get(id string) *Session {
defer cancel()
sess = &Session{}
var csrfToken string
err := s.pool.QueryRow(ctx,
`SELECT id, uid, company_id, login, created_at, last_seen
`SELECT id, uid, company_id, login, COALESCE(csrf_token, ''), created_at, last_seen
FROM sessions WHERE id = $1`, id).Scan(
&sess.ID, &sess.UID, &sess.CompanyID, &sess.Login,
&sess.ID, &sess.UID, &sess.CompanyID, &sess.Login, &csrfToken,
&sess.CreatedAt, &sess.LastActivity)
if err != nil {
return nil
}
if csrfToken != "" {
sess.CSRFToken = csrfToken
} else {
sess.CSRFToken = generateToken()
}
// Check TTL
if time.Since(sess.LastActivity) > s.ttl {
@@ -149,18 +166,18 @@ func (s *SessionStore) Get(id string) *Session {
return nil
}
// Update last activity
// Update last activity and add to memory cache
now := time.Now()
sess.LastActivity = now
// Add to memory cache
s.mu.Lock()
sess.LastActivity = now
s.sessions[id] = sess
s.mu.Unlock()
// Update last_seen in DB
s.pool.Exec(ctx,
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id)
if _, err := s.pool.Exec(ctx,
`UPDATE sessions SET last_seen = $1 WHERE id = $2`, now, id); err != nil {
log.Printf("session: failed to update last_seen in DB: %v", err)
}
return sess
}

View File

@@ -6,14 +6,17 @@ import (
"fmt"
"log"
"net/http"
"os"
"regexp"
"strings"
"sync/atomic"
"time"
"odoo-go/pkg/service"
"odoo-go/pkg/tools"
)
var dbnamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`)
// isSetupNeeded checks if the current database has been initialized.
@@ -55,6 +58,16 @@ func (s *Server) handleDatabaseCreate(w http.ResponseWriter, r *http.Request) {
return
}
// Validate master password (default: "admin", configurable via ODOO_MASTER_PASSWORD env)
masterPw := os.Getenv("ODOO_MASTER_PASSWORD")
if masterPw == "" {
masterPw = "admin"
}
if params.MasterPwd != masterPw {
writeJSON(w, map[string]string{"error": "Invalid master password"})
return
}
// Validate
if params.Login == "" || params.Password == "" {
writeJSON(w, map[string]string{"error": "Email and password are required"})
@@ -111,7 +124,10 @@ func (s *Server) handleDatabaseCreate(w http.ResponseWriter, r *http.Request) {
domain := parts[1]
domainParts := strings.Split(domain, ".")
if len(domainParts) > 0 {
companyName = strings.Title(domainParts[0])
name := domainParts[0]
if len(name) > 0 {
companyName = strings.ToUpper(name[:1]) + name[1:]
}
}
}
}
@@ -175,6 +191,195 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
json.NewEncoder(w).Encode(v)
}
// postSetupDone caches the result of isPostSetupNeeded to avoid a DB query on every request.
var postSetupDone atomic.Bool
// isPostSetupNeeded checks if the company still has default values (needs configuration).
func (s *Server) isPostSetupNeeded() bool {
if postSetupDone.Load() {
return false
}
var name string
err := s.pool.QueryRow(context.Background(),
`SELECT COALESCE(name, '') FROM res_company WHERE id = 1`).Scan(&name)
if err != nil {
return false
}
needed := name == "" || name == "My Company" || strings.HasPrefix(name, "My ")
if !needed {
postSetupDone.Store(true)
}
return needed
}
// handleSetupWizard serves the post-setup configuration wizard.
// Shown after first login when the company has not been configured yet.
// Mirrors: odoo/addons/base_setup/views/res_config_settings_views.xml
func (s *Server) handleSetupWizard(w http.ResponseWriter, r *http.Request) {
sess := GetSession(r)
if sess == nil {
http.Redirect(w, r, "/web/login", http.StatusFound)
return
}
// Load current company data
var companyName, street, city, zip, phone, email, website, vat string
var countryID int64
s.pool.QueryRow(context.Background(),
`SELECT COALESCE(name,''), COALESCE(street,''), COALESCE(city,''), COALESCE(zip,''),
COALESCE(phone,''), COALESCE(email,''), COALESCE(website,''), COALESCE(vat,''),
COALESCE(country_id, 0)
FROM res_company WHERE id = $1`, sess.CompanyID,
).Scan(&companyName, &street, &city, &zip, &phone, &email, &website, &vat, &countryID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
esc := htmlEscape
fmt.Fprintf(w, setupWizardHTML,
esc(companyName), esc(street), esc(city), esc(zip), esc(phone), esc(email), esc(website), esc(vat))
}
// handleSetupWizardSave saves the post-setup wizard data.
func (s *Server) handleSetupWizardSave(w http.ResponseWriter, r *http.Request) {
sess := GetSession(r)
if sess == nil {
writeJSON(w, map[string]string{"error": "Not authenticated"})
return
}
var params struct {
CompanyName string `json:"company_name"`
Street string `json:"street"`
City string `json:"city"`
Zip string `json:"zip"`
Phone string `json:"phone"`
Email string `json:"email"`
Website string `json:"website"`
Vat string `json:"vat"`
}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
writeJSON(w, map[string]string{"error": "Invalid request"})
return
}
if params.CompanyName == "" {
writeJSON(w, map[string]string{"error": "Company name is required"})
return
}
_, err := s.pool.Exec(context.Background(),
`UPDATE res_company SET name=$1, street=$2, city=$3, zip=$4, phone=$5, email=$6, website=$7, vat=$8
WHERE id = $9`,
params.CompanyName, params.Street, params.City, params.Zip,
params.Phone, params.Email, params.Website, params.Vat, sess.CompanyID)
if err != nil {
writeJSON(w, map[string]string{"error": fmt.Sprintf("Save error: %v", err)})
return
}
// Also update the partner linked to the company
s.pool.Exec(context.Background(),
`UPDATE res_partner SET name=$1, street=$2, city=$3, zip=$4, phone=$5, email=$6, website=$7, vat=$8
WHERE id = (SELECT partner_id FROM res_company WHERE id = $9)`,
params.CompanyName, params.Street, params.City, params.Zip,
params.Phone, params.Email, params.Website, params.Vat, sess.CompanyID)
postSetupDone.Store(true) // Mark setup as done so we don't redirect again
writeJSON(w, map[string]interface{}{"status": "ok", "redirect": "/odoo"})
}
var setupWizardHTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Setup — Configure Your Company</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0eeee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.wizard { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);
width: 100%%; max-width: 560px; }
.wizard h1 { color: #71639e; margin-bottom: 6px; font-size: 24px; }
.wizard .subtitle { color: #666; margin-bottom: 24px; font-size: 14px; }
.wizard label { display: block; margin-bottom: 4px; font-weight: 500; color: #555; font-size: 13px; }
.wizard input { width: 100%%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 4px;
font-size: 14px; margin-bottom: 14px; }
.wizard input:focus { outline: none; border-color: #71639e; box-shadow: 0 0 0 2px rgba(113,99,158,0.2); }
.wizard button { width: 100%%; padding: 14px; background: #71639e; color: white; border: none;
border-radius: 4px; font-size: 16px; cursor: pointer; margin-top: 10px; }
.wizard button:hover { background: #5f5387; }
.wizard .skip { text-align: center; margin-top: 12px; }
.wizard .skip a { color: #999; text-decoration: none; font-size: 13px; }
.wizard .skip a:hover { color: #666; }
.row { display: flex; gap: 12px; }
.row > div { flex: 1; }
.error { color: #dc3545; margin-bottom: 12px; display: none; text-align: center; font-size: 14px; }
</style>
</head>
<body>
<div class="wizard">
<h1>Configure Your Company</h1>
<p class="subtitle">Set up your company information</p>
<div id="error" class="error"></div>
<form id="wizardForm">
<label>Company Name *</label>
<input type="text" id="company_name" value="%s" required/>
<label>Street</label>
<input type="text" id="street" value="%s"/>
<div class="row">
<div><label>City</label><input type="text" id="city" value="%s"/></div>
<div><label>ZIP</label><input type="text" id="zip" value="%s"/></div>
</div>
<div class="row">
<div><label>Phone</label><input type="tel" id="phone" value="%s"/></div>
<div><label>Email</label><input type="email" id="email" value="%s"/></div>
</div>
<label>Website</label>
<input type="url" id="website" value="%s" placeholder="https://"/>
<label>Tax ID / VAT</label>
<input type="text" id="vat" value="%s"/>
<button type="submit">Save & Continue</button>
</form>
<div class="skip"><a href="/odoo">Skip for now</a></div>
</div>
<script>
document.getElementById('wizardForm').addEventListener('submit', function(e) {
e.preventDefault();
fetch('/web/setup/wizard/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
company_name: document.getElementById('company_name').value,
street: document.getElementById('street').value,
city: document.getElementById('city').value,
zip: document.getElementById('zip').value,
phone: document.getElementById('phone').value,
email: document.getElementById('email').value,
website: document.getElementById('website').value,
vat: document.getElementById('vat').value
})
})
.then(function(r) { return r.json(); })
.then(function(result) {
if (result.error) {
var el = document.getElementById('error');
el.textContent = result.error;
el.style.display = 'block';
} else {
window.location.href = result.redirect || '/odoo';
}
});
});
</script>
</body>
</html>`
// --- Database Manager HTML ---
// Mirrors: odoo/addons/web/static/src/public/database_manager.create_form.qweb.html
var databaseManagerHTML = `<!DOCTYPE html>

View File

@@ -43,8 +43,9 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
addonName := parts[0]
filePath := parts[2]
// Security: prevent directory traversal
if strings.Contains(filePath, "..") {
// Security: prevent directory traversal in both addonName and filePath
if strings.Contains(filePath, "..") || strings.Contains(addonName, "..") ||
strings.Contains(addonName, "/") || strings.Contains(addonName, "\\") {
http.NotFound(w, r)
return
}

View File

@@ -88,7 +88,7 @@ func TestExtractImports(t *testing.T) {
content := `import { Foo, Bar } from "@web/core/foo";
import { Baz as Qux } from "@web/core/baz";
const x = 1;`
deps, requires, clean := extractImports(content)
deps, requires, clean := extractImports("test.module", content)
if len(deps) != 2 {
t.Fatalf("expected 2 deps, got %d: %v", len(deps), deps)
@@ -120,7 +120,7 @@ const x = 1;`
t.Run("default import", func(t *testing.T) {
content := `import Foo from "@web/core/foo";`
deps, requires, _ := extractImports(content)
deps, requires, _ := extractImports("test.module", content)
if len(deps) != 1 || deps[0] != "@web/core/foo" {
t.Errorf("deps = %v, want [@web/core/foo]", deps)
@@ -132,7 +132,7 @@ const x = 1;`
t.Run("namespace import", func(t *testing.T) {
content := `import * as utils from "@web/core/utils";`
deps, requires, _ := extractImports(content)
deps, requires, _ := extractImports("test.module", content)
if len(deps) != 1 || deps[0] != "@web/core/utils" {
t.Errorf("deps = %v, want [@web/core/utils]", deps)
@@ -144,7 +144,7 @@ const x = 1;`
t.Run("side-effect import", func(t *testing.T) {
content := `import "@web/core/setup";`
deps, requires, _ := extractImports(content)
deps, requires, _ := extractImports("test.module", content)
if len(deps) != 1 || deps[0] != "@web/core/setup" {
t.Errorf("deps = %v, want [@web/core/setup]", deps)
@@ -157,7 +157,7 @@ const x = 1;`
t.Run("dedup deps", func(t *testing.T) {
content := `import { Foo } from "@web/core/foo";
import { Bar } from "@web/core/foo";`
deps, _, _ := extractImports(content)
deps, _, _ := extractImports("test.module", content)
if len(deps) != 1 {
t.Errorf("expected deduped deps, got %v", deps)
@@ -167,7 +167,7 @@ import { Bar } from "@web/core/foo";`
func TestTransformExports(t *testing.T) {
t.Run("export class", func(t *testing.T) {
got := transformExports("export class Foo extends Bar {")
got, _ := transformExports("export class Foo extends Bar {")
want := "const Foo = __exports.Foo = class Foo extends Bar {"
if got != want {
t.Errorf("got %q, want %q", got, want)
@@ -175,15 +175,18 @@ func TestTransformExports(t *testing.T) {
})
t.Run("export function", func(t *testing.T) {
got := transformExports("export function doSomething(a, b) {")
want := `__exports.doSomething = function doSomething(a, b) {`
got, deferred := transformExports("export function doSomething(a, b) {")
want := `function doSomething(a, b) {`
if got != want {
t.Errorf("got %q, want %q", got, want)
}
if len(deferred) != 1 || deferred[0] != "doSomething" {
t.Errorf("deferred = %v, want [doSomething]", deferred)
}
})
t.Run("export const", func(t *testing.T) {
got := transformExports("export const MAX_SIZE = 100;")
got, _ := transformExports("export const MAX_SIZE = 100;")
want := "const MAX_SIZE = __exports.MAX_SIZE = 100;"
if got != want {
t.Errorf("got %q, want %q", got, want)
@@ -191,7 +194,7 @@ func TestTransformExports(t *testing.T) {
})
t.Run("export let", func(t *testing.T) {
got := transformExports("export let counter = 0;")
got, _ := transformExports("export let counter = 0;")
want := "let counter = __exports.counter = 0;"
if got != want {
t.Errorf("got %q, want %q", got, want)
@@ -199,7 +202,7 @@ func TestTransformExports(t *testing.T) {
})
t.Run("export default", func(t *testing.T) {
got := transformExports("export default Foo;")
got, _ := transformExports("export default Foo;")
want := `__exports[Symbol.for("default")] = Foo;`
if got != want {
t.Errorf("got %q, want %q", got, want)
@@ -207,7 +210,7 @@ func TestTransformExports(t *testing.T) {
})
t.Run("export named", func(t *testing.T) {
got := transformExports("export { Foo, Bar };")
got, _ := transformExports("export { Foo, Bar };")
if !strings.Contains(got, "__exports.Foo = Foo;") {
t.Errorf("missing Foo export in: %s", got)
}
@@ -217,7 +220,7 @@ func TestTransformExports(t *testing.T) {
})
t.Run("export named with alias", func(t *testing.T) {
got := transformExports("export { Foo as default };")
got, _ := transformExports("export { Foo as default };")
if !strings.Contains(got, "__exports.default = Foo;") {
t.Errorf("missing aliased export in: %s", got)
}

View File

@@ -20,12 +20,27 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
return
}
// Parse multipart form (max 128MB)
if err := r.ParseMultipartForm(128 << 20); err != nil {
// Limit upload size to 50MB
r.Body = http.MaxBytesReader(w, r.Body, 50<<20)
// Parse multipart form (max 50MB)
if err := r.ParseMultipartForm(50 << 20); err != nil {
http.Error(w, "File too large", http.StatusRequestEntityTooLarge)
return
}
// CSRF validation for multipart form uploads.
// Mirrors: odoo/http.py validate_csrf()
sess := GetSession(r)
if sess != nil {
csrfToken := r.FormValue("csrf_token")
if csrfToken != sess.CSRFToken {
log.Printf("upload: CSRF token mismatch for uid=%d", sess.UID)
http.Error(w, "CSRF validation failed", http.StatusForbidden)
return
}
}
file, header, err := r.FormFile("ufile")
if err != nil {
http.Error(w, "No file uploaded", http.StatusBadRequest)

View File

@@ -195,6 +195,12 @@ func generateDefaultView(modelName, viewType string) string {
return generateDefaultPivotView(m)
case "graph":
return generateDefaultGraphView(m)
case "calendar":
return generateDefaultCalendarView(m)
case "activity":
return generateDefaultActivityView(m)
case "dashboard":
return generateDefaultDashboardView(m)
default:
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
}
@@ -530,6 +536,161 @@ func generateDefaultGraphView(m *orm.Model) string {
return fmt.Sprintf("<graph>\n %s\n</graph>", strings.Join(fields, "\n "))
}
// generateDefaultCalendarView creates a calendar view with auto-detected date fields.
// The OWL CalendarArchParser requires date_start; date_stop and color are optional.
// Mirrors: odoo/addons/web/static/src/views/calendar/calendar_arch_parser.js
func generateDefaultCalendarView(m *orm.Model) string {
// Auto-detect date_start field (priority order)
dateStart := ""
for _, candidate := range []string{"start", "date_start", "date_from", "date_order", "date_begin", "date"} {
if f := m.GetField(candidate); f != nil && (f.Type == orm.TypeDatetime || f.Type == orm.TypeDate) {
dateStart = candidate
break
}
}
if dateStart == "" {
// Fallback: find any datetime/date field
for _, name := range sortedFieldNames(m) {
f := m.GetField(name)
if f != nil && (f.Type == orm.TypeDatetime || f.Type == orm.TypeDate) && f.Name != "create_date" && f.Name != "write_date" {
dateStart = name
break
}
}
}
if dateStart == "" {
// No date field found — return minimal arch that won't crash
return `<calendar date_start="create_date"><field name="display_name"/></calendar>`
}
// Auto-detect date_stop field
dateStop := ""
for _, candidate := range []string{"stop", "date_stop", "date_to", "date_end"} {
if f := m.GetField(candidate); f != nil && (f.Type == orm.TypeDatetime || f.Type == orm.TypeDate) {
dateStop = candidate
break
}
}
// Auto-detect color field (M2O fields make good color discriminators)
colorField := ""
for _, candidate := range []string{"color", "user_id", "partner_id", "stage_id"} {
if f := m.GetField(candidate); f != nil {
colorField = candidate
break
}
}
// Auto-detect all_day field
allDay := ""
for _, candidate := range []string{"allday", "all_day"} {
if f := m.GetField(candidate); f != nil && f.Type == orm.TypeBoolean {
allDay = candidate
break
}
}
// Build attributes
attrs := fmt.Sprintf(`date_start="%s"`, dateStart)
if dateStop != "" {
attrs += fmt.Sprintf(` date_stop="%s"`, dateStop)
}
if colorField != "" {
attrs += fmt.Sprintf(` color="%s"`, colorField)
}
if allDay != "" {
attrs += fmt.Sprintf(` all_day="%s"`, allDay)
}
// Pick display fields for the calendar card
var fields []string
nameField := "display_name"
if f := m.GetField("name"); f != nil {
nameField = "name"
}
fields = append(fields, fmt.Sprintf(` <field name="%s"/>`, nameField))
if f := m.GetField("partner_id"); f != nil {
fields = append(fields, ` <field name="partner_id" avatar_field="avatar_128"/>`)
}
if f := m.GetField("user_id"); f != nil && colorField != "user_id" {
fields = append(fields, ` <field name="user_id"/>`)
}
return fmt.Sprintf("<calendar %s mode=\"month\">\n%s\n</calendar>",
attrs, strings.Join(fields, "\n"))
}
// generateDefaultActivityView creates a minimal activity view.
// Mirrors: odoo/addons/mail/static/src/views/web_activity/activity_arch_parser.js
func generateDefaultActivityView(m *orm.Model) string {
nameField := "display_name"
if f := m.GetField("name"); f != nil {
nameField = "name"
}
return fmt.Sprintf(`<activity string="Activities">
<templates>
<div t-name="activity-box">
<field name="%s"/>
</div>
</templates>
</activity>`, nameField)
}
// generateDefaultDashboardView creates a dashboard view with aggregate widgets.
// Mirrors: odoo/addons/board/static/src/board_view.js
func generateDefaultDashboardView(m *orm.Model) string {
var widgets []string
// Add aggregate widgets for numeric fields
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" && f.Name != "sequence" &&
f.Name != "create_uid" && f.Name != "write_uid" && f.Name != "company_id" {
widgets = append(widgets, fmt.Sprintf(
` <aggregate name="%s" field="%s" string="%s"/>`,
f.Name, f.Name, f.String))
if len(widgets) >= 6 {
break
}
}
}
// Add a graph for the first groupable dimension
var graphField string
for _, name := range sortedFieldNames(m) {
f := m.GetField(name)
if f != nil && f.IsStored() && (f.Type == orm.TypeMany2one || f.Type == orm.TypeSelection) {
graphField = name
break
}
}
var buf strings.Builder
buf.WriteString("<dashboard>\n")
if len(widgets) > 0 {
buf.WriteString(" <group>\n")
for _, w := range widgets {
buf.WriteString(w + "\n")
}
buf.WriteString(" </group>\n")
}
if graphField != "" {
buf.WriteString(fmt.Sprintf(` <view type="graph">
<graph type="bar">
<field name="%s"/>
</graph>
</view>
`, graphField))
}
buf.WriteString("</dashboard>")
return buf.String()
}
// sortedFieldNames returns field names in alphabetical order for deterministic output.
func sortedFieldNames(m *orm.Model) []string {
fields := m.Fields()

View File

@@ -2,6 +2,7 @@ package server
import (
"fmt"
"strings"
"time"
"odoo-go/pkg/orm"
@@ -451,6 +452,110 @@ func (s *Server) handleReadGroup(rs *orm.Recordset, params CallKWParams) (interf
}
if params.Method == "web_read_group" {
// --- __fold support ---
// If the first groupby is a Many2one whose comodel has a "fold" field,
// add __fold to each group. Mirrors: odoo/addons/web/models/models.py
if len(groupby) > 0 {
fieldName := strings.SplitN(groupby[0], ":", 2)[0]
m := rs.ModelDef()
if m != nil {
f := m.GetField(fieldName)
if f != nil && f.Type == orm.TypeMany2one && f.Comodel != "" {
comodel := orm.Registry.Get(f.Comodel)
if comodel != nil && comodel.GetField("fold") != nil {
addFoldInfo(rs.Env(), f.Comodel, groupby[0], groups)
}
}
}
}
// --- __records for auto_unfold ---
autoUnfold := false
if v, ok := params.KW["auto_unfold"].(bool); ok {
autoUnfold = v
}
if autoUnfold {
unfoldReadSpec, _ := params.KW["unfold_read_specification"].(map[string]interface{})
unfoldLimit := defaultWebSearchLimit
if v, ok := params.KW["unfold_read_default_limit"].(float64); ok {
unfoldLimit = int(v)
}
// Parse original domain for combining with group domain
origDomain := parseDomain(params.Args)
if origDomain == nil {
if dr, ok := params.KW["domain"].([]interface{}); ok && len(dr) > 0 {
origDomain = parseDomain([]interface{}{dr})
}
}
modelName := rs.ModelDef().Name()
maxUnfolded := 10
unfolded := 0
for _, g := range groups {
if unfolded >= maxUnfolded {
break
}
gm := g.(map[string]interface{})
fold, _ := gm["__fold"].(bool)
count, _ := gm["__count"].(int64)
// Skip folded, empty, and groups with false/nil M2O value
// Mirrors: odoo/addons/web/models/models.py _open_groups() fold checks
if fold || count == 0 {
continue
}
// For M2O groupby: skip groups where the value is false (unset M2O)
if len(groupby) > 0 {
gbVal := gm[groupby[0]]
if gbVal == nil || gbVal == false {
continue
}
}
// Build combined domain: original + group extra domain
var combinedDomain orm.Domain
if origDomain != nil {
combinedDomain = append(combinedDomain, origDomain...)
}
if extraDom, ok := gm["__extra_domain"].([]interface{}); ok && len(extraDom) > 0 {
groupDomain := parseDomain([]interface{}{extraDom})
combinedDomain = append(combinedDomain, groupDomain...)
}
found, err := rs.Env().Model(modelName).Search(combinedDomain, orm.SearchOpts{Limit: unfoldLimit})
if err != nil || found.IsEmpty() {
gm["__records"] = []orm.Values{}
unfolded++
continue
}
fields := specToFields(unfoldReadSpec)
if len(fields) == 0 {
fields = []string{"id"}
}
hasID := false
for _, f := range fields {
if f == "id" {
hasID = true
break
}
}
if !hasID {
fields = append([]string{"id"}, fields...)
}
records, err := found.Read(fields)
if err != nil {
gm["__records"] = []orm.Values{}
unfolded++
continue
}
formatRecordsForWeb(rs.Env(), modelName, records, unfoldReadSpec)
gm["__records"] = records
unfolded++
}
}
// web_read_group: also get total group count (without limit/offset)
totalLen := len(results)
if opts.Limit > 0 || opts.Offset > 0 {
@@ -470,6 +575,203 @@ func (s *Server) handleReadGroup(rs *orm.Recordset, params CallKWParams) (interf
return groups, nil
}
// handleReadProgressBar returns per-group counts for a progress bar field.
// Mirrors: odoo/orm/models.py BaseModel._read_progress_bar()
//
// Called by the kanban view to render colored progress bars per column.
// Input (via KW):
//
// domain: search filter
// group_by: field to group columns by (e.g. "stage_id")
// progress_bar: {field: "kanban_state", colors: {"done": "success", ...}}
//
// Output:
//
// {groupByValue: {pbValue: count, ...}, ...}
//
// Where groupByValue is the raw DB value (integer ID for M2O, string for
// selection, "True"/"False" for boolean).
func (s *Server) handleReadProgressBar(rs *orm.Recordset, params CallKWParams) (interface{}, *RPCError) {
// Parse domain from KW
domain := parseDomain(params.Args)
if domain == nil {
if dr, ok := params.KW["domain"].([]interface{}); ok && len(dr) > 0 {
domain = parseDomain([]interface{}{dr})
}
}
// Parse group_by (single string)
groupBy := ""
if v, ok := params.KW["group_by"].(string); ok {
groupBy = v
}
// Parse progress_bar map
progressBar, _ := params.KW["progress_bar"].(map[string]interface{})
pbField, _ := progressBar["field"].(string)
if groupBy == "" || pbField == "" {
return map[string]interface{}{}, nil
}
// Use ReadGroup with two groupby levels: [groupBy, pbField]
results, err := rs.ReadGroup(domain, []string{groupBy, pbField}, []string{"__count"})
if err != nil {
return map[string]interface{}{}, nil
}
// Determine field types for key formatting
m := rs.ModelDef()
gbField := m.GetField(groupBy)
pbFieldDef := m.GetField(pbField)
// Build nested map: {groupByValue: {pbValue: count}}
data := make(map[string]interface{})
// Collect all known progress bar values (from colors) so we initialize zeros
pbColors, _ := progressBar["colors"].(map[string]interface{})
for _, r := range results {
// Format the group-by key
gbVal := r.GroupValues[groupBy]
gbKey := formatProgressBarKey(gbVal, gbField)
// Format the progress bar value
pbVal := r.GroupValues[pbField]
pbKey := formatProgressBarValue(pbVal, pbFieldDef)
// Initialize group entry with zero counts if first time
if _, exists := data[gbKey]; !exists {
entry := make(map[string]interface{})
for colorKey := range pbColors {
entry[colorKey] = 0
}
data[gbKey] = entry
}
// Add count
entry := data[gbKey].(map[string]interface{})
existing, _ := entry[pbKey].(int)
entry[pbKey] = existing + int(r.Count)
}
return data, nil
}
// formatProgressBarKey formats a group-by value as the string key expected
// by the frontend progress bar.
// - M2O: integer ID (as string)
// - Boolean: "True" / "False"
// - nil/false: "False"
// - Other: value as string
func formatProgressBarKey(val interface{}, f *orm.Field) string {
if val == nil || val == false {
return "False"
}
// M2O: ReadGroup resolves to [id, name] pair — use the id
if f != nil && f.Type == orm.TypeMany2one {
switch v := val.(type) {
case []interface{}:
if len(v) > 0 {
return fmt.Sprintf("%v", v[0])
}
return "False"
case int64:
return fmt.Sprintf("%d", v)
case float64:
return fmt.Sprintf("%d", int64(v))
case int:
return fmt.Sprintf("%d", v)
}
}
// Boolean
if f != nil && f.Type == orm.TypeBoolean {
switch v := val.(type) {
case bool:
if v {
return "True"
}
return "False"
}
}
return fmt.Sprintf("%v", val)
}
// formatProgressBarValue formats a progress bar field value as a string key.
// Selection fields use the raw value (e.g. "done", "blocked").
// Boolean fields use "True"/"False".
func formatProgressBarValue(val interface{}, f *orm.Field) string {
if val == nil || val == false {
return "False"
}
if f != nil && f.Type == orm.TypeBoolean {
switch v := val.(type) {
case bool:
if v {
return "True"
}
return "False"
}
}
return fmt.Sprintf("%v", val)
}
// addFoldInfo reads the "fold" boolean from the comodel records referenced
// by each group and sets __fold on the group maps accordingly.
func addFoldInfo(env *orm.Environment, comodel string, groupbySpec string, groups []interface{}) {
// Collect IDs from group values (M2O pairs like [id, name])
var ids []int64
for _, g := range groups {
gm := g.(map[string]interface{})
val := gm[groupbySpec]
if pair, ok := val.([]interface{}); ok && len(pair) >= 1 {
if id, ok := orm.ToRecordID(pair[0]); ok && id > 0 {
ids = append(ids, id)
}
}
}
if len(ids) == 0 {
// All groups have false/empty value — fold them by default
for _, g := range groups {
gm := g.(map[string]interface{})
gm["__fold"] = false
}
return
}
// Read fold values from comodel
rs := env.Model(comodel).Browse(ids...)
records, err := rs.Read([]string{"id", "fold"})
if err != nil {
return
}
// Build fold map
foldMap := make(map[int64]bool)
for _, rec := range records {
id, _ := orm.ToRecordID(rec["id"])
fold, _ := rec["fold"].(bool)
foldMap[id] = fold
}
// Apply to groups
for _, g := range groups {
gm := g.(map[string]interface{})
val := gm[groupbySpec]
if pair, ok := val.([]interface{}); ok && len(pair) >= 1 {
if id, ok := orm.ToRecordID(pair[0]); ok {
gm["__fold"] = foldMap[id]
}
} else {
// false/empty group value
gm["__fold"] = false
}
}
}
// formatDateFields converts date/datetime values to Odoo's expected string format.
func formatDateFields(model string, records []orm.Values) {
m := orm.Registry.Get(model)

View File

@@ -2,6 +2,7 @@ package server
import (
"bufio"
"context"
"embed"
"encoding/json"
"fmt"
@@ -73,6 +74,14 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
return
}
// Check if post-setup wizard is needed (first login, company not configured)
if s.isPostSetupNeeded() {
if sess := GetSession(r); sess != nil {
http.Redirect(w, r, "/web/setup/wizard", http.StatusFound)
return
}
}
// Check authentication
sess := GetSession(r)
if sess == nil {
@@ -141,7 +150,7 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
%s
<script>
var odoo = {
csrf_token: "dummy",
csrf_token: "%s",
debug: "assets",
__session_info__: %s,
reloadMenus: function() {
@@ -178,12 +187,18 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
%s</head>
<body class="o_web_client">
</body>
</html>`, linkTags.String(), sessionInfoJSON, scriptTags.String())
</html>`, linkTags.String(), sess.CSRFToken, sessionInfoJSON, scriptTags.String())
}
// buildSessionInfo constructs the session_info JSON object expected by the webclient.
// Mirrors: odoo/addons/web/models/ir_http.py session_info()
func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
// Build allowed_company_ids from session (populated at login)
allowedIDs := sess.AllowedCompanyIDs
if len(allowedIDs) == 0 {
allowedIDs = []int64{sess.CompanyID}
}
return map[string]interface{}{
"session_id": sess.ID,
"uid": sess.UID,
@@ -194,7 +209,7 @@ func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
"user_context": map[string]interface{}{
"lang": "en_US",
"tz": "UTC",
"allowed_company_ids": []int64{sess.CompanyID},
"allowed_company_ids": allowedIDs,
},
"db": s.config.DBName,
"registry_hash": fmt.Sprintf("odoo-go-%d", time.Now().Unix()),
@@ -213,7 +228,7 @@ func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
"current_menu": 1,
"support_url": "",
"notification_type": "email",
"display_switch_company_menu": false,
"display_switch_company_menu": len(allowedIDs) > 1,
"test_mode": false,
"show_effect": true,
"currencies": map[string]interface{}{
@@ -226,20 +241,7 @@ func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
"lang": "en_US",
"debug": "assets",
},
"user_companies": map[string]interface{}{
"current_company": sess.CompanyID,
"allowed_companies": map[string]interface{}{
fmt.Sprintf("%d", sess.CompanyID): map[string]interface{}{
"id": sess.CompanyID,
"name": "My Company",
"sequence": 10,
"child_ids": []int64{},
"parent_id": false,
"currency_id": 1,
},
},
"disallowed_ancestor_companies": map[string]interface{}{},
},
"user_companies": s.buildUserCompanies(sess.CompanyID, allowedIDs),
"user_settings": map[string]interface{}{
"id": 1,
"user_id": map[string]interface{}{"id": sess.UID, "display_name": sess.Login},
@@ -365,3 +367,105 @@ func (s *Server) handleTranslations(w http.ResponseWriter, r *http.Request) {
"multi_lang": multiLang,
})
}
// buildUserCompanies queries company data and builds the user_companies dict
// for the session_info response. Mirrors: odoo/addons/web/models/ir_http.py
func (s *Server) buildUserCompanies(currentCompanyID int64, allowedIDs []int64) map[string]interface{} {
allowedCompanies := make(map[string]interface{})
// Batch query all companies at once
rows, err := s.pool.Query(context.Background(),
`SELECT id, COALESCE(name, 'Company'), COALESCE(currency_id, 1)
FROM res_company WHERE id = ANY($1)`, allowedIDs)
if err == nil {
defer rows.Close()
for rows.Next() {
var cid, currencyID int64
var name string
if rows.Scan(&cid, &name, &currencyID) == nil {
allowedCompanies[fmt.Sprintf("%d", cid)] = map[string]interface{}{
"id": cid,
"name": name,
"sequence": 10,
"child_ids": []int64{},
"parent_id": false,
"currency_id": currencyID,
}
}
}
}
// Fallback for any IDs not found in DB
for _, cid := range allowedIDs {
key := fmt.Sprintf("%d", cid)
if _, exists := allowedCompanies[key]; !exists {
allowedCompanies[key] = map[string]interface{}{
"id": cid, "name": fmt.Sprintf("Company %d", cid),
"sequence": 10, "child_ids": []int64{}, "parent_id": false, "currency_id": int64(1),
}
}
}
return map[string]interface{}{
"current_company": currentCompanyID,
"allowed_companies": allowedCompanies,
"disallowed_ancestor_companies": map[string]interface{}{},
}
}
// handleSwitchCompany switches the active company for the current session.
func (s *Server) handleSwitchCompany(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
sess := GetSession(r)
if sess == nil {
s.writeJSONRPC(w, nil, nil, &RPCError{Code: 100, Message: "Not authenticated"})
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 {
CompanyID int64 `json:"company_id"`
}
if err := json.Unmarshal(req.Params, &params); err != nil || params.CompanyID == 0 {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid company_id"})
return
}
// Validate company is in allowed list
allowed := false
for _, cid := range sess.AllowedCompanyIDs {
if cid == params.CompanyID {
allowed = true
break
}
}
if !allowed {
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: 403, Message: "Company not in allowed list"})
return
}
// Update session
sess.CompanyID = params.CompanyID
// Persist to DB
if s.sessions.pool != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.sessions.pool.Exec(ctx,
`UPDATE sessions SET company_id = $1 WHERE id = $2`, params.CompanyID, sess.ID)
}
s.writeJSONRPC(w, req.ID, map[string]interface{}{
"company_id": params.CompanyID,
"result": true,
}, nil)
}