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

@@ -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()