diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acf9a66e..fb93969b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.26.9" cache: true - name: Build all packages @@ -46,7 +46,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.26.9" cache: true - name: Module static tests @@ -84,7 +84,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.26.9" cache: true - name: Security bypass lint @@ -115,7 +115,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.26.9" cache: true - name: govulncheck @@ -165,7 +165,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.26.9" cache: true - name: Regenerate zimports.go @@ -206,7 +206,7 @@ jobs: # - uses: actions/setup-go@v7 # with: - # go-version: "1.26.6" + # go-version: "1.26.9" # cache: true # - name: Bootstrap kernel schema diff --git a/addons/automation/actions.go b/addons/automation/actions.go index 4dec9229..499039b1 100644 --- a/addons/automation/actions.go +++ b/addons/automation/actions.go @@ -3,6 +3,7 @@ package automation import ( "context" "encoding/json" + "fmt" "strings" "sumeru/core/applog" @@ -10,34 +11,59 @@ import ( "sumeru/core/orm" ) -// executeServerAction runs a matching sys.server.action row for an event. -// Code conventions (declarative, no script engine): -// - publish: — publish a follow-up event (payload copied from trigger) -// - write: — ORM write on payload model/id -func executeServerAction(ctx context.Context, row map[string]interface{}, ev event.Event) error { +func actionApplies(ctx context.Context, row map[string]interface{}, ev event.Event) bool { actionModel := strings.TrimSpace(orm.AsString(row["model"])) - if actionModel != "" { - evModel := strings.TrimSpace(orm.AsString(ev.Payload["model"])) - if evModel != "" && actionModel != evModel { - return nil + evModel := strings.TrimSpace(orm.AsString(ev.Payload["model"])) + if ev.Name == "cron.tick" { + if actionModel != "" || strings.TrimSpace(orm.AsString(row["trigger_domain"])) != "" { + return false } } - if domRaw := strings.TrimSpace(orm.AsString(row["trigger_domain"])); domRaw != "" { - modelName := strings.TrimSpace(orm.AsString(ev.Payload["model"])) - resID, ok := orm.CoerceInt64(ev.Payload["id"]) - if modelName == "" || !ok || resID <= 0 { - return nil - } - var domain [][]interface{} - if err := json.Unmarshal([]byte(domRaw), &domain); err != nil { - return nil - } - matches, err := orm.SearchCount(ctx, modelName, append(domain, []interface{}{"id", "=", resID})) - if err != nil || matches == 0 { - return nil + if actionModel != "" && evModel != "" && actionModel != evModel { + return false + } + domRaw := strings.TrimSpace(orm.AsString(row["trigger_domain"])) + if ev.Name == orm.EventRecordDeleted { + if domRaw == "" { + return true } + actionID, _ := orm.CoerceInt64(row["id"]) + resID, ok := orm.CoerceInt64(ev.Payload["id"]) + return ok && deleteActionMatched(evModel, int(resID), actionID) + } + if domRaw == "" { + return true + } + resID, ok := orm.CoerceInt64(ev.Payload["id"]) + if evModel == "" || !ok || resID <= 0 { + return false + } + var domain [][]interface{} + if err := json.Unmarshal([]byte(domRaw), &domain); err != nil { + applog.Warn(ctx, applog.Event{ + Message: "server action domain JSON invalid", + Component: "automation", + Operation: "server_action", + Status: "failed", + Context: map[string]interface{}{"action": row["name"]}, + Err: err, + }) + return false } + matches, err := orm.SearchCount(ctx, evModel, append(domain, []interface{}{"id", "=", resID})) + return err == nil && matches > 0 +} +// executeServerAction runs a matching sys.server.action row for an event. +// Code conventions (declarative, no script engine): +// - publish: — publish a follow-up event (payload copied from trigger) +// - write: — ORM write on payload model/id +// - webhook: +// - activity:| +func executeServerAction(ctx context.Context, row map[string]interface{}, ev event.Event) error { + if !actionApplies(ctx, row, ev) { + return nil + } code := strings.TrimSpace(orm.AsString(row["code"])) if code == "" { return nil @@ -45,6 +71,9 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve switch { case strings.HasPrefix(code, "publish:"): + if depthOf(ctx) >= automationDepthCap { + return fmt.Errorf("automation depth exceeded") + } name := strings.TrimSpace(strings.TrimPrefix(code, "publish:")) if name == "" { return nil @@ -53,7 +82,7 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve if payload == nil { payload = map[string]interface{}{} } - errs := event.Publish(ctx, event.Event{Name: name, Actor: ev.Actor, Payload: payload}) + errs := event.Publish(withDepth(ctx, depthOf(ctx)+1), event.Event{Name: name, Actor: ev.Actor, Payload: payload}) if len(errs) > 0 { return errs[0] } @@ -81,6 +110,9 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve if modelName == "" || !ok || resID <= 0 { return nil } + if err := rejectUnsafeWrite(modelName, vals); err != nil { + return err + } return orm.UpdateRecordByID(ctx, modelName, int(resID), vals) case strings.HasPrefix(code, "webhook:"): @@ -90,6 +122,9 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve } return dispatchWebhook(ctx, url, ev) + case strings.HasPrefix(code, "activity:"): + return createActivity(ctx, strings.TrimPrefix(code, "activity:"), ev) + default: applog.DebugMsg(ctx, "module", "automation", "server action code not executed (unknown prefix)", @@ -97,3 +132,37 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve } return nil } + +func createActivity(ctx context.Context, spec string, ev event.Event) error { + spec = strings.TrimSpace(spec) + xmlid, summary, _ := strings.Cut(spec, "|") + xmlid = strings.TrimSpace(xmlid) + summary = strings.TrimSpace(summary) + modelName := strings.TrimSpace(orm.AsString(ev.Payload["model"])) + resID, ok := orm.CoerceInt64(ev.Payload["id"]) + if xmlid == "" || modelName == "" || !ok || resID <= 0 { + return fmt.Errorf("activity action requires type, model, and id") + } + if _, registered := orm.Registry["mail.activity"]; !registered { + return fmt.Errorf("mail.activity is not installed") + } + typeID, typeModel, err := orm.ResolveXmlId(ctx, xmlid) + if err != nil || typeModel != "mail.activity.type" || typeID <= 0 { + return fmt.Errorf("unknown activity type %q", xmlid) + } + if summary == "" { + summary = xmlid + } + vals := map[string]interface{}{ + "model": modelName, + "res_id": int(resID), + "activity_type_id": typeID, + "summary": summary, + "state": "planned", + } + if ev.Actor > 0 { + vals["user_id"] = ev.Actor + } + _, err = orm.Create(ctx, orm.Registry["mail.activity"], vals) + return err +} diff --git a/addons/automation/hooks.go b/addons/automation/hooks.go index 0ea52744..a7ff48db 100644 --- a/addons/automation/hooks.go +++ b/addons/automation/hooks.go @@ -2,6 +2,7 @@ package automation import ( "context" + "strings" "sumeru/core/applog" "sumeru/core/event" @@ -15,7 +16,9 @@ func init() { func registerHooks() { event.Subscribe("record.created", runServerActionsForEvent) event.Subscribe("record.updated", runServerActionsForEvent) + event.Subscribe("record.deleted", runServerActionsForEvent) event.Subscribe("cron.tick", runServerActionsForEvent) + orm.RegisterBeforeDelete(matchBeforeDelete) } func runServerActionsForEvent(ctx context.Context, ev event.Event) error { @@ -25,6 +28,10 @@ func runServerActionsForEvent(ctx context.Context, ev event.Event) error { if _, ok := orm.Registry["sys.server.action"]; !ok { return nil } + modelName := strings.TrimSpace(orm.AsString(ev.Payload["model"])) + if automationSkippedModel(modelName) { + return nil + } return orm.WithElevated(ctx, "automation.server_action", func(bypass context.Context) error { installed, err := orm.InstalledModuleNames(bypass) if err != nil || !orm.ShouldMaterializeModel("sys.server.action", installed) { @@ -37,9 +44,29 @@ func runServerActionsForEvent(ctx context.Context, ev event.Event) error { if err != nil { return err } + runCtx := actorContext(ctx, ev.Actor) for _, row := range rows { - if err := executeServerAction(ctx, row, ev); err != nil { - applog.Warn(ctx, applog.Event{ + if !actionApplies(runCtx, row, ev) { + continue + } + actionID, _ := orm.CoerceInt64(row["id"]) + resID, _ := orm.CoerceInt64(ev.Payload["id"]) + claimed, err := claimRun(runCtx, actionID, ev, modelName, resID) + if err != nil { + applog.Warn(runCtx, applog.Event{ + Message: "server action log failed", + Component: "automation", + Operation: "server_action", + Status: "failed", + Err: err, + }) + continue + } + if !claimed { + continue + } + if err := executeServerAction(runCtx, row, ev); err != nil { + applog.Warn(runCtx, applog.Event{ Message: "server action failed", Component: "automation", Operation: "server_action", diff --git a/addons/automation/init.go b/addons/automation/init.go index 29e5d501..175d309d 100644 --- a/addons/automation/init.go +++ b/addons/automation/init.go @@ -6,5 +6,6 @@ import ( _ "sumeru/addons/base" _ "sumeru/addons/im" _ "sumeru/addons/audit" + _ "sumeru/addons/mail" _ "sumeru/addons/automation/models" ) diff --git a/addons/automation/manifest.json b/addons/automation/manifest.json index 3025556e..81bd9738 100644 --- a/addons/automation/manifest.json +++ b/addons/automation/manifest.json @@ -4,7 +4,7 @@ "version": "1.0.0", "author": "Sumeru", "description": "Scheduled jobs, workflow transitions, server actions, and event bindings.", - "depends": ["base", "audit", "im"], + "depends": ["base", "audit", "im", "mail"], "application": false, "data": [ "data/platform_bulk_job_cron.xml", diff --git a/addons/automation/models/sys_server_action.go b/addons/automation/models/sys_server_action.go index 08e089b1..388bf4d1 100644 --- a/addons/automation/models/sys_server_action.go +++ b/addons/automation/models/sys_server_action.go @@ -12,6 +12,6 @@ type SysServerAction struct { EventName sdk.String `sumeru:"index,string=On Event"` TriggerOn sdk.String `sumeru:"string=Trigger On,default=event"` TriggerDomain sdk.Text `sumeru:"string=Trigger Domain"` - Code sdk.Text `sumeru:"string=Code / Notes"` + Code sdk.Text `sumeru:"string=Code,help=publish:event write:json webhook:https://host/path activity:module.xmlid|summary"` Active sdk.Boolean `sumeru:"string=Active,default=true"` } diff --git a/addons/automation/models/sys_server_action_log.go b/addons/automation/models/sys_server_action_log.go new file mode 100644 index 00000000..7379d4bb --- /dev/null +++ b/addons/automation/models/sys_server_action_log.go @@ -0,0 +1,15 @@ +package models + +import "sumeru/core/sdk" + +type SysServerActionLog struct { + sdk.Model `sumeru:"model=sys.server.action.log"` + + ActionID sdk.Many2One[SysServerAction] `sumeru:"required,index,string=Action"` + EventName sdk.String `sumeru:"required,index,string=Event"` + ResModel sdk.String `sumeru:"index,column=model,string=Model"` + ResID sdk.Integer `sumeru:"index,string=Record"` + RanAt sdk.DateTime `sumeru:"required,string=Ran At"` + DedupeKey sdk.String `sumeru:"required,unique,string=Dedupe Key"` + Detail sdk.Text `sumeru:"string=Detail"` +} diff --git a/addons/automation/models/zmodels.go b/addons/automation/models/zmodels.go index 91d28d57..32889eed 100644 --- a/addons/automation/models/zmodels.go +++ b/addons/automation/models/zmodels.go @@ -8,6 +8,7 @@ func init() { sdk.MustRegister("automation", &SysCron{}, &SysServerAction{}, + &SysServerActionLog{}, &SysWorkflowTransition{}, ) } diff --git a/addons/automation/safety.go b/addons/automation/safety.go new file mode 100644 index 00000000..27800c45 --- /dev/null +++ b/addons/automation/safety.go @@ -0,0 +1,175 @@ +package automation + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "sumeru/core/applog" + "sumeru/core/event" + "sumeru/core/orm" + "sumeru/core/security" +) + +const automationDepthCap = 5 + +type depthKey struct{} + +func withDepth(ctx context.Context, n int) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, depthKey{}, n) +} + +func depthOf(ctx context.Context) int { + if ctx == nil { + return 0 + } + n, _ := ctx.Value(depthKey{}).(int) + return n +} + +func actorContext(ctx context.Context, actor int) context.Context { + out := orm.ContextWithUID(context.Background(), actor) + if cid := orm.CompanyIDFromContext(ctx); cid > 0 { + out = orm.ContextWithCompanyID(out, cid) + } + return withDepth(out, depthOf(ctx)) +} + +func automationSkippedModel(model string) bool { + switch strings.TrimSpace(model) { + case "mail.message", "sys.audit", "sys.server.action", "sys.server.action.log", "sys.outbox.event": + return true + default: + return false + } +} + +var deleteMatches sync.Map + +func deleteMatchKey(model string, id int) string { + return model + "|" + fmt.Sprint(id) +} + +func noteDeleteMatch(model string, id int, actionIDs map[int64]struct{}) { + deleteMatches.Store(deleteMatchKey(model, id), actionIDs) +} + +func deleteActionMatched(model string, id int, actionID int64) bool { + raw, ok := deleteMatches.Load(deleteMatchKey(model, id)) + if !ok { + return false + } + set, _ := raw.(map[int64]struct{}) + _, hit := set[actionID] + return hit +} + +func matchBeforeDelete(ctx context.Context, model string, id int, before map[string]interface{}) { + if orm.DB == nil || id <= 0 || automationSkippedModel(model) { + return + } + if _, ok := orm.Registry["sys.server.action"]; !ok { + return + } + _ = orm.WithElevated(ctx, "automation.delete_match", func(bypass context.Context) error { + rows, err := orm.Search(bypass, "sys.server.action", [][]interface{}{ + {"event_name", "=", orm.EventRecordDeleted}, + {"active", "=", true}, + }) + if err != nil { + return err + } + matched := map[int64]struct{}{} + for _, row := range rows { + actionModel := strings.TrimSpace(orm.AsString(row["model"])) + if actionModel != "" && actionModel != model { + continue + } + domRaw := strings.TrimSpace(orm.AsString(row["trigger_domain"])) + if domRaw == "" { + continue + } + var parsed [][]interface{} + if err := json.Unmarshal([]byte(domRaw), &parsed); err != nil { + applog.Warn(bypass, applog.Event{ + Message: "server action domain JSON invalid", + Component: "automation", + Operation: "server_action", + Status: "failed", + Context: map[string]interface{}{"action": row["name"]}, + Err: err, + }) + continue + } + if !orm.RecordMatchesDomain(model, before, parsed) { + continue + } + actionID, ok := orm.CoerceInt64(row["id"]) + if ok { + matched[actionID] = struct{}{} + } + } + noteDeleteMatch(model, id, matched) + return nil + }) +} + +func claimRun(ctx context.Context, actionID int64, ev event.Event, model string, resID int64) (bool, error) { + if orm.DB == nil || actionID <= 0 { + return true, nil + } + if _, ok := orm.Registry["sys.server.action.log"]; !ok { + return true, nil + } + bucket := time.Now().UTC().Unix() / 60 + key := fmt.Sprintf("%d|%s|%s|%d|%d", actionID, ev.Name, model, resID, bucket) + claimed := false + err := orm.WithElevated(ctx, "automation.action_log", func(bypass context.Context) error { + inst := orm.Registry["sys.server.action.log"] + _, err := orm.Create(bypass, inst, map[string]interface{}{ + "action_id": actionID, + "event_name": ev.Name, + "model": model, + "res_id": resID, + "ran_at": time.Now().UTC().Format(time.RFC3339), + "dedupe_key": key, + "detail": "run", + }) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "duplicate") || strings.Contains(strings.ToLower(err.Error()), "unique") { + return nil + } + return err + } + claimed = true + return nil + }) + return claimed, err +} + +func rejectUnsafeWrite(modelName string, vals map[string]interface{}) error { + inst := orm.RegistryModel(modelName) + if inst == nil { + return fmt.Errorf("unknown model %q", modelName) + } + known := map[string]struct{}{} + for _, field := range inst.Fields() { + known[field.Name] = struct{}{} + } + policies := security.FieldRegistry[modelName] + for key := range vals { + if _, ok := known[key]; !ok { + return fmt.Errorf("unknown field %q on %s", key, modelName) + } + if pol, ok := policies[key]; ok && (pol.ReadRedact || pol.WriteDenyDirect || pol.WriteDenyUnlessSys) { + return fmt.Errorf("field %q cannot be set by a server action", key) + } + } + return nil +} diff --git a/addons/automation/security/sys.access.csv b/addons/automation/security/sys.access.csv index e1416f11..f8c2206d 100644 --- a/addons/automation/security/sys.access.csv +++ b/addons/automation/security/sys.access.csv @@ -5,3 +5,4 @@ access_sys_workflow_user,access_sys_workflow_user,sys.workflow.transition,base.g access_sys_workflow_manager,access_sys_workflow_manager,sys.workflow.transition,automation.group_automation_manager,1,1,1,1 access_sys_server_action_user,access_sys_server_action_user,sys.server.action,base.group_user,1,0,0,0 access_sys_server_action_manager,access_sys_server_action_manager,sys.server.action,automation.group_automation_manager,1,1,1,1 +access_sys_server_action_log_manager,access_sys_server_action_log_manager,sys.server.action.log,automation.group_automation_manager,1,0,0,0 diff --git a/addons/automation/testexports.go b/addons/automation/testexports.go index 1b2e1068..246e883d 100644 --- a/addons/automation/testexports.go +++ b/addons/automation/testexports.go @@ -14,6 +14,10 @@ func ExecuteServerActionForTest(ctx context.Context, row map[string]interface{}, return executeServerAction(ctx, row, ev) } +func WithAutomationDepthForTest(ctx context.Context, n int) context.Context { + return withDepth(ctx, n) +} + func RunServerActionsForEventForTest(ctx context.Context, ev event.Event) error { return runServerActionsForEvent(ctx, ev) } diff --git a/addons/automation/views/sys_server_action_form_views.xml b/addons/automation/views/sys_server_action_form_views.xml index e30fa7a0..fcf65384 100644 --- a/addons/automation/views/sys_server_action_form_views.xml +++ b/addons/automation/views/sys_server_action_form_views.xml @@ -7,7 +7,8 @@ - + + diff --git a/addons/automation/webhook.go b/addons/automation/webhook.go index 3e7555f0..ca472c53 100644 --- a/addons/automation/webhook.go +++ b/addons/automation/webhook.go @@ -146,7 +146,7 @@ func validateWebhookURL(raw string) error { return fmt.Errorf("invalid webhook url: %w", err) } scheme := strings.ToLower(u.Scheme) - if scheme != "https" && scheme != "http" { + if scheme != "https" { return fmt.Errorf("webhook scheme %q not allowed", u.Scheme) } host := strings.TrimSpace(u.Hostname()) diff --git a/addons/base/data/partner_property_definition_data.xml b/addons/base/data/partner_property_definition_data.xml new file mode 100644 index 00000000..77d9b6a1 --- /dev/null +++ b/addons/base/data/partner_property_definition_data.xml @@ -0,0 +1,30 @@ + + + + + core.partner + extra_properties + tier + Tier + selection + bronze:Bronze,silver:Silver,gold:Gold + 10 + + + core.partner + extra_properties + score + Score + float + 20 + + + core.partner + extra_properties + vip + VIP + boolean + 30 + + + diff --git a/addons/base/manifest.json b/addons/base/manifest.json index 753acb68..05fa99b5 100644 --- a/addons/base/manifest.json +++ b/addons/base/manifest.json @@ -7,6 +7,7 @@ "application": true, "data": [ "data/core_company_data.xml", + "data/partner_property_definition_data.xml", "views/core_company_actions.xml", "views/core_users_actions.xml", "views/platform_actions.xml", @@ -34,6 +35,8 @@ "views/sys_rule_form_views.xml", "views/sys_field_access_list_views.xml", "views/sys_field_access_form_views.xml", + "views/sys_property_definition_list_views.xml", + "views/sys_property_definition_form_views.xml", "views/menus.xml" ] } diff --git a/addons/base/models/core_partner.go b/addons/base/models/core_partner.go index 9b6cacc7..88f742ac 100644 --- a/addons/base/models/core_partner.go +++ b/addons/base/models/core_partner.go @@ -22,4 +22,5 @@ type CorePartner struct { PropertyAccountPayableID sdk.Many2One[sdk.Any] `sumeru:"string=Payable Account,comodel=account.account"` Color sdk.Integer `sumeru:"string=Color Index"` Gender sdk.String `sumeru:"string=Gender,selection=male:Male,female:Female,other:Other"` + ExtraProperties sdk.Properties `sumeru:"string=Extra Properties"` } diff --git a/addons/base/models/regional_refs.go b/addons/base/models/regional_refs.go index a3167a73..60d92fc1 100644 --- a/addons/base/models/regional_refs.go +++ b/addons/base/models/regional_refs.go @@ -6,4 +6,3 @@ import geomodels "sumeru/addons/geo/models" type CoreCountry = geomodels.CoreCountry type CoreCountryState = geomodels.CoreCountryState type CoreCity = geomodels.CoreCity -type CoreCurrency = geomodels.CoreCurrency diff --git a/addons/base/models/sys_property_definition.go b/addons/base/models/sys_property_definition.go new file mode 100644 index 00000000..b63ccd14 --- /dev/null +++ b/addons/base/models/sys_property_definition.go @@ -0,0 +1,17 @@ +package models + +import "sumeru/core/sdk" + +type SysPropertyDefinition struct { + sdk.Model `sumeru:"model=sys.property.definition"` + + ResModel sdk.String `sumeru:"required,index,column=model,string=Model"` + FieldName sdk.String `sumeru:"required,index,string=Field"` + Key sdk.String `sumeru:"required,index,string=Key"` + Label sdk.String `sumeru:"string=Label"` + ValueType sdk.String `sumeru:"required,string=Type,selection=char:Text,integer:Integer,boolean:Boolean,date:Date,selection:Selection,many2one:Many2one"` + Selection sdk.Text `sumeru:"string=Selection"` + Relation sdk.String `sumeru:"string=Relation"` + Required sdk.Boolean `sumeru:"string=Required,default=false"` + Sequence sdk.Integer `sumeru:"string=Sequence,default=10"` +} diff --git a/addons/base/models/zmodels.go b/addons/base/models/zmodels.go index 6244ab1e..e119a815 100644 --- a/addons/base/models/zmodels.go +++ b/addons/base/models/zmodels.go @@ -17,5 +17,6 @@ func init() { &ResConfigSettings{}, &SysAuthProvider{}, &SysFieldAccess{}, + &SysPropertyDefinition{}, ) } diff --git a/addons/base/views/menus.xml b/addons/base/views/menus.xml index 0724ab1a..2d8e9269 100644 --- a/addons/base/views/menus.xml +++ b/addons/base/views/menus.xml @@ -34,6 +34,10 @@ + + diff --git a/addons/base/views/sys_property_definition_form_views.xml b/addons/base/views/sys_property_definition_form_views.xml new file mode 100644 index 00000000..4ed017f3 --- /dev/null +++ b/addons/base/views/sys_property_definition_form_views.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/addons/base/views/sys_property_definition_list_views.xml b/addons/base/views/sys_property_definition_list_views.xml new file mode 100644 index 00000000..b09f267f --- /dev/null +++ b/addons/base/views/sys_property_definition_list_views.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/contacts/manifest.json b/addons/contacts/manifest.json index cd9e4f37..7ae62d91 100644 --- a/addons/contacts/manifest.json +++ b/addons/contacts/manifest.json @@ -13,6 +13,7 @@ "views/actions.xml", "views/core_partner_form_views.xml", "views/core_partner_form_inherit_views.xml", + "views/core_partner_properties_inherit_views.xml", "views/core_partner_list_views.xml", "views/core_partner_kanban_views.xml", "views/menus.xml" diff --git a/addons/contacts/views/actions.xml b/addons/contacts/views/actions.xml index 5d781f05..2f0dac7c 100644 --- a/addons/contacts/views/actions.xml +++ b/addons/contacts/views/actions.xml @@ -2,6 +2,9 @@ +

