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>
228 lines
6.4 KiB
Go
228 lines
6.4 KiB
Go
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"`
|
|
}
|