@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { ModelFields, resolveReference } from "../model.js";
|
|
2
|
+
import { authenticatedClient, showCommand, } from "../resource.js";
|
|
3
|
+
import { deleteCommand } from "../mutation.js";
|
|
4
|
+
import { usageError } from "../errors.js";
|
|
5
|
+
import { colorEnabled, printJson } from "../output.js";
|
|
6
|
+
import { formatTable, humanCell } from "../table.js";
|
|
7
|
+
import { parseDomain } from "../filter.js";
|
|
8
|
+
/**
|
|
9
|
+
* Marketing surface (mailing.list / mailing.contact / mailing.trace).
|
|
10
|
+
*
|
|
11
|
+
* Read-only lists; contacts and traces support explicit, id-based deletion —
|
|
12
|
+
* mail data is personal data for erasure (invariant 4): `mailing.trace`
|
|
13
|
+
* carries a denormalized `email` with NO FK to `mailing.contact`, so deleting
|
|
14
|
+
* contacts never removes traces; both deletions are explicit CLI steps.
|
|
15
|
+
*
|
|
16
|
+
* Email is the only reliable key (name matching showed casing variance in
|
|
17
|
+
* prod): `contacts list --email` matches the email column alone; `--search`
|
|
18
|
+
* keeps the repo-wide name-or-email ilike for interactive browsing.
|
|
19
|
+
*/
|
|
20
|
+
export const mailingList = {
|
|
21
|
+
model: "mailing.list",
|
|
22
|
+
showCommand: "odoo marketing lists show",
|
|
23
|
+
defaultListFields: ["id", "name", "contact_count", "active", "create_date"],
|
|
24
|
+
showExtraFields: [
|
|
25
|
+
"is_public",
|
|
26
|
+
"mailing_count",
|
|
27
|
+
"subscription_ids",
|
|
28
|
+
"write_date",
|
|
29
|
+
],
|
|
30
|
+
resolutionHints: {},
|
|
31
|
+
priorityLabels: {},
|
|
32
|
+
pinnedDomain: [],
|
|
33
|
+
references: {},
|
|
34
|
+
};
|
|
35
|
+
export const mailingContact = {
|
|
36
|
+
model: "mailing.contact",
|
|
37
|
+
showCommand: "odoo marketing contacts show",
|
|
38
|
+
defaultListFields: ["id", "email", "name", "is_blacklisted", "create_date"],
|
|
39
|
+
showExtraFields: [
|
|
40
|
+
"list_ids",
|
|
41
|
+
"subscription_ids",
|
|
42
|
+
"email_normalized",
|
|
43
|
+
"company_name",
|
|
44
|
+
"tag_ids",
|
|
45
|
+
"write_date",
|
|
46
|
+
],
|
|
47
|
+
resolutionHints: {},
|
|
48
|
+
priorityLabels: {},
|
|
49
|
+
pinnedDomain: [],
|
|
50
|
+
references: {},
|
|
51
|
+
};
|
|
52
|
+
export const mailingTrace = {
|
|
53
|
+
model: "mailing.trace",
|
|
54
|
+
showCommand: "odoo marketing traces show",
|
|
55
|
+
defaultListFields: [
|
|
56
|
+
"id",
|
|
57
|
+
"email",
|
|
58
|
+
"model",
|
|
59
|
+
"res_id",
|
|
60
|
+
"trace_status",
|
|
61
|
+
"mass_mailing_id",
|
|
62
|
+
"create_date",
|
|
63
|
+
],
|
|
64
|
+
showExtraFields: [
|
|
65
|
+
"trace_type",
|
|
66
|
+
"sent_datetime",
|
|
67
|
+
"open_datetime",
|
|
68
|
+
"reply_datetime",
|
|
69
|
+
"failure_type",
|
|
70
|
+
"write_date",
|
|
71
|
+
],
|
|
72
|
+
resolutionHints: {},
|
|
73
|
+
priorityLabels: {},
|
|
74
|
+
pinnedDomain: [],
|
|
75
|
+
references: {},
|
|
76
|
+
};
|
|
77
|
+
function parseCount(raw, flag) {
|
|
78
|
+
if (!/^\d+$/.test(raw)) {
|
|
79
|
+
throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
|
|
80
|
+
}
|
|
81
|
+
return Number(raw);
|
|
82
|
+
}
|
|
83
|
+
/** Shared list machinery for the three read-only/list surfaces. */
|
|
84
|
+
async function runList(config, opts, domain, globals) {
|
|
85
|
+
const client = await authenticatedClient(globals);
|
|
86
|
+
const fieldsMeta = await ModelFields.load(client, config.model);
|
|
87
|
+
const limit = parseCount(opts.limit, "--limit");
|
|
88
|
+
const offset = parseCount(opts.offset, "--offset");
|
|
89
|
+
const fields = opts.fields
|
|
90
|
+
? opts.fields
|
|
91
|
+
.split(",")
|
|
92
|
+
.map((f) => f.trim())
|
|
93
|
+
.filter((f) => f !== "")
|
|
94
|
+
: config.defaultListFields;
|
|
95
|
+
for (const field of fields) {
|
|
96
|
+
if (field !== "id" && !fieldsMeta.get(field)) {
|
|
97
|
+
throw usageError(`unknown field ${JSON.stringify(field)} on ${config.model}`, `run \`odoo models fields ${config.model}\` to check availability`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const order = opts.order ?? "id asc";
|
|
101
|
+
const [records, count] = await Promise.all([
|
|
102
|
+
client.executeKw(config.model, "search_read", [domain], {
|
|
103
|
+
fields,
|
|
104
|
+
limit,
|
|
105
|
+
offset,
|
|
106
|
+
order,
|
|
107
|
+
}),
|
|
108
|
+
client.executeKw(config.model, "search_count", [domain]),
|
|
109
|
+
]);
|
|
110
|
+
if (globals.json) {
|
|
111
|
+
printJson({ model: config.model, records, count, limit, offset });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const headers = fields.map((f) => fieldsMeta.get(f)?.string ?? f);
|
|
115
|
+
const rows = records.map((rec) => fields.map((f) => humanCell(rec[f])));
|
|
116
|
+
process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
|
|
117
|
+
}
|
|
118
|
+
export async function listsListCommand(opts, globals) {
|
|
119
|
+
const domain = [];
|
|
120
|
+
if (opts.search)
|
|
121
|
+
domain.push(["name", "ilike", opts.search]);
|
|
122
|
+
if (opts.domain)
|
|
123
|
+
domain.push(...parseDomain(opts.domain));
|
|
124
|
+
return runList(mailingList, opts, domain, globals);
|
|
125
|
+
}
|
|
126
|
+
export function listsShowCommand(idArg, opts, globals) {
|
|
127
|
+
return showCommand(mailingList, idArg, opts, globals);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* `--list` resolves a mailing list by name (exact first, then ilike via the
|
|
131
|
+
* shared reference resolution) and filters contacts through the `list_ids`
|
|
132
|
+
* m2m (flat "contains" domain — verified against prod fields_get).
|
|
133
|
+
*/
|
|
134
|
+
export async function contactsListCommand(opts, globals) {
|
|
135
|
+
const client = await authenticatedClient(globals);
|
|
136
|
+
await ModelFields.load(client, "mailing.contact"); // module-installed check
|
|
137
|
+
const domain = [];
|
|
138
|
+
if (opts.list) {
|
|
139
|
+
const list = await resolveReference(client, "mailing.list", opts.list, {
|
|
140
|
+
label: "mailing list",
|
|
141
|
+
});
|
|
142
|
+
domain.push(["list_ids", "=", list.id]);
|
|
143
|
+
}
|
|
144
|
+
if (opts.email && opts.search) {
|
|
145
|
+
throw usageError("--email and --search are mutually exclusive", "--email is the identity-safe filter (email column only); --search matches name OR email");
|
|
146
|
+
}
|
|
147
|
+
if (opts.email) {
|
|
148
|
+
domain.push(["email", "ilike", opts.email]);
|
|
149
|
+
}
|
|
150
|
+
else if (opts.search) {
|
|
151
|
+
domain.push("|", ["name", "ilike", opts.search], ["email", "ilike", opts.search]);
|
|
152
|
+
}
|
|
153
|
+
if (opts.domain)
|
|
154
|
+
domain.push(...parseDomain(opts.domain));
|
|
155
|
+
return runList(mailingContact, opts, domain, globals);
|
|
156
|
+
}
|
|
157
|
+
export function contactsShowCommand(idArg, opts, globals) {
|
|
158
|
+
return showCommand(mailingContact, idArg, opts, globals);
|
|
159
|
+
}
|
|
160
|
+
export function contactsDeleteCommand(idArgs, globals) {
|
|
161
|
+
return deleteCommand(mailingContact, idArgs, globals);
|
|
162
|
+
}
|
|
163
|
+
export async function tracesListCommand(opts, globals) {
|
|
164
|
+
const client = await authenticatedClient(globals);
|
|
165
|
+
await ModelFields.load(client, "mailing.trace"); // module-installed check
|
|
166
|
+
const domain = [];
|
|
167
|
+
if (opts.email)
|
|
168
|
+
domain.push(["email", "ilike", opts.email]);
|
|
169
|
+
if (opts.mailing) {
|
|
170
|
+
const mailing = await resolveReference(client, "mailing.mailing", opts.mailing, { label: "mailing" });
|
|
171
|
+
domain.push(["mass_mailing_id", "=", mailing.id]);
|
|
172
|
+
}
|
|
173
|
+
if (opts.state)
|
|
174
|
+
domain.push(["trace_status", "=", opts.state]);
|
|
175
|
+
if (opts.domain)
|
|
176
|
+
domain.push(...parseDomain(opts.domain));
|
|
177
|
+
return runList(mailingTrace, opts, domain, globals);
|
|
178
|
+
}
|
|
179
|
+
export function tracesShowCommand(idArg, opts, globals) {
|
|
180
|
+
return showCommand(mailingTrace, idArg, opts, globals);
|
|
181
|
+
}
|
|
182
|
+
export function tracesDeleteCommand(idArgs, globals) {
|
|
183
|
+
return deleteCommand(mailingTrace, idArgs, globals);
|
|
184
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { authenticatedClient, parseId, showCommand, } from "../resource.js";
|
|
2
|
+
import { usageError } from "../errors.js";
|
|
3
|
+
import { printJson } from "../output.js";
|
|
4
|
+
import { parseDomain } from "../filter.js";
|
|
5
|
+
/**
|
|
6
|
+
* mail.message surface, erasure-scoped. Chatter attaches to every model
|
|
7
|
+
* (res.partner, helpdesk.ticket, crm.lead, ...), so `list` REQUIRES an
|
|
8
|
+
* explicit scope (--res-model / --res-id) and `scrub`/`delete` REQUIRE BOTH
|
|
9
|
+
* and cross-check every id against it — a message on another record is
|
|
10
|
+
* skipped, never touched. Bodies are excluded from `list` output (metadata
|
|
11
|
+
* plus lengths only); `show` reads the body for the single-record case.
|
|
12
|
+
*
|
|
13
|
+
* `scrub` implements the erasure SOP's tracked-change remediation: removing
|
|
14
|
+
* personal data from a record leaves a chatter `mail.message` whose
|
|
15
|
+
* `mail.tracking.value` rows carry the PRE-change values (old email, old
|
|
16
|
+
* name). Scrub unlinks those rows and rewrites the body to an erasure
|
|
17
|
+
* marker. The default marker is fixed SOP text (decision #1 discipline);
|
|
18
|
+
* `--marker` overrides it for message classes on other models, with two
|
|
19
|
+
* guardrails: plain text only (escaped, wrapped in a <p> block) and a hard
|
|
20
|
+
* refusal on email-shaped content — the residue this command exists to
|
|
21
|
+
* remove must never be re-embeddable through it.
|
|
22
|
+
*/
|
|
23
|
+
export const mailMessage = {
|
|
24
|
+
model: "mail.message",
|
|
25
|
+
showCommand: "odoo messages show",
|
|
26
|
+
defaultListFields: [
|
|
27
|
+
"id",
|
|
28
|
+
"model",
|
|
29
|
+
"res_id",
|
|
30
|
+
"message_type",
|
|
31
|
+
"subtype_id",
|
|
32
|
+
"author_id",
|
|
33
|
+
"date",
|
|
34
|
+
],
|
|
35
|
+
showExtraFields: ["body", "email_from", "tracking_value_ids"],
|
|
36
|
+
resolutionHints: {},
|
|
37
|
+
priorityLabels: {},
|
|
38
|
+
pinnedDomain: [],
|
|
39
|
+
references: {},
|
|
40
|
+
};
|
|
41
|
+
/** SOP-verbatim default; a variant is a decision (TDD), not a flag value. */
|
|
42
|
+
export const ERASURE_BODY_MARKER = "<p>Partner anonymized (erasure flow)</p>";
|
|
43
|
+
const MARKER_MAX_LENGTH = 1000;
|
|
44
|
+
const EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
|
45
|
+
function parseCount(raw, flag) {
|
|
46
|
+
if (!/^\d+$/.test(raw)) {
|
|
47
|
+
throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
|
|
48
|
+
}
|
|
49
|
+
return Number(raw);
|
|
50
|
+
}
|
|
51
|
+
function parseScope(resModel, resId, flags) {
|
|
52
|
+
if (resModel === undefined || resId === undefined) {
|
|
53
|
+
throw usageError(`${flags} requires both --res-model and --res-id`, "the scope cross-check must fully qualify the parent record — combine 'messages list --res-model X --res-id Y' with 'messages scrub/delete <ids> --res-model X --res-id Y'");
|
|
54
|
+
}
|
|
55
|
+
return { resModel, resId: parseCount(resId, "--res-id") };
|
|
56
|
+
}
|
|
57
|
+
function resolveMarker(opts) {
|
|
58
|
+
const raw = opts.marker?.trim();
|
|
59
|
+
if (raw === undefined || raw === "")
|
|
60
|
+
return ERASURE_BODY_MARKER;
|
|
61
|
+
if (raw.length > MARKER_MAX_LENGTH) {
|
|
62
|
+
throw usageError("--marker exceeds the 1000 character limit", "markers are short annotations; the erasure register holds the details");
|
|
63
|
+
}
|
|
64
|
+
if (EMAIL_PATTERN.test(raw)) {
|
|
65
|
+
throw usageError("--marker contains an email-shaped token", "scrub exists to remove personal data from chatter; a marker that can carry an email address re-creates the residue through the remediation itself. Name the record by id, never by address");
|
|
66
|
+
}
|
|
67
|
+
const escaped = raw
|
|
68
|
+
.replace(/&/g, "&")
|
|
69
|
+
.replace(/</g, "<")
|
|
70
|
+
.replace(/>/g, ">");
|
|
71
|
+
return `<p>${escaped}</p>`;
|
|
72
|
+
}
|
|
73
|
+
function scopeMismatch(rec, scope) {
|
|
74
|
+
return rec.model !== scope.resModel || rec.res_id !== scope.resId;
|
|
75
|
+
}
|
|
76
|
+
export async function messagesListCommand(opts, globals) {
|
|
77
|
+
const client = await authenticatedClient(globals);
|
|
78
|
+
const resId = opts.resId !== undefined ? parseCount(opts.resId, "--res-id") : undefined;
|
|
79
|
+
// Scope guard: mail.message is a global chatter table across every
|
|
80
|
+
// model; never browse it unbounded (PII read footgun).
|
|
81
|
+
if (opts.resModel === undefined && resId === undefined) {
|
|
82
|
+
throw usageError("messages list requires an explicit scope", "pass --res-model and --res-id (AND-ed); a model-only list is allowed but still large — prefer the pair");
|
|
83
|
+
}
|
|
84
|
+
const limit = parseCount(opts.limit, "--limit");
|
|
85
|
+
const offset = parseCount(opts.offset, "--offset");
|
|
86
|
+
const domain = [];
|
|
87
|
+
if (opts.resModel !== undefined)
|
|
88
|
+
domain.push(["model", "=", opts.resModel]);
|
|
89
|
+
if (resId !== undefined)
|
|
90
|
+
domain.push(["res_id", "=", resId]);
|
|
91
|
+
if (opts.domain)
|
|
92
|
+
domain.push(...parseDomain(opts.domain));
|
|
93
|
+
const readFields = Array.from(new Set([...mailMessage.defaultListFields, "body", "tracking_value_ids"]));
|
|
94
|
+
const order = opts.order ?? "id asc";
|
|
95
|
+
const [records, count] = await Promise.all([
|
|
96
|
+
client.executeKw("mail.message", "search_read", [domain], {
|
|
97
|
+
fields: readFields,
|
|
98
|
+
limit,
|
|
99
|
+
offset,
|
|
100
|
+
order,
|
|
101
|
+
}),
|
|
102
|
+
client.executeKw("mail.message", "search_count", [
|
|
103
|
+
domain,
|
|
104
|
+
]),
|
|
105
|
+
]);
|
|
106
|
+
// Body content never leaves the command in list output — length only.
|
|
107
|
+
const rows = records.map((rec) => {
|
|
108
|
+
const body = rec.body;
|
|
109
|
+
const row = { ...rec };
|
|
110
|
+
delete row.body;
|
|
111
|
+
row.body_length =
|
|
112
|
+
typeof body === "string" && body.length > 0 ? body.length : 0;
|
|
113
|
+
const tv = rec.tracking_value_ids;
|
|
114
|
+
row.tracking_values = Array.isArray(tv) ? tv.length : 0;
|
|
115
|
+
delete row.tracking_value_ids;
|
|
116
|
+
return row;
|
|
117
|
+
});
|
|
118
|
+
if (globals.json) {
|
|
119
|
+
printJson({ model: "mail.message", records: rows, count, limit, offset });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const headers = [
|
|
123
|
+
"id",
|
|
124
|
+
"model",
|
|
125
|
+
"res_id",
|
|
126
|
+
"message_type",
|
|
127
|
+
"author_id",
|
|
128
|
+
"date",
|
|
129
|
+
"body_length",
|
|
130
|
+
"tracking_values",
|
|
131
|
+
];
|
|
132
|
+
const human = records.map((rec) => {
|
|
133
|
+
const body = rec.body;
|
|
134
|
+
const tv = rec.tracking_value_ids;
|
|
135
|
+
return [
|
|
136
|
+
String(rec.id ?? ""),
|
|
137
|
+
String(rec.model ?? ""),
|
|
138
|
+
String(rec.res_id ?? ""),
|
|
139
|
+
String(rec.message_type ?? ""),
|
|
140
|
+
String(Array.isArray(rec.author_id) ? rec.author_id[1] : (rec.author_id ?? "")),
|
|
141
|
+
String(rec.date ?? ""),
|
|
142
|
+
String(typeof body === "string" && body.length > 0 ? body.length : 0),
|
|
143
|
+
String(Array.isArray(tv) ? tv.length : 0),
|
|
144
|
+
];
|
|
145
|
+
});
|
|
146
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...human.map((r) => r[i].length)));
|
|
147
|
+
const fmt = (cells) => cells
|
|
148
|
+
.map((c, i) => c.padEnd(widths[i]))
|
|
149
|
+
.join(" ")
|
|
150
|
+
.trimEnd();
|
|
151
|
+
const lines = [fmt(headers), ...human.map(fmt)];
|
|
152
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
153
|
+
}
|
|
154
|
+
export function messagesShowCommand(idArg, opts, globals) {
|
|
155
|
+
return showCommand(mailMessage, idArg, opts, globals);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Strip tracked-change residue from in-scope messages: unlink every
|
|
159
|
+
* mail.tracking.value row (they hold the pre-change values) and rewrite the
|
|
160
|
+
* body to the erasure marker. Messages without tracking rows are skipped —
|
|
161
|
+
* this command never rewrites arbitrary chatter uninvited; the explicit
|
|
162
|
+
* weapon for verified personal data in message bodies is `messages delete`.
|
|
163
|
+
*/
|
|
164
|
+
export async function messagesScrubCommand(idArgs, opts, globals) {
|
|
165
|
+
const client = await authenticatedClient(globals);
|
|
166
|
+
const scope = parseScope(opts.resModel, opts.resId, "messages scrub");
|
|
167
|
+
const ids = idArgs.map((a) => parseId(a));
|
|
168
|
+
const marker = resolveMarker(opts);
|
|
169
|
+
const records = (await client.executeKw("mail.message", "read", [ids], {
|
|
170
|
+
fields: ["model", "res_id", "tracking_value_ids"],
|
|
171
|
+
}));
|
|
172
|
+
const scrubbed = [];
|
|
173
|
+
const skipped = [];
|
|
174
|
+
for (const id of ids) {
|
|
175
|
+
const rec = records.find((r) => r.id === id);
|
|
176
|
+
if (!rec) {
|
|
177
|
+
skipped.push({ id, reason: "not found" });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (scopeMismatch(rec, scope)) {
|
|
181
|
+
skipped.push({
|
|
182
|
+
id,
|
|
183
|
+
reason: `scope mismatch (message is on ${rec.model}/${rec.res_id})`,
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const tv = Array.isArray(rec.tracking_value_ids)
|
|
188
|
+
? rec.tracking_value_ids
|
|
189
|
+
: [];
|
|
190
|
+
if (tv.length === 0) {
|
|
191
|
+
skipped.push({ id, reason: "no tracking values" });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// Field NAMES only — old/new values are the residue being removed and
|
|
195
|
+
// must never echo back through command output, not even in dry-run.
|
|
196
|
+
const tvRows = (await client.executeKw("mail.tracking.value", "read", [[...tv]], { fields: ["field_id", "field_info"] }));
|
|
197
|
+
const fieldNames = tvRows.map((tvRow) => {
|
|
198
|
+
const info = tvRow.field_info;
|
|
199
|
+
if (info && typeof info === "object" && "name" in info) {
|
|
200
|
+
return String(info.name);
|
|
201
|
+
}
|
|
202
|
+
const f = tvRow.field_id;
|
|
203
|
+
return Array.isArray(f) ? String(f[1]) : String(f ?? "unknown");
|
|
204
|
+
});
|
|
205
|
+
if (globals.dryRun) {
|
|
206
|
+
scrubbed.push({
|
|
207
|
+
id,
|
|
208
|
+
trackingRows: tv.length,
|
|
209
|
+
fields: fieldNames,
|
|
210
|
+
marker,
|
|
211
|
+
});
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
await client.executeKw("mail.tracking.value", "unlink", [tv]);
|
|
215
|
+
// In production Odoo the unlink cascades the m2m; clear it explicitly so
|
|
216
|
+
// the message row itself no longer dangles (and so test servers see the
|
|
217
|
+
// same end state).
|
|
218
|
+
await client.executeKw("mail.message", "write", [
|
|
219
|
+
[id],
|
|
220
|
+
{ tracking_value_ids: [[6, 0, []]] },
|
|
221
|
+
]);
|
|
222
|
+
await client.executeKw("mail.message", "write", [[id], { body: marker }]);
|
|
223
|
+
scrubbed.push({
|
|
224
|
+
id,
|
|
225
|
+
trackingRowsRemoved: tv.length,
|
|
226
|
+
fields: fieldNames,
|
|
227
|
+
marker,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (globals.json) {
|
|
231
|
+
printJson({
|
|
232
|
+
model: "mail.message",
|
|
233
|
+
scope,
|
|
234
|
+
marker,
|
|
235
|
+
scrubbed,
|
|
236
|
+
skipped,
|
|
237
|
+
removedTrackingRows: scrubbed.reduce((n, s) => n + Number(s.trackingRowsRemoved ?? s.trackingRows ?? 0), 0),
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
for (const s of scrubbed) {
|
|
242
|
+
const fields = s.fields ?? [];
|
|
243
|
+
if (globals.dryRun) {
|
|
244
|
+
process.stdout.write(`Would scrub mail.message ${s.id}: remove ${s.trackingRows} tracking row(s) [${fields.join(", ")}], set marker\n`);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
process.stdout.write(`Scrubbed mail.message ${s.id}: removed ${s.trackingRowsRemoved} tracking row(s) [${fields.join(", ")}], marker written\n`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const s of skipped) {
|
|
251
|
+
process.stdout.write(`Skipping mail.message ${s.id}: ${s.reason}\n`);
|
|
252
|
+
}
|
|
253
|
+
if (!globals.dryRun && scrubbed.length > 0) {
|
|
254
|
+
process.stdout.write(`Done — verify with: odoo messages list --res-model ${scope.resModel} --res-id ${scope.resId} --json (tracking_values must be 0)`);
|
|
255
|
+
}
|
|
256
|
+
process.stdout.write("\n");
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Delete in-scope messages outright. This is the §6/escalation weapon for
|
|
260
|
+
* verified personal data in message bodies (after the SQL/checks confirmed
|
|
261
|
+
* it) — distinct from `scrub`, which annotates tracked-change residue while
|
|
262
|
+
* keeping the accountability trail.
|
|
263
|
+
*/
|
|
264
|
+
export async function messagesDeleteCommand(idArgs, opts, globals) {
|
|
265
|
+
const client = await authenticatedClient(globals);
|
|
266
|
+
const scope = parseScope(opts.resModel, opts.resId, "messages delete");
|
|
267
|
+
const ids = idArgs.map((a) => parseId(a));
|
|
268
|
+
const records = (await client.executeKw("mail.message", "read", [ids], {
|
|
269
|
+
fields: ["model", "res_id"],
|
|
270
|
+
}));
|
|
271
|
+
const deleted = [];
|
|
272
|
+
const skipped = [];
|
|
273
|
+
for (const id of ids) {
|
|
274
|
+
const rec = records.find((r) => r.id === id);
|
|
275
|
+
if (!rec) {
|
|
276
|
+
skipped.push({ id, reason: "not found" });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (scopeMismatch(rec, scope)) {
|
|
280
|
+
skipped.push({
|
|
281
|
+
id,
|
|
282
|
+
reason: `scope mismatch (message is on ${rec.model}/${rec.res_id})`,
|
|
283
|
+
});
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
deleted.push(id);
|
|
287
|
+
}
|
|
288
|
+
if (!globals.dryRun && deleted.length > 0) {
|
|
289
|
+
await client.executeKw("mail.message", "unlink", [deleted]);
|
|
290
|
+
}
|
|
291
|
+
if (globals.json) {
|
|
292
|
+
printJson({
|
|
293
|
+
model: "mail.message",
|
|
294
|
+
scope,
|
|
295
|
+
...(globals.dryRun ? { wouldDelete: deleted } : { deleted }),
|
|
296
|
+
skipped,
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
for (const id of deleted) {
|
|
301
|
+
process.stdout.write(globals.dryRun
|
|
302
|
+
? `Would delete mail.message ${id}\n`
|
|
303
|
+
: `Deleted mail.message ${id}\n`);
|
|
304
|
+
}
|
|
305
|
+
for (const s of skipped) {
|
|
306
|
+
process.stdout.write(`Skipping mail.message ${s.id}: ${s.reason}\n`);
|
|
307
|
+
}
|
|
308
|
+
process.stdout.write("\n");
|
|
309
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ModelFields } from "../model.js";
|
|
2
|
+
import { authenticatedClient, parseId, showCommand, } from "../resource.js";
|
|
3
|
+
import { deleteCommand } from "../mutation.js";
|
|
4
|
+
import { conflictError } from "../errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* res.users surface (sign-in identities). Deliberately narrow: `show` and a
|
|
7
|
+
* guarded `delete` — no list, no create/update. `res.users` holds internal
|
|
8
|
+
* accounts (employees, service accounts) that must never be removed via the
|
|
9
|
+
* CLI, so `users delete` enforces portal-only (`share=true`) always:
|
|
10
|
+
* any internal user in the batch aborts the whole command without deleting
|
|
11
|
+
* anything (see docs/plans/users-module.md §5).
|
|
12
|
+
*/
|
|
13
|
+
export const resUsers = {
|
|
14
|
+
model: "res.users",
|
|
15
|
+
showCommand: "odoo users show",
|
|
16
|
+
defaultListFields: [
|
|
17
|
+
"id",
|
|
18
|
+
"login",
|
|
19
|
+
"email",
|
|
20
|
+
"name",
|
|
21
|
+
"share",
|
|
22
|
+
"active",
|
|
23
|
+
"partner_id",
|
|
24
|
+
],
|
|
25
|
+
showExtraFields: ["company_id", "create_date", "login_date", "write_date"],
|
|
26
|
+
resolutionHints: {},
|
|
27
|
+
priorityLabels: {},
|
|
28
|
+
pinnedDomain: [],
|
|
29
|
+
references: {},
|
|
30
|
+
};
|
|
31
|
+
export function usersShowCommand(idArg, opts, globals) {
|
|
32
|
+
return showCommand(resUsers, idArg, opts, globals);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Portal-only delete: read `share` for every requested id first (missing
|
|
36
|
+
* ids are absent from the response and left for deleteCommand's skipped
|
|
37
|
+
* reporting), and refuse the entire batch when any id is an internal user
|
|
38
|
+
* (`share=false`). There is no override flag by design.
|
|
39
|
+
*/
|
|
40
|
+
export async function usersDeleteCommand(idArgs, globals) {
|
|
41
|
+
const ids = idArgs.map(parseId);
|
|
42
|
+
const client = await authenticatedClient(globals);
|
|
43
|
+
// fields_get first: doubles as the module/model presence check (§1).
|
|
44
|
+
await ModelFields.load(client, resUsers.model);
|
|
45
|
+
if (ids.length > 0) {
|
|
46
|
+
const rows = (await client.executeKw("res.users", "read", [ids], {
|
|
47
|
+
fields: ["share", "login", "name"],
|
|
48
|
+
}));
|
|
49
|
+
const internal = rows.filter((r) => r.share !== true);
|
|
50
|
+
if (internal.length > 0) {
|
|
51
|
+
const named = internal
|
|
52
|
+
.map((r) => `${r.id} (${r.login || r.name || "unknown login"})`)
|
|
53
|
+
.join(", ");
|
|
54
|
+
throw conflictError("protected_record", `internal user(s) cannot be deleted via the CLI: ${named}`, "users delete only removes portal users (share=true); internal accounts are managed in the Odoo UI");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return deleteCommand(resUsers, idArgs, globals);
|
|
58
|
+
}
|