@bergetai/odoo-cli 0.9.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/cli.js +319 -0
- package/dist/commands/attachments.js +158 -0
- package/dist/commands/footprint.js +843 -0
- package/dist/commands/marketing.js +184 -0
- package/dist/commands/messages.js +309 -0
- package/dist/commands/users.js +58 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,6 +65,37 @@ odoo crm teams create --name "Partnerships" --team-leader alice@acme.com
|
|
|
65
65
|
odoo crm teams update 5 --name "Direct Sales (renamed)" --dry-run
|
|
66
66
|
odoo crm teams delete 5 --dry-run
|
|
67
67
|
|
|
68
|
+
# Record attachments (ir.attachment) — explicit scope required
|
|
69
|
+
odoo attachments list --res-model helpdesk.ticket --res-id 398 --json
|
|
70
|
+
odoo attachments list --res-model mail.message --res-id 253924
|
|
71
|
+
odoo attachments list --search "spec" --limit 100
|
|
72
|
+
odoo attachments show 31337
|
|
73
|
+
odoo attachments download 31337 --out ./spec.pdf
|
|
74
|
+
odoo attachments delete 31337 --dry-run
|
|
75
|
+
odoo attachments delete 31337
|
|
76
|
+
|
|
77
|
+
# Marketing (mailing.*) — email is the key; trace deletion is always explicit
|
|
78
|
+
odoo marketing lists list
|
|
79
|
+
odoo marketing lists show 11
|
|
80
|
+
odoo marketing contacts list --email person@example.com --json
|
|
81
|
+
odoo marketing contacts list --list "Berget Code waitlist"
|
|
82
|
+
odoo marketing contacts delete 441 --dry-run
|
|
83
|
+
odoo marketing contacts delete 441
|
|
84
|
+
odoo marketing traces list --email person@example.com --json
|
|
85
|
+
odoo marketing traces delete 7001 --dry-run
|
|
86
|
+
odoo marketing traces delete 7001
|
|
87
|
+
|
|
88
|
+
# Sign-in identities (res.users) — show + guarded delete only
|
|
89
|
+
odoo users show 9
|
|
90
|
+
odoo users delete 9 --dry-run
|
|
91
|
+
odoo users delete 9
|
|
92
|
+
odoo users delete 9 3782 --json
|
|
93
|
+
|
|
94
|
+
# Footprint (read-only) — everything tied to an email, with SOP dispositions
|
|
95
|
+
odoo footprint person@example.com --json
|
|
96
|
+
odoo footprint person@example.com # grouped human report
|
|
97
|
+
odoo footprint person@example.com --active-only # what the UI sees
|
|
98
|
+
|
|
68
99
|
# Internal notes (NEVER sends email to customer)
|
|
69
100
|
odoo helpdesk tickets add-note 398 --body "Internal note text"
|
|
70
101
|
odoo helpdesk tickets add-note 398 --body-file note.md
|
|
@@ -83,6 +114,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
|
|
|
83
114
|
|
|
84
115
|
## Features
|
|
85
116
|
|
|
117
|
+
- Footprint overview (`footprint <email>`) — one read-only invocation that enumerates everything tied to an email across the erasure-relevant modules (`res.partner` incl. archived, `res.users`, `crm.lead`, `mailing.*`, `helpdesk.ticket`, `mail.message`, `ir.attachment`), correlates the found partners into the RESTRICT accounting surfaces (`account.move`/`sale.order` incl. invoice & shipping addresses, `account.payment`), and classifies every record by erasure disposition: `delete`, `anonymize` (referenced — shell, never unlink), `keep` (bokföringslagen 7-year retention), `review` (account roots, legal-claim branch, internal users, unverifiable attachment parents). `--json` emits a structured per-group payload (fixed 11-group key order, `totalRecords`, per-group `found`/`total` server count/`truncated`/`dispositions` tally, many2one tuples normalized to `{id, name}`) with a `commandHint` on each actionable record, a top-level `runOrder` (SOP execution order), and decision-grade fields on partner records (`child_ids`/`user_ids`/`vat`). Review records with out-of-footprint parents are resolved inline — the parent's name is embedded in the reason (bounded by a per-model fetch cap; beyond it records keep an `inspectHint`). Every record tells you what to run next — or already answers the question. Read-only by design: verification (art. 12.6) is a human step and the argument must already be the verified email; record-rule hidden rows (e.g. `user_notification` messages) mean the raw SQL pre-flight stays the authoritative evidence. Never mutates anything
|
|
86
118
|
- Read-only CRM surface (`crm leads list/show`) — default columns, `--fields`/`--all-fields`, `--limit`/`--offset`/`--order`, `--domain`/`--filter` with `fields_get` type checking, reference resolution (`--stage`/`--team`/`--assignee`/`--tag`), table/JSON output with `search_count` envelope
|
|
87
119
|
- Partners surface (`partners list/show/tags/create/update/delete`) — `res.partner` CRUD with `--company`/`--individual` filters, `--search` (case-insensitive name/email match), `--tag` (repeatable, resolves `res.partner.category` by exact name then ilike), `--archived` (include inactive), curated list columns (ID, Name, Company Type, City, Email, Is a Company, Tags, Salesperson), curated `show` view with tag names, salesperson, customer/supplier ranks plus every populated `x_studio*` custom field, `partners tags` (usage counts via `read_group`, sorted by usage), first-class mutation flags for `--name`, `--email`, `--phone`, `--website`, `--parent` (m2o), `--is-company`, `--street`, `--city`, `--zip`, `--country` (m2o), `--comment`, `--add-tag`/`--remove-tag` on update (unknown tags fail on add; removal silently skips tags the record lacks; protected fields `company_type` and `x_studio_invoice_approved` refuse writes), full `--dry-run` support, `--fields`/`--all-fields`, table/JSON output
|
|
88
120
|
- Sales teams surface (`crm teams list/show/create/update/delete`) — `--archived` (include inactive teams), `--team-leader` (team leader, login/email/name), `--active`/`--use-leads`/`--use-opportunities`/`--invoiced-target`/`--color`/`--sequence`, full `--dry-run` support, `--fields`/`--all-fields`, table/JSON output
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,12 @@ import { createTaskCommand, projectListCommand, projectShowCommand, tasksListCom
|
|
|
10
10
|
import { documentsDocuments, documentsListCommand, foldersListCommand, documentsShowCommand, documentsCreateCommand, documentsUpdateCommand, documentsDeleteCommand, treeCommand, uploadCommand, downloadCommand, } from "./commands/documents.js";
|
|
11
11
|
import { versionCommand } from "./commands/version.js";
|
|
12
12
|
import { partnersCreateCommand, partnersDeleteCommand, partnersListCommand, partnersShowCommand, partnersTagsCommand, partnersUpdateCommand, resPartner, } from "./commands/partners.js";
|
|
13
|
+
import { usersDeleteCommand, usersShowCommand, } from "./commands/users.js";
|
|
14
|
+
import { attachmentsDeleteCommand, attachmentsDownloadCommand, attachmentsListCommand, attachmentsShowCommand, } from "./commands/attachments.js";
|
|
15
|
+
import { messagesDeleteCommand, messagesListCommand, messagesScrubCommand, messagesShowCommand, } from "./commands/messages.js";
|
|
16
|
+
import { contactsDeleteCommand, contactsListCommand, contactsShowCommand, listsListCommand, listsShowCommand, tracesDeleteCommand, tracesListCommand, tracesShowCommand, } from "./commands/marketing.js";
|
|
13
17
|
import { modelsFieldsCommand, modelsReadCommand, } from "./commands/models.js";
|
|
18
|
+
import { footprintCommand, } from "./commands/footprint.js";
|
|
14
19
|
import { CliError } from "./errors.js";
|
|
15
20
|
import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
|
|
16
21
|
import { printError } from "./output.js";
|
|
@@ -721,6 +726,320 @@ Examples:
|
|
|
721
726
|
.action(async () => {
|
|
722
727
|
await run((g) => partnersTagsCommand(g), program);
|
|
723
728
|
});
|
|
729
|
+
const users = program
|
|
730
|
+
.command("users")
|
|
731
|
+
.description("sign-in identities (res.users)");
|
|
732
|
+
users
|
|
733
|
+
.command("show")
|
|
734
|
+
.description("read one user (portal login, email, linked partner; internal accounts included)")
|
|
735
|
+
.argument("<id>", "record id")
|
|
736
|
+
.option("--fields <a,b,c>", "fields to display")
|
|
737
|
+
.option("--all-fields", "display every readable field")
|
|
738
|
+
.addHelpText("after", `
|
|
739
|
+
Examples:
|
|
740
|
+
$ odoo users show 3781
|
|
741
|
+
$ odoo users show 3781 --json`)
|
|
742
|
+
.action(async (id, opts) => {
|
|
743
|
+
await run((g) => usersShowCommand(id, opts, g), program);
|
|
744
|
+
});
|
|
745
|
+
users
|
|
746
|
+
.command("delete")
|
|
747
|
+
.description("delete portal users (preview with --dry-run; unknown ids are skipped)")
|
|
748
|
+
.argument("<id...>", "record ids")
|
|
749
|
+
.addHelpText("after", `
|
|
750
|
+
Portal users only (share=true): any internal user (share=false) in the
|
|
751
|
+
batch aborts the whole command — employees and service accounts are managed
|
|
752
|
+
in the Odoo UI, never via the CLI.
|
|
753
|
+
|
|
754
|
+
Order matters: delete the portal user BEFORE deleting their partner —
|
|
755
|
+
res_users.partner_id is ondelete=restrict, so a surviving user makes the
|
|
756
|
+
partner delete fail with an FK violation.
|
|
757
|
+
|
|
758
|
+
Examples:
|
|
759
|
+
$ odoo users delete 3781 --dry-run
|
|
760
|
+
$ odoo users delete 3781
|
|
761
|
+
$ odoo users delete 3781 3782 --json`)
|
|
762
|
+
.action(async (ids) => {
|
|
763
|
+
await run((g) => usersDeleteCommand(ids, g), program);
|
|
764
|
+
});
|
|
765
|
+
const attachments = program
|
|
766
|
+
.command("attachments")
|
|
767
|
+
.description("record attachments (ir.attachment)");
|
|
768
|
+
attachments
|
|
769
|
+
.command("list")
|
|
770
|
+
.description("list attachments; REQUIRES a scope: --res-model, --res-id, and/or --search (AND-ed)")
|
|
771
|
+
.option("--res-model <model>", "filter by linked record model (soft reference)")
|
|
772
|
+
.option("--res-id <n>", "filter by linked record id")
|
|
773
|
+
.option("--search <text>", "ilike match on the attachment name")
|
|
774
|
+
.option("--domain <json>", "extra Odoo domain as a JSON array (AND-ed with the scope flags)")
|
|
775
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
776
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
777
|
+
.option("--order <order>", 'sort order, e.g. "create_date desc"', undefined)
|
|
778
|
+
.addHelpText("after", `
|
|
779
|
+
The ir.attachment table also holds accounting report PDFs. Never browse
|
|
780
|
+
or delete without a scope.
|
|
781
|
+
|
|
782
|
+
Examples:
|
|
783
|
+
$ odoo attachments list --res-model helpdesk.ticket --res-id 398 --json
|
|
784
|
+
$ odoo attachments list --res-model mail.message --res-id 253924
|
|
785
|
+
$ odoo attachments list --search "spec" --limit 100
|
|
786
|
+
$ odoo attachments list --res-model crm.lead --domain '[["res_id","=",91]]'`)
|
|
787
|
+
.action(async (opts) => {
|
|
788
|
+
await run((g) => attachmentsListCommand(opts, g), program);
|
|
789
|
+
});
|
|
790
|
+
attachments
|
|
791
|
+
.command("show")
|
|
792
|
+
.description("read one attachment (metadata; the datas payload is excluded)")
|
|
793
|
+
.argument("<id>", "record id")
|
|
794
|
+
.option("--fields <a,b,c>", "fields to display")
|
|
795
|
+
.option("--all-fields", "display every readable field (includes datas)")
|
|
796
|
+
.action(async (id, opts) => {
|
|
797
|
+
await run((g) => attachmentsShowCommand(id, opts, g), program);
|
|
798
|
+
});
|
|
799
|
+
attachments
|
|
800
|
+
.command("download")
|
|
801
|
+
.description("decode the attachment payload to a local file (--out - for stdout)")
|
|
802
|
+
.argument("<id>", "record id")
|
|
803
|
+
.option("--out <path>", "output path ('-' for stdout; default: datas_fname/name)")
|
|
804
|
+
.option("--force", "overwrite an existing output file")
|
|
805
|
+
.action(async (id, opts) => {
|
|
806
|
+
await run((g) => attachmentsDownloadCommand(id, opts, g), program);
|
|
807
|
+
});
|
|
808
|
+
attachments
|
|
809
|
+
.command("delete")
|
|
810
|
+
.description("delete attachments explicitly (preview with --dry-run; unknown ids are skipped)")
|
|
811
|
+
.argument("<id...>", "record ids")
|
|
812
|
+
.addHelpText("after", `
|
|
813
|
+
Attachments are NEVER removed automatically when their parent record is
|
|
814
|
+
deleted (res_model/res_id is a soft link, no FK cascade) — deletion must be
|
|
815
|
+
explicit. Scope the ids via 'attachments list' first.
|
|
816
|
+
|
|
817
|
+
Examples:
|
|
818
|
+
$ odoo attachments delete 31337 --dry-run
|
|
819
|
+
$ odoo attachments delete 31337 31338`)
|
|
820
|
+
.action(async (ids) => {
|
|
821
|
+
await run((g) => attachmentsDeleteCommand(ids, g), program);
|
|
822
|
+
});
|
|
823
|
+
const messages = program
|
|
824
|
+
.command("messages")
|
|
825
|
+
.description("chatter messages (mail.message), erasure-scoped");
|
|
826
|
+
messages
|
|
827
|
+
.command("list")
|
|
828
|
+
.description("list chatter on a record; REQUIRES a scope: --res-model and/or --res-id (AND-ed); bodies excluded (length only)")
|
|
829
|
+
.option("--res-model <model>", "filter by parent record model (e.g. res.partner)")
|
|
830
|
+
.option("--res-id <n>", "filter by parent record id")
|
|
831
|
+
.option("--domain <json>", "extra Odoo domain as a JSON array (AND-ed with the scope flags)")
|
|
832
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
833
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
834
|
+
.option("--order <order>", 'sort order, e.g. "date desc"', undefined)
|
|
835
|
+
.addHelpText("after", `
|
|
836
|
+
mail.message is a global chatter table across every model. Never browse
|
|
837
|
+
or delete without a scope. Bodies are excluded from list output
|
|
838
|
+
(body_length only); use 'messages show' for the full body.
|
|
839
|
+
|
|
840
|
+
Examples:
|
|
841
|
+
$ odoo messages list --res-model res.partner --res-id 3201 --json
|
|
842
|
+
$ odoo messages show 295203`)
|
|
843
|
+
.action(async (opts) => {
|
|
844
|
+
await run((g) => messagesListCommand(opts, g), program);
|
|
845
|
+
});
|
|
846
|
+
messages
|
|
847
|
+
.command("show")
|
|
848
|
+
.description("read one message (full body and tracking value ids)")
|
|
849
|
+
.argument("<id>", "record id")
|
|
850
|
+
.option("--fields <a,b,c>", "fields to display")
|
|
851
|
+
.option("--all-fields", "display every readable field")
|
|
852
|
+
.action(async (id, opts) => {
|
|
853
|
+
await run((g) => messagesShowCommand(id, opts, g), program);
|
|
854
|
+
});
|
|
855
|
+
messages
|
|
856
|
+
.command("scrub")
|
|
857
|
+
.description("strip tracked-change residue (old values) and replace the body with an erasure marker (preview with --dry-run)")
|
|
858
|
+
.argument("<id...>", "message ids")
|
|
859
|
+
.requiredOption("--res-model <model>", "parent record model — every id is cross-checked against it")
|
|
860
|
+
.requiredOption("--res-id <n>", "parent record id — every id is cross-checked against it")
|
|
861
|
+
.option("--marker <text>", "replacement body text (plain text, escaped, wrapped in <p>); default is the fixed SOP marker")
|
|
862
|
+
.addHelpText("after", `
|
|
863
|
+
Erasure SOP usage: removing personal data from a record leaves a chatter
|
|
864
|
+
message whose tracking rows carry the PRE-change values (old email, old
|
|
865
|
+
name). Scrub removes those rows and replaces the body with an erasure
|
|
866
|
+
marker. Messages without tracking rows are skipped (never rewrite
|
|
867
|
+
arbitrary chatter — use 'messages delete' for verified personal-data
|
|
868
|
+
bodies). Dry-run shows the tracked FIELD NAMES, never the old values.
|
|
869
|
+
The default marker is fixed SOP text; --marker exists because messages
|
|
870
|
+
attach to many models — it may not contain an email-shaped token.
|
|
871
|
+
|
|
872
|
+
Examples:
|
|
873
|
+
$ odoo messages scrub 295203 --res-model res.partner --res-id 3201 --dry-run
|
|
874
|
+
$ odoo messages scrub 295203 --res-model res.partner --res-id 3201
|
|
875
|
+
$ odoo messages scrub 5511 --res-model helpdesk.ticket --res-id 398 --marker "Ticket anonymized (erasure flow)"`)
|
|
876
|
+
.action(async (ids, opts) => {
|
|
877
|
+
await run((g) => messagesScrubCommand(ids, opts, g), program);
|
|
878
|
+
});
|
|
879
|
+
messages
|
|
880
|
+
.command("delete")
|
|
881
|
+
.description("delete chatter messages outright, for verified personal data in bodies (preview with --dry-run)")
|
|
882
|
+
.argument("<id...>", "message ids")
|
|
883
|
+
.requiredOption("--res-model <model>", "parent record model — every id is cross-checked against it")
|
|
884
|
+
.requiredOption("--res-id <n>", "parent record id — every id is cross-checked against it")
|
|
885
|
+
.addHelpText("after", `
|
|
886
|
+
This is the escalation-scope weapon: verify (SQL/probe) that the message
|
|
887
|
+
bodies actually contain personal data BEFORE deleting. For tracked-change
|
|
888
|
+
residue use 'messages scrub' instead — it keeps the accountability trail.
|
|
889
|
+
|
|
890
|
+
Examples:
|
|
891
|
+
$ odoo messages delete 44022 44028 --res-model res.partner --res-id 3201 --dry-run
|
|
892
|
+
$ odoo messages delete 44022 --res-model res.partner --res-id 3201`)
|
|
893
|
+
.action(async (ids, opts) => {
|
|
894
|
+
await run((g) => messagesDeleteCommand(ids, opts, g), program);
|
|
895
|
+
});
|
|
896
|
+
const marketing = program
|
|
897
|
+
.command("marketing")
|
|
898
|
+
.description("marketing lists, contacts, and send traces (mailing.*)");
|
|
899
|
+
const mlLists = marketing
|
|
900
|
+
.command("lists")
|
|
901
|
+
.description("mailing lists (mailing.list; read-only)");
|
|
902
|
+
mlLists
|
|
903
|
+
.command("list")
|
|
904
|
+
.description("list mailing lists")
|
|
905
|
+
.option("--search <text>", "ilike match on the list name")
|
|
906
|
+
.option("--domain <json>", "Odoo domain as a JSON array")
|
|
907
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
908
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
909
|
+
.option("--order <order>", 'sort order, e.g. "contact_count desc"', undefined)
|
|
910
|
+
.action(async (opts) => {
|
|
911
|
+
await run((g) => listsListCommand(opts, g), program);
|
|
912
|
+
});
|
|
913
|
+
mlLists
|
|
914
|
+
.command("show")
|
|
915
|
+
.description("read one mailing list (use --all-fields for subscriber ids)")
|
|
916
|
+
.argument("<id>", "record id")
|
|
917
|
+
.option("--fields <a,b,c>", "fields to display")
|
|
918
|
+
.option("--all-fields", "display every readable field")
|
|
919
|
+
.action(async (id, opts) => {
|
|
920
|
+
await run((g) => listsShowCommand(id, opts, g), program);
|
|
921
|
+
});
|
|
922
|
+
const mlContacts = marketing
|
|
923
|
+
.command("contacts")
|
|
924
|
+
.description("mailing contacts (mailing.contact)");
|
|
925
|
+
mlContacts
|
|
926
|
+
.command("list")
|
|
927
|
+
.description("list mailing contacts; --email is the identity-safe filter (email column only)")
|
|
928
|
+
.option("--search <text>", "ilike match on name OR email (interactive browsing)")
|
|
929
|
+
.option("--email <text>", "ilike match on email ONLY (identity-safe; exclusive with --search)")
|
|
930
|
+
.option("--list <name|id>", "filter by mailing list membership (resolved by name)")
|
|
931
|
+
.option("--domain <json>", "Odoo domain as a JSON array")
|
|
932
|
+
.option("--fields <a,b,c>", "columns to display")
|
|
933
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
934
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
935
|
+
.option("--order <order>", 'sort order, e.g. "email asc"', undefined)
|
|
936
|
+
.addHelpText("after", `
|
|
937
|
+
Email is the only reliable key for a person (name matching showed casing
|
|
938
|
+
variance in production): prefer --email over --search wherever the identity
|
|
939
|
+
of the subject matters.
|
|
940
|
+
|
|
941
|
+
Examples:
|
|
942
|
+
$ odoo marketing contacts list --email person@example.com --json
|
|
943
|
+
$ odoo marketing contacts list --list "Berget Code waitlist"
|
|
944
|
+
$ odoo marketing contacts delete 441 442 --dry-run`)
|
|
945
|
+
.action(async (opts) => {
|
|
946
|
+
await run((g) => contactsListCommand(opts, g), program);
|
|
947
|
+
});
|
|
948
|
+
mlContacts
|
|
949
|
+
.command("show")
|
|
950
|
+
.description("read one mailing contact (membership via --all-fields/list_ids)")
|
|
951
|
+
.argument("<id>", "record id")
|
|
952
|
+
.option("--fields <a,b,c>", "fields to display")
|
|
953
|
+
.option("--all-fields", "display every readable field")
|
|
954
|
+
.action(async (id, opts) => {
|
|
955
|
+
await run((g) => contactsShowCommand(id, opts, g), program);
|
|
956
|
+
});
|
|
957
|
+
mlContacts
|
|
958
|
+
.command("delete")
|
|
959
|
+
.description("delete mailing contacts (preview with --dry-run; unknown ids are skipped)")
|
|
960
|
+
.argument("<id...>", "record ids")
|
|
961
|
+
.addHelpText("after", `
|
|
962
|
+
Deleting a contact does NOT delete the person's send traces: mailing.trace
|
|
963
|
+
rows carry a denormalized email with no FK to the contact — erase both:
|
|
964
|
+
1. odoo marketing traces list --email <address> → trace ids
|
|
965
|
+
2. odoo marketing traces delete <ids>
|
|
966
|
+
|
|
967
|
+
Examples:
|
|
968
|
+
$ odoo marketing contacts delete 441 --dry-run
|
|
969
|
+
$ odoo marketing contacts delete 441 442`)
|
|
970
|
+
.action(async (ids) => {
|
|
971
|
+
await run((g) => contactsDeleteCommand(ids, g), program);
|
|
972
|
+
});
|
|
973
|
+
const mlTraces = marketing
|
|
974
|
+
.command("traces")
|
|
975
|
+
.description("per-recipient send traces (mailing.trace; denormalized email, no FK)");
|
|
976
|
+
mlTraces
|
|
977
|
+
.command("list")
|
|
978
|
+
.description("list send traces by denormalized email — independent of contact records")
|
|
979
|
+
.option("--email <text>", "ilike match on the trace email column")
|
|
980
|
+
.option("--mailing <name|id>", "filter by campaign (mailing.mailing, resolved by name)")
|
|
981
|
+
.option("--state <status>", 'filter by trace_status (e.g. "sent", "opened", "replied", "error")')
|
|
982
|
+
.option("--domain <json>", "Odoo domain as a JSON array")
|
|
983
|
+
.option("--fields <a,b,c>", "columns to display")
|
|
984
|
+
.option("--limit <n>", "max records (default: 50)", "50")
|
|
985
|
+
.option("--offset <n>", "skip the first n records", "0")
|
|
986
|
+
.option("--order <order>", 'sort order, e.g. "create_date desc"', undefined)
|
|
987
|
+
.action(async (opts) => {
|
|
988
|
+
await run((g) => tracesListCommand(opts, g), program);
|
|
989
|
+
});
|
|
990
|
+
mlTraces
|
|
991
|
+
.command("show")
|
|
992
|
+
.description("read one send trace")
|
|
993
|
+
.argument("<id>", "record id")
|
|
994
|
+
.option("--fields <a,b,c>", "fields to display")
|
|
995
|
+
.option("--all-fields", "display every readable field")
|
|
996
|
+
.action(async (id, opts) => {
|
|
997
|
+
await run((g) => tracesShowCommand(id, opts, g), program);
|
|
998
|
+
});
|
|
999
|
+
mlTraces
|
|
1000
|
+
.command("delete")
|
|
1001
|
+
.description("delete send traces explicitly (preview with --dry-run; unknown ids are skipped)")
|
|
1002
|
+
.argument("<id...>", "record ids")
|
|
1003
|
+
.addHelpText("after", `
|
|
1004
|
+
mailing.trace has NO FK to mailing.contact: deleting contacts never removes
|
|
1005
|
+
traces — deletion here is always explicit. Trace email is denormalized, so
|
|
1006
|
+
trace deletion never requires the contact record to exist.
|
|
1007
|
+
|
|
1008
|
+
Examples:
|
|
1009
|
+
$ odoo marketing traces list --email person@example.com --json
|
|
1010
|
+
$ odoo marketing traces delete 7001 7002 --dry-run`)
|
|
1011
|
+
.action(async (ids) => {
|
|
1012
|
+
await run((g) => tracesDeleteCommand(ids, g), program);
|
|
1013
|
+
});
|
|
1014
|
+
program
|
|
1015
|
+
.command("footprint")
|
|
1016
|
+
.description("everything tied to an email across erasure-relevant modules, with SOP dispositions — read-only")
|
|
1017
|
+
.argument("<email>", "the verified email of the data subject")
|
|
1018
|
+
.option("--active-only", "search only active rows (default: active + archived — archived data is still personal data)")
|
|
1019
|
+
.option("--limit <n>", "max records per group", "500")
|
|
1020
|
+
.addHelpText("after", `
|
|
1021
|
+
Read-only overview for erasure handling (Flow A step 1 + the §6.2.4
|
|
1022
|
+
pre-flight): searches res.partner, res.users, crm.lead, mailing.*,
|
|
1023
|
+
helpdesk.ticket, mail.message and ir.attachment by email and correlates the
|
|
1024
|
+
found partners into the RESTRICT accounting surfaces (account.move /
|
|
1025
|
+
sale.order / account.payment) that decide unlink-vs-anonymize.
|
|
1026
|
+
|
|
1027
|
+
Dispositions: delete | anonymize (referenced — shell, never unlink) | keep
|
|
1028
|
+
(bokföringslagen) | review (machine cannot decide: account roots,
|
|
1029
|
+
legal-claim branch, internal users, unverifiable attachment parents).
|
|
1030
|
+
|
|
1031
|
+
Verification is a human step (art. 12.6): the argument must already be the
|
|
1032
|
+
VERIFIED email. Nothing is mutated — act through the per-module commands,
|
|
1033
|
+
--dry-run first. Archived rows are included by default; run the §6.2.4 SQL
|
|
1034
|
+
pre-flight as the authoritative evidence (record rules can hide rows).
|
|
1035
|
+
|
|
1036
|
+
Examples:
|
|
1037
|
+
$ odoo footprint person@example.com --json
|
|
1038
|
+
$ odoo footprint person@example.com
|
|
1039
|
+
$ odoo footprint person@example.com --active-only`)
|
|
1040
|
+
.action(async (email, opts) => {
|
|
1041
|
+
await run((g) => footprintCommand(email, opts, g), program);
|
|
1042
|
+
});
|
|
724
1043
|
const models = program
|
|
725
1044
|
.command("models")
|
|
726
1045
|
.description("generic model introspection (any model with an XML-RPC API)");
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { ModelFields } from "../model.js";
|
|
5
|
+
import { authenticatedClient, parseId, showCommand, } from "../resource.js";
|
|
6
|
+
import { deleteCommand } from "../mutation.js";
|
|
7
|
+
import { notFoundError, usageError } from "../errors.js";
|
|
8
|
+
import { colorEnabled, printJson } from "../output.js";
|
|
9
|
+
import { parseDomain } from "../filter.js";
|
|
10
|
+
import { humanCell } from "../table.js";
|
|
11
|
+
/**
|
|
12
|
+
* ir.attachment surface (generic binary store behind soft res_model/res_id
|
|
13
|
+
* links). Read + download + delete only; no create/update. `list` REQUIRES an
|
|
14
|
+
* explicit scope (--res-model / --res-id / --search): the table also holds
|
|
15
|
+
* accounting report PDFs (bokföringsunderlag), and an unbounded dump would
|
|
16
|
+
* invite filter-less bulk deletion. Nothing in Odoo cascades attachment
|
|
17
|
+
* deletion when a parent record is removed, so erasure must call
|
|
18
|
+
* `attachments delete` explicitly (invariant 4).
|
|
19
|
+
*/
|
|
20
|
+
export const irAttachment = {
|
|
21
|
+
model: "ir.attachment",
|
|
22
|
+
showCommand: "odoo attachments show",
|
|
23
|
+
defaultListFields: [
|
|
24
|
+
"id",
|
|
25
|
+
"name",
|
|
26
|
+
"res_model",
|
|
27
|
+
"res_id",
|
|
28
|
+
"mimetype",
|
|
29
|
+
"file_size",
|
|
30
|
+
"create_date",
|
|
31
|
+
],
|
|
32
|
+
showExtraFields: ["checksum", "create_uid", "write_date", "url", "write_uid"],
|
|
33
|
+
resolutionHints: {},
|
|
34
|
+
priorityLabels: {},
|
|
35
|
+
pinnedDomain: [],
|
|
36
|
+
references: {},
|
|
37
|
+
};
|
|
38
|
+
function parseCount(raw, flag) {
|
|
39
|
+
if (!/^\d+$/.test(raw)) {
|
|
40
|
+
throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
|
|
41
|
+
}
|
|
42
|
+
return Number(raw);
|
|
43
|
+
}
|
|
44
|
+
export async function attachmentsListCommand(opts, globals) {
|
|
45
|
+
const client = await authenticatedClient(globals);
|
|
46
|
+
const resId = opts.resId !== undefined ? parseCount(opts.resId, "--res-id") : undefined;
|
|
47
|
+
// Scope guard: never browse the whole ir.attachment table. It holds
|
|
48
|
+
// accounting PDFs alongside erasure-relevant files, so an unbounded dump
|
|
49
|
+
// is a footgun both to read and to delete from.
|
|
50
|
+
if (opts.resModel === undefined && resId === undefined && !opts.search) {
|
|
51
|
+
throw usageError("attachments list requires an explicit scope", "pass --res-model (e.g. `--res-model helpdesk.ticket`), --res-id, and/or --search; combine freely, they are AND-ed");
|
|
52
|
+
}
|
|
53
|
+
const limit = parseCount(opts.limit, "--limit");
|
|
54
|
+
const offset = parseCount(opts.offset, "--offset");
|
|
55
|
+
const fieldsMeta = await ModelFields.load(client, "ir.attachment");
|
|
56
|
+
// No `active` field on ir.attachment (no archive machinery): plain
|
|
57
|
+
// search_read/search_count without an active_test context.
|
|
58
|
+
const domain = [];
|
|
59
|
+
if (opts.resModel !== undefined)
|
|
60
|
+
domain.push(["res_model", "=", opts.resModel]);
|
|
61
|
+
if (resId !== undefined)
|
|
62
|
+
domain.push(["res_id", "=", resId]);
|
|
63
|
+
if (opts.search)
|
|
64
|
+
domain.push(["name", "ilike", opts.search]);
|
|
65
|
+
if (opts.domain)
|
|
66
|
+
domain.push(...parseDomain(opts.domain));
|
|
67
|
+
const order = opts.order ?? "id asc";
|
|
68
|
+
const [records, count] = await Promise.all([
|
|
69
|
+
client.executeKw("ir.attachment", "search_read", [domain], {
|
|
70
|
+
fields: irAttachment.defaultListFields,
|
|
71
|
+
limit,
|
|
72
|
+
offset,
|
|
73
|
+
order,
|
|
74
|
+
}),
|
|
75
|
+
client.executeKw("ir.attachment", "search_count", [
|
|
76
|
+
domain,
|
|
77
|
+
]),
|
|
78
|
+
]);
|
|
79
|
+
if (globals.json) {
|
|
80
|
+
printJson({ model: "ir.attachment", records, count, limit, offset });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const headers = irAttachment.defaultListFields.map((f) => fieldsMeta.get(f)?.string ?? f);
|
|
84
|
+
const rows = records.map((rec) => headers.map((_, i) => humanCell(rec[irAttachment.defaultListFields[i]])));
|
|
85
|
+
process.stdout.write(formatPlainTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
|
|
86
|
+
}
|
|
87
|
+
/** Aligned key-value-free table; TTY coloring matches other list commands. */
|
|
88
|
+
function formatPlainTable(headers, rows, _color) {
|
|
89
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
90
|
+
const fmt = (cells) => cells
|
|
91
|
+
.map((c, i) => c.padEnd(widths[i]))
|
|
92
|
+
.join(" ")
|
|
93
|
+
.trimEnd();
|
|
94
|
+
const lines = [fmt(headers)];
|
|
95
|
+
for (const r of rows)
|
|
96
|
+
lines.push(fmt(r));
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
export function attachmentsShowCommand(idArg, opts, globals) {
|
|
100
|
+
return showCommand(irAttachment, idArg, opts, globals);
|
|
101
|
+
}
|
|
102
|
+
export function attachmentsDeleteCommand(idArgs, globals) {
|
|
103
|
+
return deleteCommand(irAttachment, idArgs, globals);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Read `datas` (base64 of the filestore payload) and write decoded bytes to
|
|
107
|
+
* --out (or "-" for stdout, or a default derived from datas_fname/name).
|
|
108
|
+
* Modeled on the `documents download` implementation.
|
|
109
|
+
*/
|
|
110
|
+
export async function attachmentsDownloadCommand(idArg, opts, globals) {
|
|
111
|
+
const id = parseId(idArg);
|
|
112
|
+
const client = await authenticatedClient(globals);
|
|
113
|
+
await ModelFields.load(client, "ir.attachment");
|
|
114
|
+
const rows = (await client.executeKw("ir.attachment", "read", [[id]], {
|
|
115
|
+
fields: ["name", "datas", "mimetype", "url"],
|
|
116
|
+
}));
|
|
117
|
+
if (rows.length === 0) {
|
|
118
|
+
throw notFoundError("record_not_found", `no ir.attachment record with id ${id}`);
|
|
119
|
+
}
|
|
120
|
+
const rec = rows[0];
|
|
121
|
+
if (rec.datas === false || rec.datas === "") {
|
|
122
|
+
throw usageError(`attachment ${id} "${rec.name}" has no file content`, "url-style attachments store a link instead of bytes" +
|
|
123
|
+
(rec.url ? ` (\u0022${rec.url}\u0022)` : " (check the url field)"));
|
|
124
|
+
}
|
|
125
|
+
const buffer = Buffer.from(rec.datas, "base64");
|
|
126
|
+
if (opts.out === "-") {
|
|
127
|
+
process.stdout.write(buffer);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const defaultName = rec.name || `ir.attachment.${id}.bin`;
|
|
131
|
+
const outPath = resolve(opts.out ?? defaultName);
|
|
132
|
+
try {
|
|
133
|
+
await stat(outPath);
|
|
134
|
+
if (!opts.force) {
|
|
135
|
+
throw usageError(`file already exists: ${outPath}`, "pass --force to overwrite");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
if (err.code !== "ENOENT")
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
await writeFile(outPath, buffer);
|
|
143
|
+
if (globals.json) {
|
|
144
|
+
printJson({
|
|
145
|
+
model: "ir.attachment",
|
|
146
|
+
action: "download",
|
|
147
|
+
id,
|
|
148
|
+
name: rec.name,
|
|
149
|
+
path: outPath,
|
|
150
|
+
file_size: buffer.length,
|
|
151
|
+
mimetype: rec.mimetype,
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const color = colorEnabled(false) && process.stdout.isTTY === true;
|
|
156
|
+
const line = `Downloaded ir.attachment ${id} → ${outPath} (${buffer.length} bytes)`;
|
|
157
|
+
process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
|
|
158
|
+
}
|