diff --git a/addons/contacts/views/core_partner_properties_inherit_views.xml b/addons/contacts/views/core_partner_properties_inherit_views.xml new file mode 100644 index 00000000..1a9fc362 --- /dev/null +++ b/addons/contacts/views/core_partner_properties_inherit_views.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/currency/data/core_currency_data.xml b/addons/currency/data/core_currency_data.xml new file mode 100644 index 00000000..cb77b4f8 --- /dev/null +++ b/addons/currency/data/core_currency_data.xml @@ -0,0 +1,17 @@ + + + + + USD$True + EUR€True + GBP£True + IDRRpTrue + SGDS$True + JPY¥True + INR₹True + AUDA$True + + diff --git a/addons/currency/init.go b/addons/currency/init.go new file mode 100644 index 00000000..a7043dee --- /dev/null +++ b/addons/currency/init.go @@ -0,0 +1,8 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package currency + +import ( + _ "sumeru/addons/base" + _ "sumeru/addons/currency/models" +) diff --git a/addons/currency/manifest.json b/addons/currency/manifest.json new file mode 100644 index 00000000..74d31aa0 --- /dev/null +++ b/addons/currency/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "currency", + "display_name": "Currencies", + "version": "1.0.0", + "author": "Sumeru", + "description": "Currency catalog and exchange rates as platform primitives.", + "depends": ["base"], + "auto_install": true, + "application": false, + "data": [ + "data/core_currency_data.xml", + "views/currency_actions.xml", + "views/core_currency_list_views.xml", + "views/core_currency_form_views.xml", + "views/core_currency_rate_list_views.xml", + "views/core_currency_rate_form_views.xml", + "views/menus.xml" + ] +} diff --git a/addons/geo/models/core_currency.go b/addons/currency/models/core_currency.go similarity index 71% rename from addons/geo/models/core_currency.go rename to addons/currency/models/core_currency.go index e9eb4f74..0f572da7 100644 --- a/addons/geo/models/core_currency.go +++ b/addons/currency/models/core_currency.go @@ -4,6 +4,8 @@ import ( "sumeru/core/sdk" ) +// CoreCurrency is the platform currency catalog. Rates are kept on +// core.currency.rate (see core_currency_rate.go). type CoreCurrency struct { sdk.Model `sumeru:"model=core.currency"` diff --git a/addons/currency/models/core_currency_rate.go b/addons/currency/models/core_currency_rate.go new file mode 100644 index 00000000..bda44e66 --- /dev/null +++ b/addons/currency/models/core_currency_rate.go @@ -0,0 +1,22 @@ +package models + +import ( + "sumeru/core/sdk" +) + +// CoreCurrencyRate holds the value of one unit of CurrencyID in the platform +// reference currency (the currency whose rate is 1.0), effective from DateFrom. +// +// Rates are global in this first iteration; verticals (e.g. accounting) can +// layer company-scoped rates and rate providers on top of this primitive. +// No rates are seeded as core data — rows are created by verticals or rate +// providers. Lookups resolve the newest DateFrom <= the conversion date (see +// sdk.ConvertCurrency). +type CoreCurrencyRate struct { + sdk.Model `sumeru:"model=core.currency.rate"` + + CurrencyID sdk.Many2One[CoreCurrency] `sumeru:"required,index,string=Currency"` + Rate sdk.Float64 `sumeru:"required,string=Rate"` + DateFrom sdk.Date `sumeru:"required,index,string=Date From"` + Active sdk.Boolean `sumeru:"string=Active,default=true"` +} diff --git a/addons/currency/models/zmodels.go b/addons/currency/models/zmodels.go new file mode 100644 index 00000000..d326c013 --- /dev/null +++ b/addons/currency/models/zmodels.go @@ -0,0 +1,12 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import "sumeru/core/sdk" + +func init() { + sdk.MustRegister("currency", + &CoreCurrency{}, + &CoreCurrencyRate{}, + ) +} diff --git a/addons/currency/views/core_currency_form_views.xml b/addons/currency/views/core_currency_form_views.xml new file mode 100644 index 00000000..d9ccf4f2 --- /dev/null +++ b/addons/currency/views/core_currency_form_views.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/currency/views/core_currency_list_views.xml b/addons/currency/views/core_currency_list_views.xml new file mode 100644 index 00000000..ef98c564 --- /dev/null +++ b/addons/currency/views/core_currency_list_views.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/currency/views/core_currency_rate_form_views.xml b/addons/currency/views/core_currency_rate_form_views.xml new file mode 100644 index 00000000..94f3fcc3 --- /dev/null +++ b/addons/currency/views/core_currency_rate_form_views.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/addons/currency/views/core_currency_rate_list_views.xml b/addons/currency/views/core_currency_rate_list_views.xml new file mode 100644 index 00000000..b3566a79 --- /dev/null +++ b/addons/currency/views/core_currency_rate_list_views.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/currency/views/currency_actions.xml b/addons/currency/views/currency_actions.xml new file mode 100644 index 00000000..a3350ea6 --- /dev/null +++ b/addons/currency/views/currency_actions.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/addons/currency/views/menus.xml b/addons/currency/views/menus.xml new file mode 100644 index 00000000..451bc263 --- /dev/null +++ b/addons/currency/views/menus.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/addons/digest/README.md b/addons/digest/README.md new file mode 100644 index 00000000..0320ad3d --- /dev/null +++ b/addons/digest/README.md @@ -0,0 +1,17 @@ +# Digest addon + +Scheduled email digests with KPI rows backed by `digest.digest` and `digest.kpi`. + +## KPI compute codes + +`ComputeKPI` in [`compute.go`](compute.go) evaluates each KPI’s `compute_code` at send time. Built-in codes today: + +| Code | Meaning | +|------|---------| +| `crm.lead_count` | Count of `crm.lead` records | +| `crm.lead_won_count` | Won leads | +| `crm.opportunity_count` | Opportunities (`type = opportunity`) | + +When a module needs a new metric, add a `case` in `ComputeKPI` (or a shared registry helper in this package) and reference the code from KPI records in XML/data. Unknown codes return an error so bad configuration surfaces in cron logs instead of silent zeroes. + +If the target model is not installed, counts return `0` without error. diff --git a/addons/geo/manifest.json b/addons/geo/manifest.json index 41898a5d..015de7ab 100644 --- a/addons/geo/manifest.json +++ b/addons/geo/manifest.json @@ -3,7 +3,7 @@ "display_name": "Regional data", "version": "1.0.0", "author": "Sumeru", - "description": "Countries, states, cities, and currencies.", + "description": "Countries, states, and cities.", "depends": ["base"], "auto_install": true, "application": false, diff --git a/addons/geo/models/zmodels.go b/addons/geo/models/zmodels.go index 9398eeb6..ce438af8 100644 --- a/addons/geo/models/zmodels.go +++ b/addons/geo/models/zmodels.go @@ -9,6 +9,5 @@ func init() { &CoreCity{}, &CoreCountry{}, &CoreCountryState{}, - &CoreCurrency{}, ) } diff --git a/addons/mail/actions.go b/addons/mail/actions.go new file mode 100644 index 00000000..b6e39f76 --- /dev/null +++ b/addons/mail/actions.go @@ -0,0 +1,92 @@ +package mail + +import ( + "context" + "fmt" + "strings" + "time" + + "sumeru/core/orm" +) + +func init() { + orm.RegisterObjectAction("mail.activity", "action_done", actionDone) + orm.RegisterObjectAction("mail.activity", "action_snooze", actionSnooze) + orm.RegisterSearchResultFilter(filterParentReadable) +} + +func actionDone(ctx context.Context, model string, id int, _ map[string]string) (string, error) { + if model != "mail.activity" || id <= 0 { + return "", fmt.Errorf("record not found") + } + rec, err := orm.SearchOne(ctx, "mail.activity", map[string]interface{}{"id": id}) + if err != nil { + return "", fmt.Errorf("record not found") + } + if strings.TrimSpace(orm.AsString(rec["state"])) == "done" { + return "", nil + } + if !activityActorAllowed(ctx, rec) { + return "", fmt.Errorf("access denied") + } + return "", orm.UpdateRecordByID(ctx, "mail.activity", id, map[string]interface{}{ + "state": "done", + "date_done": time.Now().UTC().Format("2006-01-02"), + }) +} + +func actionSnooze(ctx context.Context, model string, id int, _ map[string]string) (string, error) { + if model != "mail.activity" || id <= 0 { + return "", fmt.Errorf("record not found") + } + rec, err := orm.SearchOne(ctx, "mail.activity", map[string]interface{}{"id": id}) + if err != nil { + return "", fmt.Errorf("record not found") + } + if strings.TrimSpace(orm.AsString(rec["state"])) == "done" { + return "", fmt.Errorf("done activities cannot be snoozed") + } + if !activityActorAllowed(ctx, rec) { + return "", fmt.Errorf("access denied") + } + days := 1 + typeID := recordID(rec["activity_type_id"]) + if typeID > 0 { + typeRow, err := orm.SearchOne(ctx, "mail.activity.type", map[string]interface{}{"id": int(typeID)}) + if err == nil { + if n, ok := orm.CoerceInt64(typeRow["delay_count"]); ok && n > 0 { + days = int(n) + } + } + } + base := time.Now().UTC() + if raw := strings.TrimSpace(orm.AsString(rec["date_deadline"])); len(raw) >= 10 { + if parsed, err := time.Parse("2006-01-02", raw[:10]); err == nil { + base = parsed + } + } + next := base.AddDate(0, 0, days).Format("2006-01-02") + return "", orm.UpdateRecordByID(ctx, "mail.activity", id, map[string]interface{}{ + "date_deadline": next, + }) +} + +func activityActorAllowed(ctx context.Context, rec map[string]interface{}) bool { + uid := orm.SecurityUID(ctx) + if uid > 0 && recordID(rec["user_id"]) == int64(uid) { + return true + } + return orm.UserHasGroupXML(ctx, uid, "base.group_system") +} + +func recordID(v interface{}) int64 { + if n, ok := orm.CoerceInt64(v); ok { + return n + } + items, ok := v.([]interface{}) + if ok && len(items) > 0 { + n, _ := orm.CoerceInt64(items[0]) + return n + } + return 0 +} diff --git a/addons/mail/data/mail_activity_type_data.xml b/addons/mail/data/mail_activity_type_data.xml new file mode 100644 index 00000000..abb497a3 --- /dev/null +++ b/addons/mail/data/mail_activity_type_data.xml @@ -0,0 +1,17 @@ + + + + + To Do + 1 + + + Call + 0 + + + Email + 2 + + + diff --git a/addons/mail/init.go b/addons/mail/init.go new file mode 100644 index 00000000..53bb45f6 --- /dev/null +++ b/addons/mail/init.go @@ -0,0 +1,9 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package mail + +import ( + _ "sumeru/addons/base" + _ "sumeru/addons/platform" + _ "sumeru/addons/mail/models" +) diff --git a/addons/mail/manifest.json b/addons/mail/manifest.json new file mode 100644 index 00000000..aeabd7a7 --- /dev/null +++ b/addons/mail/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "mail", + "display_name": "Mail", + "version": "1.0.0", + "author": "Sumeru", + "description": "Record messages and activities. Outbound transport stays in core/mail.", + "depends": ["base", "platform"], + "application": false, + "data": [ + "security/security.xml", + "security/sys.access.csv", + "data/mail_activity_type_data.xml", + "views/actions.xml", + "views/mail_activity_type_list_views.xml", + "views/mail_activity_type_form_views.xml", + "views/mail_activity_list_views.xml", + "views/mail_activity_form_views.xml", + "views/mail_activity_activity_views.xml", + "views/mail_message_list_views.xml", + "views/mail_message_form_views.xml", + "views/menus.xml" + ] +} diff --git a/addons/mail/models/mail_activity.go b/addons/mail/models/mail_activity.go new file mode 100644 index 00000000..8aa4ea63 --- /dev/null +++ b/addons/mail/models/mail_activity.go @@ -0,0 +1,17 @@ +package models + +import "sumeru/core/sdk" + +type MailActivity struct { + sdk.Model `sumeru:"model=mail.activity"` + + ResModel sdk.String `sumeru:"required,index,column=model,string=Document Model"` + ResID sdk.Integer `sumeru:"required,index,string=Document"` + ActivityTypeID sdk.Many2One[MailActivityType] `sumeru:"required,index,string=Type"` + UserID sdk.Many2One[CoreUser] `sumeru:"index,string=Assigned To"` + DateDeadline sdk.Date `sumeru:"index,string=Due Date"` + Summary sdk.String `sumeru:"string=Summary"` + Note sdk.Text `sumeru:"string=Note"` + State sdk.String `sumeru:"index,string=State,default=planned,selection=planned:Planned,done:Done"` + DateDone sdk.Date `sumeru:"string=Done On"` +} diff --git a/addons/mail/models/mail_activity_type.go b/addons/mail/models/mail_activity_type.go new file mode 100644 index 00000000..0f826b27 --- /dev/null +++ b/addons/mail/models/mail_activity_type.go @@ -0,0 +1,11 @@ +package models + +import "sumeru/core/sdk" + +type MailActivityType struct { + sdk.Model `sumeru:"model=mail.activity.type"` + + Name sdk.String `sumeru:"required,unique,string=Name"` + DelayCount sdk.Integer `sumeru:"string=Delay (days),default=0"` + Icon sdk.String `sumeru:"string=Icon"` +} diff --git a/addons/mail/models/mail_message.go b/addons/mail/models/mail_message.go new file mode 100644 index 00000000..74d48eb2 --- /dev/null +++ b/addons/mail/models/mail_message.go @@ -0,0 +1,19 @@ +package models + +import "sumeru/core/sdk" + +type MailMessage struct { + sdk.Model `sumeru:"model=mail.message"` + + ResModel sdk.String `sumeru:"index,column=model,string=Document Model"` + ResID sdk.Integer `sumeru:"index,string=Document"` + MessageType sdk.String `sumeru:"string=Type,default=email,selection=email:Email,comment:Comment"` + Subject sdk.String `sumeru:"string=Subject"` + Body sdk.Text `sumeru:"string=Body"` + EmailFrom sdk.String `sumeru:"string=From"` + MessageID sdk.String `sumeru:"index,unique,column=message_id,string=Message-ID"` + InReplyTo sdk.String `sumeru:"index,string=In-Reply-To"` + AuthorID sdk.Many2One[CorePartner] `sumeru:"string=Author"` + State sdk.String `sumeru:"index,string=State,default=received,selection=received:Received,quarantine:Quarantine,rejected:Rejected"` + CompanyID sdk.Many2One[CoreCompany] `sumeru:"string=Company"` +} diff --git a/addons/mail/models/zmodels.go b/addons/mail/models/zmodels.go new file mode 100644 index 00000000..742fd0b3 --- /dev/null +++ b/addons/mail/models/zmodels.go @@ -0,0 +1,13 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import "sumeru/core/sdk" + +func init() { + sdk.MustRegister("mail", + &MailActivity{}, + &MailActivityType{}, + &MailMessage{}, + ) +} diff --git a/addons/mail/models/zrefs.go b/addons/mail/models/zrefs.go new file mode 100644 index 00000000..9c746168 --- /dev/null +++ b/addons/mail/models/zrefs.go @@ -0,0 +1,17 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import ( + basemodels "sumeru/addons/base/models" +) + +// CoreCompany → core.company +type CoreCompany = basemodels.CoreCompany + +// CorePartner → core.partner +type CorePartner = basemodels.CorePartner + +// CoreUser → core.user +type CoreUser = basemodels.CoreUser + diff --git a/addons/mail/parent_filter.go b/addons/mail/parent_filter.go new file mode 100644 index 00000000..cbc8cb12 --- /dev/null +++ b/addons/mail/parent_filter.go @@ -0,0 +1,34 @@ +package mail + +import ( + "context" + "strings" + + "sumeru/core/orm" +) + +func filterParentReadable(ctx context.Context, model string, rows []map[string]interface{}) []map[string]interface{} { + if model != "mail.message" && model != "mail.activity" { + return rows + } + if orm.SecurityBypass(ctx) { + return rows + } + manager := orm.UserHasGroupXML(ctx, orm.SecurityUID(ctx), "mail.group_mail_manager") + out := make([]map[string]interface{}, 0, len(rows)) + for _, row := range rows { + parent := strings.TrimSpace(orm.AsString(row["model"])) + parentID := recordID(row["res_id"]) + if parent == "" || parentID <= 0 || parent == "mail.message" || parent == "mail.activity" { + if model == "mail.message" && strings.TrimSpace(orm.AsString(row["state"])) == "quarantine" && manager { + out = append(out, row) + } + continue + } + if _, err := orm.SearchOne(ctx, parent, map[string]interface{}{"id": int(parentID)}); err != nil { + continue + } + out = append(out, row) + } + return out +} diff --git a/addons/mail/security/security.xml b/addons/mail/security/security.xml new file mode 100644 index 00000000..3c30a734 --- /dev/null +++ b/addons/mail/security/security.xml @@ -0,0 +1,19 @@ + + + + + Mail + 40 + + + Mail / User + + + + + Mail / Manager + + + + + diff --git a/addons/mail/security/sys.access.csv b/addons/mail/security/sys.access.csv new file mode 100644 index 00000000..f5d33c8c --- /dev/null +++ b/addons/mail/security/sys.access.csv @@ -0,0 +1,7 @@ +id,name,model,group_id,perm_read,perm_write,perm_create,perm_unlink +access_mail_message_user,access_mail_message_user,mail.message,mail.group_mail_user,1,0,1,0 +access_mail_message_manager,access_mail_message_manager,mail.message,mail.group_mail_manager,1,1,1,1 +access_mail_activity_user,access_mail_activity_user,mail.activity,mail.group_mail_user,1,1,1,0 +access_mail_activity_manager,access_mail_activity_manager,mail.activity,mail.group_mail_manager,1,1,1,1 +access_mail_activity_type_user,access_mail_activity_type_user,mail.activity.type,mail.group_mail_user,1,0,0,0 +access_mail_activity_type_manager,access_mail_activity_type_manager,mail.activity.type,mail.group_mail_manager,1,1,1,1 diff --git a/addons/mail/views/actions.xml b/addons/mail/views/actions.xml new file mode 100644 index 00000000..744fb127 --- /dev/null +++ b/addons/mail/views/actions.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/addons/mail/views/mail_activity_activity_views.xml b/addons/mail/views/mail_activity_activity_views.xml new file mode 100644 index 00000000..ca5ba2f3 --- /dev/null +++ b/addons/mail/views/mail_activity_activity_views.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/addons/mail/views/mail_activity_form_views.xml b/addons/mail/views/mail_activity_form_views.xml new file mode 100644 index 00000000..3ef90a17 --- /dev/null +++ b/addons/mail/views/mail_activity_form_views.xml @@ -0,0 +1,26 @@ + + + + +

