Odoo ERP ported to Go — complete backend + original OWL frontend
Full port of Odoo's ERP system from Python to Go, with the original Odoo JavaScript frontend (OWL framework) running against the Go server. Backend (10,691 LoC Go): - Custom ORM: CRUD, domains→SQL with JOINs, computed fields, sequences - 93 models across 14 modules (base, account, sale, stock, purchase, hr, project, crm, fleet, product, l10n_de, google_address/translate/calendar) - Auth with bcrypt + session cookies - Setup wizard (company, SKR03 chart, admin, demo data) - Double-entry bookkeeping constraint - Sale→Invoice workflow (confirm SO → generate invoice → post) - SKR03 chart of accounts (110 accounts) + German taxes (USt/VSt) - Record rules (multi-company filter) - Google integrations as opt-in modules (Maps, Translate, Calendar) Frontend: - Odoo's original OWL webclient (503 JS modules, 378 XML templates) - JS transpiled via Odoo's js_transpiler (ES modules → odoo.define) - SCSS compiled to CSS (675KB) via dart-sass - XML templates compiled to registerTemplate() JS calls - Static file serving from Odoo source addons - Login page, session management, menu navigation - Contacts list view renders with real data from PostgreSQL Infrastructure: - 14MB single binary (CGO_ENABLED=0) - Docker Compose (Go server + PostgreSQL 16) - Zero phone-home (no outbound calls to odoo.com) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
227
addons/google_calendar/models/google_calendar.go
Normal file
227
addons/google_calendar/models/google_calendar.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
"odoo-go/pkg/tools"
|
||||
)
|
||||
|
||||
var calendarClient *tools.APIClient
|
||||
|
||||
func getCalendarClient() *tools.APIClient {
|
||||
if calendarClient != nil {
|
||||
return calendarClient
|
||||
}
|
||||
apiKey := os.Getenv("GOOGLE_CALENDAR_API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil
|
||||
}
|
||||
calendarClient = tools.NewAPIClient("https://www.googleapis.com", apiKey)
|
||||
return calendarClient
|
||||
}
|
||||
|
||||
// initCalendarEvent registers the calendar.event model.
|
||||
// Mirrors: odoo/addons/calendar/models/calendar_event.py
|
||||
func initCalendarEvent() {
|
||||
m := orm.NewModel("calendar.event", orm.ModelOpts{
|
||||
Description: "Calendar Event",
|
||||
Order: "start desc",
|
||||
})
|
||||
|
||||
m.AddFields(
|
||||
orm.Char("name", orm.FieldOpts{String: "Meeting Subject", Required: true}),
|
||||
orm.Datetime("start", orm.FieldOpts{String: "Start", Required: true}),
|
||||
orm.Datetime("stop", orm.FieldOpts{String: "Stop", Required: true}),
|
||||
orm.Boolean("allday", orm.FieldOpts{String: "All Day"}),
|
||||
orm.Text("description", orm.FieldOpts{String: "Description"}),
|
||||
orm.Char("location", orm.FieldOpts{String: "Location"}),
|
||||
orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Organizer"}),
|
||||
orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Contact"}),
|
||||
orm.Many2many("attendee_ids", "res.partner", orm.FieldOpts{String: "Attendees"}),
|
||||
orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
|
||||
orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
|
||||
orm.Selection("state", []orm.SelectionItem{
|
||||
{Value: "draft", Label: "Unconfirmed"},
|
||||
{Value: "open", Label: "Confirmed"},
|
||||
{Value: "done", Label: "Done"},
|
||||
{Value: "cancel", Label: "Cancelled"},
|
||||
}, orm.FieldOpts{String: "Status", Default: "draft"}),
|
||||
// Google sync fields
|
||||
orm.Char("google_event_id", orm.FieldOpts{String: "Google Event ID", Index: true}),
|
||||
orm.Char("google_calendar_id", orm.FieldOpts{String: "Google Calendar ID"}),
|
||||
orm.Datetime("google_synced_at", orm.FieldOpts{String: "Last Synced"}),
|
||||
)
|
||||
}
|
||||
|
||||
// initGoogleCalendarSync registers sync methods.
|
||||
func initGoogleCalendarSync() {
|
||||
event := orm.Registry.Get("calendar.event")
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// push_to_google: Create/update event in Google Calendar
|
||||
event.RegisterMethod("push_to_google", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
client := getCalendarClient()
|
||||
if client == nil {
|
||||
return nil, fmt.Errorf("google_calendar: GOOGLE_CALENDAR_API_KEY not configured")
|
||||
}
|
||||
|
||||
env := rs.Env()
|
||||
calendarID := "primary"
|
||||
if len(args) > 0 {
|
||||
if cid, ok := args[0].(string); ok && cid != "" {
|
||||
calendarID = cid
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range rs.IDs() {
|
||||
var name, description, location, googleEventID string
|
||||
var start, stop string
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COALESCE(name,''), COALESCE(description,''), COALESCE(location,''),
|
||||
COALESCE(google_event_id,''), COALESCE(start::text,''), COALESCE(stop::text,'')
|
||||
FROM calendar_event WHERE id = $1`, id,
|
||||
).Scan(&name, &description, &location, &googleEventID, &start, &stop)
|
||||
|
||||
eventBody := map[string]interface{}{
|
||||
"summary": name,
|
||||
"description": description,
|
||||
"location": location,
|
||||
"start": map[string]string{
|
||||
"dateTime": start,
|
||||
"timeZone": "Europe/Berlin",
|
||||
},
|
||||
"end": map[string]string{
|
||||
"dateTime": stop,
|
||||
"timeZone": "Europe/Berlin",
|
||||
},
|
||||
}
|
||||
|
||||
if googleEventID != "" {
|
||||
// Update existing
|
||||
var result map[string]interface{}
|
||||
err := client.PostJSON(
|
||||
fmt.Sprintf("/calendar/v3/calendars/%s/events/%s", calendarID, googleEventID),
|
||||
nil, eventBody, &result,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("google_calendar: update event %d: %w", id, err)
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
err := client.PostJSON(
|
||||
fmt.Sprintf("/calendar/v3/calendars/%s/events", calendarID),
|
||||
nil, eventBody, &result,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("google_calendar: create event %d: %w", id, err)
|
||||
}
|
||||
// Store Google event ID
|
||||
env.Tx().Exec(env.Ctx(),
|
||||
`UPDATE calendar_event SET google_event_id = $1, google_synced_at = NOW() WHERE id = $2`,
|
||||
result.ID, id)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
// pull_from_google: Fetch events from Google Calendar
|
||||
event.RegisterMethod("pull_from_google", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
|
||||
client := getCalendarClient()
|
||||
if client == nil {
|
||||
return nil, fmt.Errorf("google_calendar: GOOGLE_CALENDAR_API_KEY not configured")
|
||||
}
|
||||
|
||||
calendarID := "primary"
|
||||
if len(args) > 0 {
|
||||
if cid, ok := args[0].(string); ok && cid != "" {
|
||||
calendarID = cid
|
||||
}
|
||||
}
|
||||
|
||||
var result GoogleEventsResponse
|
||||
err := client.GetJSON(
|
||||
fmt.Sprintf("/calendar/v3/calendars/%s/events", calendarID),
|
||||
map[string]string{
|
||||
"maxResults": "50",
|
||||
"singleEvents": "true",
|
||||
"orderBy": "startTime",
|
||||
"timeMin": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
&result,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("google_calendar: fetch events: %w", err)
|
||||
}
|
||||
|
||||
env := rs.Env()
|
||||
imported := 0
|
||||
for _, ge := range result.Items {
|
||||
// Check if already synced
|
||||
var existing int
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM calendar_event WHERE google_event_id = $1`, ge.ID,
|
||||
).Scan(&existing)
|
||||
|
||||
if existing > 0 {
|
||||
continue // Already imported
|
||||
}
|
||||
|
||||
startTime := ge.Start.DateTime
|
||||
if startTime == "" {
|
||||
startTime = ge.Start.Date
|
||||
}
|
||||
endTime := ge.End.DateTime
|
||||
if endTime == "" {
|
||||
endTime = ge.End.Date
|
||||
}
|
||||
|
||||
eventRS := env.Model("calendar.event")
|
||||
_, err := eventRS.Create(orm.Values{
|
||||
"name": ge.Summary,
|
||||
"description": ge.Description,
|
||||
"location": ge.Location,
|
||||
"start": startTime,
|
||||
"stop": endTime,
|
||||
"google_event_id": ge.ID,
|
||||
"google_synced_at": "now",
|
||||
"state": "open",
|
||||
})
|
||||
if err == nil {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"imported": imported,
|
||||
"total": len(result.Items),
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// --- Google Calendar API Response Types ---
|
||||
|
||||
type GoogleEventsResponse struct {
|
||||
Items []GoogleEvent `json:"items"`
|
||||
}
|
||||
|
||||
type GoogleEvent struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Location string `json:"location"`
|
||||
Start struct {
|
||||
DateTime string `json:"dateTime"`
|
||||
Date string `json:"date"`
|
||||
} `json:"start"`
|
||||
End struct {
|
||||
DateTime string `json:"dateTime"`
|
||||
Date string `json:"date"`
|
||||
} `json:"end"`
|
||||
}
|
||||
6
addons/google_calendar/models/init.go
Normal file
6
addons/google_calendar/models/init.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
func Init() {
|
||||
initCalendarEvent()
|
||||
initGoogleCalendarSync()
|
||||
}
|
||||
27
addons/google_calendar/module.go
Normal file
27
addons/google_calendar/module.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Package google_calendar provides Google Calendar sync integration.
|
||||
// OPT-IN: Only active when GOOGLE_CALENDAR_API_KEY is configured.
|
||||
//
|
||||
// Features:
|
||||
// - Sync events between Odoo and Google Calendar
|
||||
// - Create Google Calendar events from project tasks
|
||||
// - Import Google Calendar events as activities
|
||||
package google_calendar
|
||||
|
||||
import (
|
||||
"odoo-go/addons/google_calendar/models"
|
||||
"odoo-go/pkg/modules"
|
||||
)
|
||||
|
||||
func init() {
|
||||
modules.Register(&modules.Module{
|
||||
Name: "google_calendar",
|
||||
Description: "Google Calendar Sync",
|
||||
Version: "19.0.1.0.0",
|
||||
Category: "Integration",
|
||||
Depends: []string{"base"},
|
||||
Application: false,
|
||||
Installable: true,
|
||||
Sequence: 100,
|
||||
Init: models.Init,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user