+
+ + + + + + + + + + + + + + + + +
+
diff --git a/addons/mail/views/mail_activity_list_views.xml b/addons/mail/views/mail_activity_list_views.xml new file mode 100644 index 00000000..2583054d --- /dev/null +++ b/addons/mail/views/mail_activity_list_views.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/mail/views/mail_activity_type_form_views.xml b/addons/mail/views/mail_activity_type_form_views.xml new file mode 100644 index 00000000..0dfe0d84 --- /dev/null +++ b/addons/mail/views/mail_activity_type_form_views.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/mail/views/mail_activity_type_list_views.xml b/addons/mail/views/mail_activity_type_list_views.xml new file mode 100644 index 00000000..7971a67b --- /dev/null +++ b/addons/mail/views/mail_activity_type_list_views.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/mail/views/mail_message_form_views.xml b/addons/mail/views/mail_message_form_views.xml new file mode 100644 index 00000000..7c8d965e --- /dev/null +++ b/addons/mail/views/mail_message_form_views.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/mail/views/mail_message_list_views.xml b/addons/mail/views/mail_message_list_views.xml new file mode 100644 index 00000000..b56f1095 --- /dev/null +++ b/addons/mail/views/mail_message_list_views.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/mail/views/menus.xml b/addons/mail/views/menus.xml new file mode 100644 index 00000000..03e35aae --- /dev/null +++ b/addons/mail/views/menus.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/platform/init.go b/addons/platform/init.go index d322426a..4877366f 100644 --- a/addons/platform/init.go +++ b/addons/platform/init.go @@ -4,5 +4,6 @@ package platform import ( _ "sumeru/addons/base" + _ "sumeru/addons/currency" _ "sumeru/addons/platform/models" ) diff --git a/addons/platform/manifest.json b/addons/platform/manifest.json index ebfba54d..77bfbeec 100644 --- a/addons/platform/manifest.json +++ b/addons/platform/manifest.json @@ -4,7 +4,7 @@ "version": "1.0.0", "author": "Sumeru", "description": "Attachments, sequences, system parameters, reports, and bulk import templates.", - "depends": ["base"], + "depends": ["base", "currency"], "auto_install": true, "application": false, "data": [ @@ -24,6 +24,9 @@ "views/sys_bulk_import_form_views.xml", "views/sys_import_template_list_views.xml", "views/sys_import_template_form_views.xml", + "views/platform_pricing_sample_actions.xml", + "views/platform_pricing_sample_list_views.xml", + "views/platform_pricing_sample_form_views.xml", "views/menus.xml" ] } diff --git a/addons/platform/models/platform_pricing_sample.go b/addons/platform/models/platform_pricing_sample.go new file mode 100644 index 00000000..df7615f2 --- /dev/null +++ b/addons/platform/models/platform_pricing_sample.go @@ -0,0 +1,14 @@ +package models + +import ( + "sumeru/core/sdk" +) + +// PlatformPricingSample is a reference row for SUM-PLAT-18 monetary fields and conversion. +type PlatformPricingSample struct { + sdk.Model `sumeru:"model=platform.pricing.sample"` + + Name sdk.String `sumeru:"required,string=Label"` + CurrencyID sdk.Many2One[CoreCurrency] `sumeru:"required,index,string=Currency"` + Amount sdk.Money `sumeru:"required,string=Amount,currency=currency_id"` +} diff --git a/addons/platform/models/zmodels.go b/addons/platform/models/zmodels.go index 8f73fac2..0f3436d4 100644 --- a/addons/platform/models/zmodels.go +++ b/addons/platform/models/zmodels.go @@ -6,6 +6,7 @@ import "sumeru/core/sdk" func init() { sdk.MustRegister("platform", + &PlatformPricingSample{}, &SysAttachment{}, &SysBulkImport{}, &SysConfigParameter{}, diff --git a/addons/platform/models/zrefs.go b/addons/platform/models/zrefs.go index bcec8b9c..88186fc1 100644 --- a/addons/platform/models/zrefs.go +++ b/addons/platform/models/zrefs.go @@ -4,11 +4,15 @@ package models import ( basemodels "sumeru/addons/base/models" + currencymodels "sumeru/addons/currency/models" ) // CoreCompany → core.company type CoreCompany = basemodels.CoreCompany +// CoreCurrency → core.currency +type CoreCurrency = currencymodels.CoreCurrency + // CoreUser → core.user type CoreUser = basemodels.CoreUser diff --git a/addons/platform/security/sys.access.csv b/addons/platform/security/sys.access.csv index adf7c7eb..65d1a3e8 100644 --- a/addons/platform/security/sys.access.csv +++ b/addons/platform/security/sys.access.csv @@ -1,2 +1,3 @@ id,name,model,group_id,perm_read,perm_write,perm_create,perm_unlink access_sys_import_template_user,access_sys_import_template_user,sys.import.template,base.group_user,1,1,1,1 +access_platform_pricing_sample_user,access_platform_pricing_sample_user,platform.pricing.sample,base.group_user,1,1,1,1 diff --git a/addons/platform/views/menus.xml b/addons/platform/views/menus.xml index 10d868ee..b3eef696 100644 --- a/addons/platform/views/menus.xml +++ b/addons/platform/views/menus.xml @@ -15,5 +15,8 @@ + diff --git a/addons/platform/views/platform_pricing_sample_actions.xml b/addons/platform/views/platform_pricing_sample_actions.xml new file mode 100644 index 00000000..146852a7 --- /dev/null +++ b/addons/platform/views/platform_pricing_sample_actions.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/addons/platform/views/platform_pricing_sample_form_views.xml b/addons/platform/views/platform_pricing_sample_form_views.xml new file mode 100644 index 00000000..156c9ad8 --- /dev/null +++ b/addons/platform/views/platform_pricing_sample_form_views.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/platform/views/platform_pricing_sample_list_views.xml b/addons/platform/views/platform_pricing_sample_list_views.xml new file mode 100644 index 00000000..3006dd7a --- /dev/null +++ b/addons/platform/views/platform_pricing_sample_list_views.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/uom/data/uom_data.xml b/addons/uom/data/uom_data.xml new file mode 100644 index 00000000..a9d75ba0 --- /dev/null +++ b/addons/uom/data/uom_data.xml @@ -0,0 +1,37 @@ + + + + + Unit + Weight + Volume + Length + Time + + + Unit11True + Dozens121True + Hundreds1001True + + + kg10.001True + g0.0010.001True + mg0.0000010.001True + t10000.001True + + + l10.001True + ml0.0010.001True + m³10000.001True + + + m10.01True + cm0.010.01True + mm0.0010.01True + km10000.01True + + + Hours10.01True + Days80.01True + + diff --git a/addons/uom/init.go b/addons/uom/init.go new file mode 100644 index 00000000..96c42470 --- /dev/null +++ b/addons/uom/init.go @@ -0,0 +1,8 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package uom + +import ( + _ "sumeru/addons/base" + _ "sumeru/addons/uom/models" +) diff --git a/addons/uom/manifest.json b/addons/uom/manifest.json new file mode 100644 index 00000000..f362e701 --- /dev/null +++ b/addons/uom/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "uom", + "display_name": "Units of Measure", + "version": "1.0.0", + "author": "Sumeru", + "description": "Units of measure with categories and conversion factors.", + "depends": ["base"], + "auto_install": true, + "application": false, + "data": [ + "data/uom_data.xml", + "views/uom_actions.xml", + "views/uom_category_list_views.xml", + "views/uom_category_form_views.xml", + "views/uom_uom_list_views.xml", + "views/uom_uom_form_views.xml", + "views/menus.xml" + ] +} diff --git a/addons/uom/models/uom.go b/addons/uom/models/uom.go new file mode 100644 index 00000000..d5460a2a --- /dev/null +++ b/addons/uom/models/uom.go @@ -0,0 +1,27 @@ +package models + +import ( + "sumeru/core/sdk" +) + +// UomCategory groups units of measure that can be converted between each +// other (e.g. Weight: kg, g, ton). +type UomCategory struct { + sdk.Model `sumeru:"model=uom.category"` + + Name sdk.String `sumeru:"required,unique,index,string=Category"` +} + +// UomUom is a unit of measure. Factor expresses how many reference units one +// unit of this UoM contains (e.g. in Weight with kg as reference: kg=1, +// g=0.001, ton=1000). Conversions inside a category divide the factors and +// round to the target UoM's Rounding precision (see sdk.ConvertUom). +type UomUom struct { + sdk.Model `sumeru:"model=uom.uom"` + + Name sdk.String `sumeru:"required,index,string=Unit of Measure"` + CategoryID sdk.Many2One[UomCategory] `sumeru:"required,index,string=Category"` + Factor sdk.Float64 `sumeru:"required,string=Ratio,default=1"` + Rounding sdk.Float64 `sumeru:"string=Rounding Precision,default=0.01"` + Active sdk.Boolean `sumeru:"string=Active,default=true"` +} diff --git a/addons/uom/models/zmodels.go b/addons/uom/models/zmodels.go new file mode 100644 index 00000000..dbc77091 --- /dev/null +++ b/addons/uom/models/zmodels.go @@ -0,0 +1,12 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import "sumeru/core/sdk" + +func init() { + sdk.MustRegister("uom", + &UomCategory{}, + &UomUom{}, + ) +} diff --git a/addons/uom/views/menus.xml b/addons/uom/views/menus.xml new file mode 100644 index 00000000..f6bf70c1 --- /dev/null +++ b/addons/uom/views/menus.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/addons/uom/views/uom_actions.xml b/addons/uom/views/uom_actions.xml new file mode 100644 index 00000000..7a031140 --- /dev/null +++ b/addons/uom/views/uom_actions.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/addons/uom/views/uom_category_form_views.xml b/addons/uom/views/uom_category_form_views.xml new file mode 100644 index 00000000..ca29ed92 --- /dev/null +++ b/addons/uom/views/uom_category_form_views.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/addons/uom/views/uom_category_list_views.xml b/addons/uom/views/uom_category_list_views.xml new file mode 100644 index 00000000..e774164e --- /dev/null +++ b/addons/uom/views/uom_category_list_views.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/addons/uom/views/uom_uom_form_views.xml b/addons/uom/views/uom_uom_form_views.xml new file mode 100644 index 00000000..dcc0e494 --- /dev/null +++ b/addons/uom/views/uom_uom_form_views.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/addons/uom/views/uom_uom_list_views.xml b/addons/uom/views/uom_uom_list_views.xml new file mode 100644 index 00000000..769b092c --- /dev/null +++ b/addons/uom/views/uom_uom_list_views.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/cmd/sumeru/zimports.go b/cmd/sumeru/zimports.go index 7f66655b..56c1fadb 100644 --- a/cmd/sumeru/zimports.go +++ b/cmd/sumeru/zimports.go @@ -5,6 +5,7 @@ package main import ( _ "sumeru/addons/base" _ "sumeru/addons/contacts" + _ "sumeru/addons/currency" _ "sumeru/addons/geo" _ "sumeru/addons/i18n" _ "sumeru/addons/im" @@ -12,5 +13,7 @@ import ( _ "sumeru/addons/digest" _ "sumeru/addons/platform" _ "sumeru/addons/audit" + _ "sumeru/addons/mail" _ "sumeru/addons/automation" + _ "sumeru/addons/uom" ) diff --git a/core/engine/assets/css/sumeru-messages.css b/core/engine/assets/css/sumeru-messages.css index 02a527f2..9d3354a1 100644 --- a/core/engine/assets/css/sumeru-messages.css +++ b/core/engine/assets/css/sumeru-messages.css @@ -520,14 +520,6 @@ } } -/* Legacy card classes (unused in dock UI; kept for compatibility) */ -.sum-msg-card { - border-radius: var(--sum-radius-sm); - padding: 0.55rem 0.65rem; - background: var(--sum-surface); - border: 1px solid var(--sum-line); -} - .sum-msg-author { font-size: var(--sum-font-size-sm); font-weight: var(--sum-font-weight-semibold); @@ -539,12 +531,3 @@ color: var(--sum-muted); white-space: nowrap; } - -.sum-msg-card--out { - margin-left: 1.25rem; - background: var(--sum-header-tint-08); -} - -.sum-msg-card--pending { - opacity: 0.75; -} diff --git a/core/engine/assets/css/sumeru-views.css b/core/engine/assets/css/sumeru-views.css index 9547abf6..29623784 100644 --- a/core/engine/assets/css/sumeru-views.css +++ b/core/engine/assets/css/sumeru-views.css @@ -3363,3 +3363,36 @@ body.sum-debug--field-inspector.sum-debug--active .sum-field-widget[data-sum-deb max-height: 60vh; overflow: auto; } + +.sum-properties { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sum-properties-row { + display: grid; + grid-template-columns: minmax(8rem, 30%) 1fr; + gap: 0.5rem 1rem; + align-items: center; +} + +.sum-properties-empty { + color: var(--sum-muted); + font-size: 0.875rem; +} + +.sum-activity-type { + margin: 0.75rem 0 0.35rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--sum-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.sum-activity-user h3 { + margin: 0.35rem 0; + font-size: 0.875rem; + font-weight: 600; +} diff --git a/core/engine/render/swc_bootstrap.go b/core/engine/render/swc_bootstrap.go index 33d9dbdb..cd6c5ff6 100644 --- a/core/engine/render/swc_bootstrap.go +++ b/core/engine/render/swc_bootstrap.go @@ -8,6 +8,7 @@ import ( "sumeru/addons/im" "sumeru/core/orm" + "sumeru/core/server/config" ) // SWCBootstrapWorkspace is the initial workspace route embedded in bootstrap JSON. @@ -40,8 +41,11 @@ type swcBootstrap struct { ActivityEnabled bool `json:"activityEnabled"` InternalMessagesEnabled bool `json:"internalMessagesEnabled"` CompanyFormHref string `json:"companyFormHref,omitempty"` - BusEnabled bool `json:"busEnabled"` - DocsURL string `json:"docsUrl"` + BusEnabled bool `json:"busEnabled"` + CloseSessionOnLastTab bool `json:"closeSessionOnLastTab"` + SessionBrowserPingSeconds int `json:"sessionBrowserPingSeconds"` + SessionBrowserGraceSeconds int `json:"sessionBrowserGraceSeconds"` + DocsURL string `json:"docsUrl"` ProfileURL string `json:"profileUrl"` Features map[string]bool `json:"features,omitempty"` Workspace *SWCBootstrapWorkspace `json:"workspace,omitempty"` @@ -105,8 +109,11 @@ func BuildSWCBootstrapJSON(ctx context.Context, page PageData, ws *SWCBootstrapW ActivityEnabled: shellActivityEnabled(ctx, page), InternalMessagesEnabled: im.IMEnabled(ctx), CompanyFormHref: im.CompanySettingsFormHref(ctx, page.ShellActiveCompanyID), - BusEnabled: true, - DocsURL: page.UserDocsHref, + BusEnabled: true, + CloseSessionOnLastTab: config.AppConfig.CloseSessionOnLastTab, + SessionBrowserPingSeconds: sessionBrowserPingSecondsForBootstrap(), + SessionBrowserGraceSeconds: sessionBrowserGraceSecondsForBootstrap(), + DocsURL: page.UserDocsHref, ProfileURL: page.UserProfileHref, Features: buildSWCFeatureFlags(ctx), Workspace: ws, @@ -208,6 +215,22 @@ func parseSWCPinnedApps(raw template.JS) []string { return out } +func sessionBrowserPingSecondsForBootstrap() int { + sec := config.AppConfig.SessionBrowserPingSeconds + if sec <= 0 { + return 30 + } + return sec +} + +func sessionBrowserGraceSecondsForBootstrap() int { + sec := config.AppConfig.SessionBrowserGraceSeconds + if sec <= 0 { + return 90 + } + return sec +} + func buildSWCFeatureFlags(ctx context.Context) map[string]bool { uid := orm.UIDFromContext(ctx) if uid <= 0 { diff --git a/core/engine/swcmeta/arch.go b/core/engine/swcmeta/arch.go index a2e1e979..ebfcb459 100644 --- a/core/engine/swcmeta/arch.go +++ b/core/engine/swcmeta/arch.go @@ -57,6 +57,11 @@ func SerializeViewForUser(ctx context.Context, view *parser.View) ViewArch { arch.Graph = &GraphMeta{Chart: view.GraphChart()} } switch strings.ToLower(strings.TrimSpace(view.Type)) { + case "activity": + arch.Activity = &ActivityMeta{} + if ds := strings.TrimSpace(view.DateStart); ds != "" { + arch.Calendar = &CalendarMeta{DateStart: ds} + } case "calendar": arch.Calendar = &CalendarMeta{ DateStart: strings.TrimSpace(view.DateStart), @@ -169,6 +174,17 @@ func enrichField(model string, f ArchField) ArchField { if f.Widget == "" && fd.Widget != "" { f.Widget = fd.Widget } + if fd.Widget == "monetary" || fd.Currency != "" { + if f.Widget == "" { + f.Widget = "monetary" + } + if f.Options == nil { + f.Options = map[string]string{} + } + if fd.Currency != "" { + f.Options["currency_field"] = fd.Currency + } + } if f.Options == nil && fd.Domain != "" { f.Options = map[string]string{"domain": fd.Domain} } @@ -341,10 +357,10 @@ func serializeButtons(buttons []parser.Button) []ArchButton { out := make([]ArchButton, 0, len(buttons)) for _, b := range buttons { ab := ArchButton{ - Name: strings.TrimSpace(b.Name), - String: strings.TrimSpace(b.String), - Type: strings.TrimSpace(b.Type), - Class: strings.TrimSpace(b.Class), + Name: strings.TrimSpace(b.Name), + String: strings.TrimSpace(b.String), + Type: strings.TrimSpace(b.Type), + Class: strings.TrimSpace(b.Class), Confirm: strings.TrimSpace(b.Confirm), } if lit, truthy, expr, err := parser.ParseModifierAttr("invisible", b.Invisible); err == nil && lit { diff --git a/core/engine/swcmeta/types.go b/core/engine/swcmeta/types.go index 0560e8fc..1302a190 100644 --- a/core/engine/swcmeta/types.go +++ b/core/engine/swcmeta/types.go @@ -2,34 +2,34 @@ package swcmeta // WorkspacePayload is the JSON body for GET /web/swc/workspace. type WorkspacePayload struct { - ActionID int `json:"actionId"` - MenuID string `json:"menuId"` - ViewType string `json:"viewType"` - Model string `json:"model"` - RecordID int `json:"recordId"` - FormEdit bool `json:"formEdit"` - CSRFToken string `json:"csrfToken"` - Arch ViewArch `json:"arch"` - Record map[string]interface{} `json:"record,omitempty"` - Records []map[string]interface{} `json:"records,omitempty"` - ViewTabs []ViewTab `json:"viewTabs"` - Breadcrumbs []Breadcrumb `json:"breadcrumbs"` - ListSearch string `json:"listSearch,omitempty"` - ListSearchURL string `json:"listSearchUrl,omitempty"` - ListTotal int `json:"listTotal,omitempty"` - ListSort string `json:"listSort,omitempty"` - ListOffset int `json:"listOffset,omitempty"` - ListFilter string `json:"listFilter,omitempty"` - ListDomain string `json:"listDomain,omitempty"` - ListGroupBy string `json:"listGroupBy,omitempty"` - ListSections []ListSectionMeta `json:"listSections,omitempty"` - Favorites []SavedSearchMeta `json:"favorites,omitempty"` - FormBaseQuery string `json:"formBaseQuery,omitempty"` - Defaults map[string]interface{} `json:"defaults,omitempty"` - IframeURL string `json:"iframeUrl,omitempty"` - UserSecurity *UserSecurityPayload `json:"userSecurity,omitempty"` - RecordMessagesEligible bool `json:"recordMessagesEligible,omitempty"` - RecordLogEligible bool `json:"recordLogEligible,omitempty"` + ActionID int `json:"actionId"` + MenuID string `json:"menuId"` + ViewType string `json:"viewType"` + Model string `json:"model"` + RecordID int `json:"recordId"` + FormEdit bool `json:"formEdit"` + CSRFToken string `json:"csrfToken"` + Arch ViewArch `json:"arch"` + Record map[string]interface{} `json:"record,omitempty"` + Records []map[string]interface{} `json:"records,omitempty"` + ViewTabs []ViewTab `json:"viewTabs"` + Breadcrumbs []Breadcrumb `json:"breadcrumbs"` + ListSearch string `json:"listSearch,omitempty"` + ListSearchURL string `json:"listSearchUrl,omitempty"` + ListTotal int `json:"listTotal,omitempty"` + ListSort string `json:"listSort,omitempty"` + ListOffset int `json:"listOffset,omitempty"` + ListFilter string `json:"listFilter,omitempty"` + ListDomain string `json:"listDomain,omitempty"` + ListGroupBy string `json:"listGroupBy,omitempty"` + ListSections []ListSectionMeta `json:"listSections,omitempty"` + Favorites []SavedSearchMeta `json:"favorites,omitempty"` + FormBaseQuery string `json:"formBaseQuery,omitempty"` + Defaults map[string]interface{} `json:"defaults,omitempty"` + IframeURL string `json:"iframeUrl,omitempty"` + UserSecurity *UserSecurityPayload `json:"userSecurity,omitempty"` + RecordMessagesEligible bool `json:"recordMessagesEligible,omitempty"` + RecordLogEligible bool `json:"recordLogEligible,omitempty"` } type ViewTab struct { @@ -52,25 +52,26 @@ type ListSectionMeta struct { } type ViewArch struct { - Type string `json:"type"` - Model string `json:"model"` - Title string `json:"title,omitempty"` - HasChatter bool `json:"hasChatter,omitempty"` - Fields []ArchField `json:"fields"` - Header *ArchHeader `json:"header,omitempty"` - Footer *ArchFooter `json:"footer,omitempty"` - Sheet *ArchSheet `json:"sheet,omitempty"` - FormMeta *FormMeta `json:"formMeta,omitempty"` - Kanban *KanbanMeta `json:"kanban,omitempty"` - Pivot *PivotMeta `json:"pivot,omitempty"` - Search *SearchMeta `json:"search,omitempty"` - Graph *GraphMeta `json:"graph,omitempty"` - Calendar *CalendarMeta `json:"calendar,omitempty"` - Gantt *GanttMeta `json:"gantt,omitempty"` - Map *MapMeta `json:"map,omitempty"` - Cohort *CohortMeta `json:"cohort,omitempty"` + Type string `json:"type"` + Model string `json:"model"` + Title string `json:"title,omitempty"` + HasChatter bool `json:"hasChatter,omitempty"` + Fields []ArchField `json:"fields"` + Header *ArchHeader `json:"header,omitempty"` + Footer *ArchFooter `json:"footer,omitempty"` + Sheet *ArchSheet `json:"sheet,omitempty"` + FormMeta *FormMeta `json:"formMeta,omitempty"` + Kanban *KanbanMeta `json:"kanban,omitempty"` + Pivot *PivotMeta `json:"pivot,omitempty"` + Search *SearchMeta `json:"search,omitempty"` + Graph *GraphMeta `json:"graph,omitempty"` + Activity *ActivityMeta `json:"activity,omitempty"` + Calendar *CalendarMeta `json:"calendar,omitempty"` + Gantt *GanttMeta `json:"gantt,omitempty"` + Map *MapMeta `json:"map,omitempty"` + Cohort *CohortMeta `json:"cohort,omitempty"` Hierarchy *HierarchyMeta `json:"hierarchy,omitempty"` - Report *ReportMeta `json:"report,omitempty"` + Report *ReportMeta `json:"report,omitempty"` } type FormMeta struct { @@ -115,13 +116,13 @@ type ArchListSubview struct { } type ArchButton struct { - Name string `json:"name"` - String string `json:"string"` - Type string `json:"type"` - Class string `json:"class,omitempty"` - Invisible bool `json:"invisible,omitempty"` - InvisibleExpr string `json:"invisible_expr,omitempty"` - Confirm string `json:"confirm,omitempty"` + Name string `json:"name"` + String string `json:"string"` + Type string `json:"type"` + Class string `json:"class,omitempty"` + Invisible bool `json:"invisible,omitempty"` + InvisibleExpr string `json:"invisible_expr,omitempty"` + Confirm string `json:"confirm,omitempty"` } type ArchHeader struct { @@ -224,6 +225,8 @@ type GraphMeta struct { Chart string `json:"chart,omitempty"` } +type ActivityMeta struct{} + type CalendarMeta struct { DateStart string `json:"dateStart,omitempty"` DateStop string `json:"dateStop,omitempty"` diff --git a/core/engine/templates/auth/login.html b/core/engine/templates/auth/login.html index e6a36832..e99eaf48 100644 --- a/core/engine/templates/auth/login.html +++ b/core/engine/templates/auth/login.html @@ -64,11 +64,6 @@

Sign in

- diff --git a/core/engine/templates/portal/portal_home.html b/core/engine/templates/portal/portal_home.html index bd7238b3..397ebd7d 100644 --- a/core/engine/templates/portal/portal_home.html +++ b/core/engine/templates/portal/portal_home.html @@ -12,7 +12,10 @@
{{if .LogoURL}}{{end}} {{.AppName}} Portal - Sign out +
+ + +
{{if .Error}}

{{.Error}}

{{end}} diff --git a/core/engine/templates/shell/base.html b/core/engine/templates/shell/base.html index a51ae6b7..910e2d4d 100644 --- a/core/engine/templates/shell/base.html +++ b/core/engine/templates/shell/base.html @@ -4,6 +4,7 @@ + {{if .CSRFToken}}{{end}} {{if .ModuleName}}{{.ModuleName}} | {{end}}{{.AppName}} {{range .ExtraStylesheetURLs}} @@ -273,6 +274,8 @@

{{.Name}}

aria-selected="true" data-sum-activity-tab="messages">Messages + Activities