346 Commits

Author SHA1 Message Date
7d35b4a377 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:50 +00:00
bd4df2fc3e Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:49 +00:00
6991e4956c Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:49 +00:00
807c474af4 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:48 +00:00
62e3b75c64 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:47 +00:00
a875de6ab9 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:47 +00:00
adf867464e Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:46 +00:00
9ed6dddba1 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:45 +00:00
45bacfa973 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:44 +00:00
ebade46d0a Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:44 +00:00
f63282ef6d Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:43 +00:00
9fe857ebd7 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:42 +00:00
f37d7240fc Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:42 +00:00
6376ea081d Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:41 +00:00
1ff139ba75 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:41 +00:00
6d40e0caa6 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:40 +00:00
be10c7bdd8 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:39 +00:00
8b0af310fc Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:38 +00:00
a1145a7773 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:38 +00:00
773a390bed Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:37 +00:00
5c61e3dfad Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:36 +00:00
f7a44ace9e Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:36 +00:00
c688b17afb Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:35 +00:00
d7337681f6 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:34 +00:00
44f11fa3ab Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:34 +00:00
204c353b16 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:33 +00:00
b0e561d572 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:32 +00:00
f2423bd49d Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:32 +00:00
5520ca5d4f Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:31 +00:00
e5f4d4483e Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:30 +00:00
dcc929a326 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:29 +00:00
87828837c6 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:28 +00:00
71cf5380ff Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:28 +00:00
a592f6cc70 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:27 +00:00
4c8d4f5f7d Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:26 +00:00
ac1a9b8cdc Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:26 +00:00
ce13daaa58 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:25 +00:00
20540056fa Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:24 +00:00
3f481c75d4 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:24 +00:00
5b59a07033 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:23 +00:00
8b1fb96368 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:22 +00:00
5c8f90ff77 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:21 +00:00
0667f24bd7 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:21 +00:00
54ac099597 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:20 +00:00
7341099882 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:19 +00:00
cc78bca1dc Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:19 +00:00
734b356286 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:18 +00:00
4dd14c3fa0 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:18 +00:00
b29092491b Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:17 +00:00
255ec20637 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:16 +00:00
b70114419a Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:16 +00:00
4e5ceb11fb Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:15 +00:00
ab4ea51bff Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:14 +00:00
02fc2bbc84 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:14 +00:00
7aa2cf424a Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:13 +00:00
5e6726ee08 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:12 +00:00
f6f43fbca2 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:11 +00:00
e161f17642 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:11 +00:00
c661356c1f Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:10 +00:00
2001a64180 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:10 +00:00
99d1daa1e8 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:09 +00:00
fa1a7d42e1 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:08 +00:00
6d90045065 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:07 +00:00
88f656b55c Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:07 +00:00
a0b28de2bf Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:06 +00:00
f810819876 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:06 +00:00
739fb53837 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:05 +00:00
6f8ed82b4c Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:04 +00:00
9309fb6768 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:03 +00:00
8da7e5a08b Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:03 +00:00
b15f459f58 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:02 +00:00
0a657d2f43 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:02 +00:00
31fea6f015 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:01 +00:00
601b399d65 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 10:44:00 +00:00
Tower Deploy
7cef9f1a32 Wipe cetmix_tower_yaml (polluted by overlapping uploads) 2026-04-27 13:43:58 +03:00
18dd9c7a1f Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:29 +00:00
1c6d6b1dcc Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:29 +00:00
b3d78f3f06 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:28 +00:00
5d5fbb835e Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:28 +00:00
f259d7da1b Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:27 +00:00
433f68b5a4 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:27 +00:00
3729ee8cd6 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:26 +00:00
261e8aea62 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:25 +00:00
a1dd66ec6a Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:25 +00:00
f579fbc83f Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:24 +00:00
bd2cfbcc3d Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:24 +00:00
9c009dddb5 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:23 +00:00
fd94630e79 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:22 +00:00
c8274bd0a6 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:22 +00:00
4bea3edbeb Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:21 +00:00
3aa73a29a5 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:20 +00:00
5934b7cf4d Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:20 +00:00
39f0b6d406 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:19 +00:00
1c1a16a55a Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:18 +00:00
991507c29a Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:18 +00:00
553f5fa25f Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:17 +00:00
8c5ef8bfd2 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:17 +00:00
4e0580a2b4 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:16 +00:00
451e109b7f Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:15 +00:00
fa79d8c15d Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:15 +00:00
55800608ec Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:14 +00:00
63e66334af Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:14 +00:00
4b7d2f2efc Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:13 +00:00
a7b02a742a Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:12 +00:00
825ad03236 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:12 +00:00
484763b809 Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:11 +00:00
b0d2c5668c Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:11 +00:00
444278accb Tower: upload web_notify 16.0.3.2.0 (via marketplace) 2026-04-27 08:47:10 +00:00
c8b19a8c62 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:09 +00:00
1a3285cdc4 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:08 +00:00
cd55fd9f19 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:08 +00:00
d75d397e6a Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:07 +00:00
4e95aa47de Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:06 +00:00
0911b0d951 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:06 +00:00
1ea59d44f0 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:05 +00:00
b4fcbfdf2a Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:04 +00:00
cca99e065a Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:04 +00:00
ec6e3c8fd2 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:03 +00:00
2c1d9c3ef2 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:03 +00:00
583dd0dd15 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:02 +00:00
66ae014a38 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:01 +00:00
b2f175536a Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:01 +00:00
6794a1b842 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:47:00 +00:00
191f857aff Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:46:59 +00:00
bf6065aeb7 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:46:59 +00:00
00e6ff7e78 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:46:58 +00:00
1f5b011fce Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:46:58 +00:00
61db219e01 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:46:57 +00:00
771994f944 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:46:56 +00:00
def74bd656 Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:46:56 +00:00
6e4be30e3a Tower: upload rpc_helper 16.0.1.0.0 (via marketplace) 2026-04-27 08:46:55 +00:00
96a2eeda3a Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:54 +00:00
a6209db573 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:53 +00:00
bfc350252a Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:53 +00:00
64efc9b0b4 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:52 +00:00
8d4ddfb7d2 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:51 +00:00
447b8431e6 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:51 +00:00
007783c1e2 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:50 +00:00
72a4524aed Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:50 +00:00
7e37a29bee Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:49 +00:00
1f0cf23801 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:48 +00:00
999a996df8 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:48 +00:00
8966de83af Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:47 +00:00
403368df7a Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:47 +00:00
fef59e7a73 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:46 +00:00
c2285f865e Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:45 +00:00
34d8248b79 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:45 +00:00
f64852997f Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:44 +00:00
fcf45b130e Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:43 +00:00
fd4665364d Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:43 +00:00
91a344cbc2 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:43 +00:00
7b8f5090db Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:42 +00:00
e2039f54f4 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:41 +00:00
445b34f452 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:40 +00:00
c3a4151359 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:40 +00:00
c05ba71bcd Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:39 +00:00
389a32d760 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:39 +00:00
609ef99c44 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:38 +00:00
71e98f5b3f Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:37 +00:00
25052f2e2d Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:37 +00:00
a5c0f76f89 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:36 +00:00
81d2547e9d Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:36 +00:00
a0c172c649 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:35 +00:00
8a65785c52 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:34 +00:00
85fff4657e Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:34 +00:00
114449be53 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:33 +00:00
df1dabb253 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:33 +00:00
65094d2031 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:32 +00:00
9d8a226283 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:31 +00:00
7bff54cb58 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:31 +00:00
4f9f60b121 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:30 +00:00
f0cee69a24 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:30 +00:00
0d6e910d3e Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:29 +00:00
64f515e11b Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:28 +00:00
ef22709eb7 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:28 +00:00
65c6df9940 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:27 +00:00
cbc12f44b8 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:27 +00:00
45eba87eda Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:26 +00:00
510be1ffcb Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:25 +00:00
9ceb54d29c Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:25 +00:00
942da80b9c Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:24 +00:00
3da4cc2dec Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:23 +00:00
b4572fa6f1 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:23 +00:00
01f5ee1c46 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:22 +00:00
952b235888 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:22 +00:00
f98c11412d Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:21 +00:00
a8e27776d3 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:20 +00:00
6038b70592 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:20 +00:00
e259a897fe Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:19 +00:00
05027ef13c Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:19 +00:00
d65b12bc80 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:18 +00:00
905d4a6c04 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:17 +00:00
a213ef10a8 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:17 +00:00
f2b16e50a7 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:16 +00:00
4d25cf4ade Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:16 +00:00
82b2acd792 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:15 +00:00
7522999082 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:14 +00:00
f8e694b71a Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:14 +00:00
83cbdf54e9 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:13 +00:00
7744f3212d Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:13 +00:00
b55049d482 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:12 +00:00
54f981fd25 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:11 +00:00
7d753b772a Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:11 +00:00
cd8e63eb08 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:10 +00:00
29f5780312 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:10 +00:00
6dd6679e9a Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:09 +00:00
26c795216e Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:08 +00:00
5b40d83c0c Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:08 +00:00
22279e8c98 Tower: upload queue_job 16.0.2.12.0 (via marketplace) 2026-04-27 08:46:07 +00:00
09bc143899 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:46:06 +00:00
d29af3f5ad Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:46:05 +00:00
7441874199 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:46:05 +00:00
068638b20a Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:46:04 +00:00
5c65820935 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:46:03 +00:00
748b61b2f6 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:46:03 +00:00
70d359dd8d Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:46:02 +00:00
c4d093c497 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:46:01 +00:00
39ccc6bde5 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:46:01 +00:00
8df4722e8b Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:46:00 +00:00
fe3a822173 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:59 +00:00
7d9a1eefbb Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:59 +00:00
c74f5414af Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:58 +00:00
98387bc517 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:58 +00:00
a6e739601e Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:57 +00:00
e3b372f3d0 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:56 +00:00
8f8e41943a Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:56 +00:00
7af8e80303 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:55 +00:00
9f86d4807c Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:55 +00:00
1a082b425c Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:54 +00:00
48fcec14c5 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:54 +00:00
d54a6b9d08 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:53 +00:00
26e1be3a4f Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:53 +00:00
10cd0f3bc1 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:52 +00:00
d2ec4529cc Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:51 +00:00
a1bf9980cb Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:50 +00:00
42292618bb Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:50 +00:00
07d598c857 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:49 +00:00
757ec36790 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:49 +00:00
7441e29889 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:48 +00:00
c48a8ddc63 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:47 +00:00
c31ba607e5 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:46 +00:00
97eafd2fcf Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:45 +00:00
b3e06b7bbd Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:44 +00:00
ddc65dc558 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:44 +00:00
8dc88a671f Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:43 +00:00
928a2661bb Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:43 +00:00
7f9278fc8f Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:41 +00:00
bc99107f8e Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:41 +00:00
db6cbffd60 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:40 +00:00
55df443de3 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:39 +00:00
e28e930732 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:38 +00:00
2ffa038703 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:38 +00:00
5c6a987442 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:37 +00:00
5f26a8f675 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:36 +00:00
0f25bd4d77 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:35 +00:00
41a6368228 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:34 +00:00
20ec0b6fd6 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:34 +00:00
71655a3923 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:33 +00:00
06103e090a Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:32 +00:00
f78d7b8d35 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:32 +00:00
b87a626ee7 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:31 +00:00
5c587f8e7d Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:30 +00:00
14645156c6 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:29 +00:00
9af897fa59 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:29 +00:00
6b447e3364 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:28 +00:00
162e2aa3e8 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:27 +00:00
68fa068d8b Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:26 +00:00
d481df1702 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:26 +00:00
2f6ce319ba Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:25 +00:00
8093696ec8 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:24 +00:00
53d1657954 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:24 +00:00
87eae8f9c1 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:23 +00:00
1b5655d1aa Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:23 +00:00
01ec5954bb Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:22 +00:00
0d853abbc3 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:21 +00:00
fada6f30ff Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:20 +00:00
c10bbc8f8a Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:20 +00:00
492d828ca3 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:19 +00:00
343a0700b6 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:19 +00:00
c582038d23 Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:18 +00:00
4c70b26e1d Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:17 +00:00
56b120ae6f Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:16 +00:00
3ef03aea6e Tower: upload cetmix_tower_yaml 16.0.2.0.3 (via marketplace) 2026-04-27 08:45:15 +00:00
9897dcfa04 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:14 +00:00
01b7ffd8d3 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:13 +00:00
0ed1b40384 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:13 +00:00
0a1b6e156a Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:12 +00:00
f09ad65b7a Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:12 +00:00
92b30574c7 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:11 +00:00
f5eb897143 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:10 +00:00
8ed74a3aed Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:10 +00:00
7158e9210f Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:09 +00:00
9444f8805a Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:09 +00:00
2095fde1f4 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:08 +00:00
922c8a49d5 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:07 +00:00
7acf00fc4d Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:07 +00:00
86b416cb47 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:06 +00:00
09ed1d8731 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:05 +00:00
022f0cb891 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:05 +00:00
8e4a3d8d4a Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:04 +00:00
97f60c2aa5 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:03 +00:00
7fb3d0a77d Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:03 +00:00
82d2d1eff6 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:02 +00:00
1ed5e88c7c Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:01 +00:00
a1f473b8a3 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:01 +00:00
0ed968a17b Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:45:00 +00:00
1a3e7389fa Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:59 +00:00
8199d0022d Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:59 +00:00
8eb03de70b Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:58 +00:00
1a43c797c3 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:58 +00:00
668ff3da60 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:57 +00:00
a3d8b01582 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:56 +00:00
380afede5e Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:56 +00:00
bf85022852 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:55 +00:00
76f3b5cd0d Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:54 +00:00
818c86a758 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:54 +00:00
a718da84af Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:53 +00:00
c7b7860fd6 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:53 +00:00
31da31ec45 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:52 +00:00
2fd5aa0787 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:51 +00:00
d47e45ae64 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:51 +00:00
1fea3621f5 Tower: upload cetmix_tower_webhook 16.0.1.0.5 (via marketplace) 2026-04-27 08:44:50 +00:00
6855e3711a Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:49 +00:00
26f2040905 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:48 +00:00
a52b141017 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:48 +00:00
5d988b1cb8 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:47 +00:00
4fc18d865b Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:46 +00:00
262a6e4b84 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:46 +00:00
e450738fd7 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:45 +00:00
83ec459ca5 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:44 +00:00
4de853d788 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:44 +00:00
ad6cbac1f8 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:43 +00:00
2fcd451339 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:43 +00:00
762547c1f5 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:42 +00:00
25cc185aee Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:41 +00:00
0fc6a1d6f3 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:41 +00:00
76991aecae Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:40 +00:00
f7c03a7122 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:39 +00:00
dc0fa2dff7 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:39 +00:00
99043f1c52 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:38 +00:00
73a89f15e6 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:38 +00:00
b4a3b13ee0 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:37 +00:00
3d30491875 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:36 +00:00
dda64246c5 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:36 +00:00
a0d1d19687 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:35 +00:00
66c81b2a91 Tower: upload cetmix_tower_server_queue 16.0.1.2.2 (via marketplace) 2026-04-27 08:44:35 +00:00
261 changed files with 35413 additions and 286 deletions

View File

@@ -0,0 +1,117 @@
=========================
Cetmix Tower Server Queue
=========================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:b40d3d39da3d8e2545c72b63aa3f14bdb1aaafbfbfbbb51e07ba599400427b8d
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github
:target: https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server_queue
:alt: cetmix/cetmix-tower
|badge1| |badge2| |badge3|
This module implements asynchronous task execution for `Cetmix
Tower <https://cetmix.com/tower>`__.
It requires the `queue_job <https://github.com/OCA/queue/queue_job>`__
module to be installed and configured in the Odoo instance.
Please refer to the `official
documentation <https://cetmix.com/tower>`__ for detailed information.
**Table of contents**
.. contents::
:local:
Configuration
=============
Please refer to the `official
documentation <https://cetmix.com/tower>`__ for detailed configuration
instructions.
Usage
=====
Please refer to the `official
documentation <https://cetmix.com/tower>`__ for detailed usage
instructions.
Changelog
=========
16.0.1.2.0 (2025-11-12)
-----------------------
- Features: Use the 'web_notify' module to send user notifications.
(5074)
16.0.1.1.4 (2025-11-05)
-----------------------
- Bugfixes: Finish multiple commands at once. (5062)
16.0.1.1.3 (2025-10-13)
-----------------------
- Features: Terminate running flight plan manually (3410)
16.0.1.1.0 (2025-07-16)
-----------------------
- Features: cetmix_tower_server_queue: Add async file upload/download
via job queue (3720)
- Features: Terminate command with error if job has failed (4718)
16.0.1.0.2 (2025-05-16)
-----------------------
- Features: 'sudo' parameter is not passed to command. (4678)
16.0.1.0.1 (2025-05-09)
-----------------------
- Bugfixes: Non-critical issues and performance improvements. (4611)
16.0.1.0.0
----------
Release for Odoo 16.0
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/cetmix/cetmix-tower/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cetmix_tower_server_queue%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
-------
* Cetmix
Maintainers
-----------
This module is part of the `cetmix/cetmix-tower <https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server_queue>`_ project on GitHub.
You are welcome to contribute.

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,19 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Cetmix Tower Server Queue",
"summary": "Cetmix Tower asynchronous task execution using 'queue_job'",
"version": "16.0.1.2.2",
"development_status": "Beta",
"category": "Productivity",
"website": "https://tower.cetmix.com",
"author": "Cetmix",
"license": "AGPL-3",
"installable": True,
"auto_install": True,
"depends": ["cetmix_tower_server", "queue_job"],
"data": [
"views/cx_tower_command_log_view.xml",
"views/cx_tower_file_view.xml",
],
}

View File

@@ -0,0 +1,150 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * cetmix_tower_server_queue
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: cetmix_tower_server_queue
#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_command_log__command_status
msgid ""
"0 if command finished successfully.\n"
"-100 general error,\n"
"-101 not found,\n"
"-201 another instance of this command is running,\n"
"-202 no runner found for the command action,\n"
"-203 Python code execution failed\n"
"-205 plan line condition check failed\n"
"503 if SSH connection error occurred\n"
"601 if queue job failed"
msgstr ""
#. module: cetmix_tower_server_queue
#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_command_log
msgid "Cetmix Tower Command Log"
msgstr ""
#. module: cetmix_tower_server_queue
#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_file
msgid "Cetmix Tower File"
msgstr ""
#. module: cetmix_tower_server_queue
#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_server
msgid "Cetmix Tower Server"
msgstr ""
#. module: cetmix_tower_server_queue
#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form
msgid "Error"
msgstr ""
#. module: cetmix_tower_server_queue
#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__command_status
msgid "Exit Code"
msgstr ""
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "Failure"
msgstr ""
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "File downloaded!"
msgstr ""
#. module: cetmix_tower_server_queue
#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed
msgid "File is currently being processed"
msgstr ""
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "File uploaded!"
msgstr ""
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "File(s) %(name)s download failed: %(error)s"
msgstr ""
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "File(s) %(name)s upload failed: %(error)s"
msgstr ""
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "Files downloaded!"
msgstr ""
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "Files uploaded!"
msgstr ""
#. module: cetmix_tower_server_queue
#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed
msgid "Is Being Processed"
msgstr ""
#. module: cetmix_tower_server_queue
#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form
msgid "Processing"
msgstr ""
#. module: cetmix_tower_server_queue
#: model:ir.model,name:cetmix_tower_server_queue.model_queue_job
#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__queue_job_id
msgid "Queue Job"
msgstr ""
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form
#, python-format
msgid "Success"
msgstr ""
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "The following files are already being processed: %(name)s"
msgstr ""
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid ""
"Unable to upload file '%(f)s'.\n"
"Upload operation is not supported for 'server' type files."
msgstr ""

View File

@@ -0,0 +1,148 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * cetmix_tower_server_queue
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: it\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 2.3\n"
#. module: cetmix_tower_server_queue
#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_command_log__command_status
msgid ""
"0 if command finished successfully.\n"
"-100 general error,\n"
"-101 not found,\n"
"-201 another instance of this command is running,\n"
"-202 no runner found for the command action,\n"
"-203 Python code execution failed\n"
"-205 plan line condition check failed\n"
"503 if SSH connection error occurred\n"
"601 if queue job failed"
msgstr "0 se il comando è stato completato correttamente.-100 errore generale,-101 non trovato,-201 un'altra istanza di questo comando è in esecuzione,-202 nessun runner trovato per l'azione del comando,-203 esecuzione del codice Python non riuscita,-205 controllo delle condizioni della riga del piano non riuscito,503 se si è verificato un errore di connessione SSH,601 se il processo in coda non è riuscito."
#. module: cetmix_tower_server_queue
#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_command_log
msgid "Cetmix Tower Command Log"
msgstr "Registro comando Cetmix Tower"
#. module: cetmix_tower_server_queue
#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_file
msgid "Cetmix Tower File"
msgstr "File Cetmix Tower"
#. module: cetmix_tower_server_queue
#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_server
msgid "Cetmix Tower Server"
msgstr "Server Cetmix Tower"
#. module: cetmix_tower_server_queue
#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form
msgid "Error"
msgstr "Errore"
#. module: cetmix_tower_server_queue
#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__command_status
msgid "Exit Code"
msgstr "Codice uscita"
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "Failure"
msgstr "Fallimento"
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "File downloaded!"
msgstr "File scaricato!"
#. module: cetmix_tower_server_queue
#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed
msgid "File is currently being processed"
msgstr "Il file è in lavorazione"
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "File uploaded!"
msgstr "File caricato!"
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "Files downloaded!"
msgstr "File scaricati!"
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "Files uploaded!"
msgstr "File caricati!"
#. module: cetmix_tower_server_queue
#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed
msgid "Is Being Processed"
msgstr "In lavorazione"
#. module: cetmix_tower_server_queue
#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form
msgid "Processing"
msgstr "Lavorazione"
#. module: cetmix_tower_server_queue
#: model:ir.model,name:cetmix_tower_server_queue.model_queue_job
#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__queue_job_id
msgid "Queue Job"
msgstr "Accoda lavoro"
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form
#, python-format
msgid "Success"
msgstr "Successo"
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "The following files are already being processed: %(name)s"
msgstr "I seguenti file sono già in fase di elaborazione: %(name)s"
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid ""
"Unable to upload file '%(f)s'.\n"
"Upload operation is not supported for 'server' type files."
msgstr ""
"Impossibile caricare il file '%(f)s'.\n"
"L'operazione di caricamento non è supportata per i file di tipo 'server'."
#~ msgid "Display Name"
#~ msgstr "Nome visualizzato"
#~ msgid "ID"
#~ msgstr "ID"
#~ msgid "Last Modified on"
#~ msgstr "Ultima modifica il"

View File

@@ -0,0 +1,4 @@
from . import cx_tower_command_log
from . import cx_tower_server
from . import queue_job
from . import cx_tower_file

View File

@@ -0,0 +1,82 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import fields, models, tools
from odoo.addons.cetmix_tower_server.models.constants import (
COMMAND_STOPPED,
COMMAND_TIMED_OUT,
)
from odoo.addons.queue_job.job import CANCELLED
_logger = logging.getLogger(__name__)
class CxTowerCommandLog(models.Model):
_inherit = "cx.tower.command.log"
queue_job_id = fields.Many2one(
"queue.job",
readonly=True,
groups="queue_job.group_queue_job_manager",
)
command_status = fields.Integer(
help="0 if command finished successfully.\n"
"-100 general error,\n"
"-101 not found,\n"
"-201 another instance of this command is running,\n"
"-202 no runner found for the command action,\n"
"-203 Python code execution failed\n"
"-205 plan line condition check failed\n"
"503 if SSH connection error occurred\n"
"601 if queue job failed"
)
def finish(
self, finish_date=None, status=None, response=None, error=None, **kwargs
):
"""Finish the command log
Args:
finish_date (Datetime, optional): Command finish date. Defaults to None.
status (Integer, optional): Command status. Defaults to None.
response (Text, optional): Command response. Defaults to None.
error (Text, optional): Command error. Defaults to None.
"""
# Filter out command logs that are already stopped
command_logs_to_process = self.filtered(
lambda log: log.command_status != COMMAND_STOPPED
)
if not command_logs_to_process:
return
# Avoid finishing the command log multiple times at the same time
try:
with self.env.cr.savepoint(), tools.mute_logger("odoo.sql_db"):
self.env.cr.execute(
f"SELECT command_status FROM {self._table} WHERE id IN %s FOR UPDATE NOWAIT", # noqa: E501
(tuple(command_logs_to_process.ids),),
)
except Exception as e:
_logger.error(
"Could not acquire lock on command logs %s, skipping finish: %s",
command_logs_to_process.ids,
e,
)
return
# Update the related queue job state if the command timed out
if status == COMMAND_TIMED_OUT:
for command_log in command_logs_to_process:
if command_log.queue_job_id:
command_log.queue_job_id.sudo()._change_job_state(
CANCELLED, result=error
)
return super(CxTowerCommandLog, command_logs_to_process).finish(
finish_date, status, response, error, **kwargs
)

View File

@@ -0,0 +1,184 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class CxTowerFile(models.Model):
_inherit = "cx.tower.file"
is_being_processed = fields.Boolean(
copy=False,
help="File is currently being processed",
)
def _check_files_being_processed(self, raise_error):
"""
Check if any file in the recordset is being processed.
True if at least one file is already processing and raise_error is False.
False if no files are currently being processed.
The caller uses the boolean to decide whether to continue or abort.
"""
processing_files = self.filtered(lambda rec: rec.is_being_processed)
if processing_files:
if raise_error:
raise UserError(
_(
"The following files are already being processed: %(name)s",
name=", ".join(processing_files.mapped("name")),
)
)
else:
return True
return False
def upload(self, raise_error=False):
"""
Trigger asynchronous upload via job queue.
"""
# Check if the file is already being processed
if self._check_files_being_processed(raise_error):
return
self.write({"server_response": False, "is_being_processed": True})
# Enqueue the upload if not already in a queue job;
# otherwise, execute immediately
if not self.env.context.get("job_uuid"):
self.with_delay()._do_upload(raise_error=raise_error)
else:
self._do_upload(raise_error=raise_error)
def download(self, raise_error=False):
"""
Trigger asynchronous download via job queue.
"""
# Check if the file is already being processed
if self._check_files_being_processed(raise_error):
return
self.write({"server_response": False, "is_being_processed": True})
# Enqueue the download if not already in a queue job;
# otherwise, execute immediately
if not self.env.context.get("job_uuid"):
self.with_delay()._do_download(raise_error=raise_error)
else:
self._do_download(raise_error=raise_error)
def _do_upload(self, raise_error=True):
"""
Uploads the files within a job context and notifies the user on success.
Logs the error if an exception occurs;
failure state is managed by the parent method.
"""
try:
with self.env.cr.savepoint():
result = super().upload(raise_error=raise_error)
single_msg = _("File uploaded!")
plural_msg = _("Files uploaded!")
self.env.user.notify_success(
message=single_msg if len(self) == 1 else plural_msg,
title=_("Success"),
# This notification should not be sticky
# to avoid blocking the user's screen
sticky=False,
)
return result
except Exception as e:
if not raise_error:
self.env.user.notify_danger(
message=_(
"File(s) %(name)s upload failed: %(error)s",
name=", ".join(self.mapped("name")),
error=str(e),
),
title=_("Failure"),
sticky=self.env["ir.config_parameter"]
.sudo()
.get_param("cetmix_tower_server.notification_type_error", "sticky")
== "sticky",
)
_logger.error("File %s upload failed: %s", str(self), str(e))
else:
raise
finally:
self.write({"is_being_processed": False})
def _do_download(self, raise_error=True):
"""
Downloads the files within a job context and notifies the user on success.
Logs the error if an exception occurs;
failure state is managed by the parent method.
"""
try:
with self.env.cr.savepoint():
result = super().download(raise_error=raise_error)
single_msg = _("File downloaded!")
plural_msg = _("Files downloaded!")
self.env.user.notify_success(
message=single_msg if len(self) == 1 else plural_msg,
title=_("Success"),
# This notification should not be sticky
# to avoid blocking the user's screen
sticky=False,
)
return result
except Exception as e:
if not raise_error:
self.env.user.notify_danger(
message=_(
"File(s) %(name)s download failed: %(error)s",
name=", ".join(self.mapped("name")),
error=str(e),
),
title=_("Failure"),
sticky=self.env["ir.config_parameter"]
.sudo()
.get_param("cetmix_tower_server.notification_type_error", "sticky")
== "sticky",
)
_logger.error("File %s download failed: %s", str(self), str(e))
else:
raise
finally:
self.write({"is_being_processed": False})
def action_pull_from_server(self):
"""
Pull file from server without notification.
"""
tower_files = self.filtered(lambda file_: file_.source == "tower")
server_files = self - tower_files
tower_files.action_get_current_server_code()
server_files.download(raise_error=False)
def action_push_to_server(self):
"""
Push the file to server without success notification.
"""
server_files = self.filtered(lambda file_: file_.source == "server")
if server_files:
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Failure"),
"message": _(
"Unable to upload file '%(f)s'.\n"
"Upload operation is not supported for 'server' type files.",
f=", ".join(server_files.mapped("rendered_name")),
),
"sticky": False,
},
}
self.upload(raise_error=False)

View File

@@ -0,0 +1,77 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerServer(models.Model):
_inherit = "cx.tower.server"
def _command_runner_wrapper(
self,
command,
log_record,
rendered_command_code,
sudo=None,
rendered_command_path=None,
ssh_connection=None,
**kwargs,
):
# If the flight plan log has an entry on the parent flight plan log,
# it means that this flight plan was launched from another plan,
# this plan should be launched as a synchronous command to
# preserve the order of execution of commands with action “Run flight plan”.
# Use runner only if command log record is provided.
if log_record and not log_record.plan_log_id.parent_flight_plan_log_id:
job = self.with_delay()._queue_command_runner_wrapper(
command=command,
log_record=log_record,
rendered_command_code=rendered_command_code,
sudo=sudo,
rendered_command_path=rendered_command_path,
ssh_connection=ssh_connection,
**kwargs,
)
log_record.sudo().queue_job_id = job.db_record().id
# Otherwise fallback to `super` to return the command output
else:
return super()._command_runner_wrapper(
command=command,
log_record=log_record,
rendered_command_code=rendered_command_code,
sudo=sudo,
rendered_command_path=rendered_command_path,
ssh_connection=ssh_connection,
**kwargs,
)
def _queue_command_runner_wrapper(
self,
command,
log_record,
rendered_command_code,
sudo=None,
rendered_command_path=None,
ssh_connection=None,
**kwargs,
):
# avoid executing command if plan was stopped
log_record.invalidate_recordset(["plan_log_id"])
plan_log_id = log_record.plan_log_id
if plan_log_id:
plan_log_id.invalidate_recordset(["is_stopped"])
# If plan was stopped, stop the command
if plan_log_id.is_stopped:
log_record.stop()
return
return self._command_runner(
command=command,
log_record=log_record,
rendered_command_code=rendered_command_code,
sudo=sudo,
rendered_command_path=rendered_command_path,
ssh_connection=ssh_connection,
**kwargs,
)

View File

@@ -0,0 +1,23 @@
# Copyright 2013-2020 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
from odoo import models
class QueueJob(models.Model):
_inherit = "queue.job"
QUEUE_JOB_ERROR = 601
def write(self, vals):
"""
Override write method to update command status
and write error information in the log record
"""
if vals.get("state") == "failed":
log_record = self.kwargs.get("log_record")
if log_record:
log_record.finish(
status=self.QUEUE_JOB_ERROR,
error=vals.get("exc_info"),
)
return super().write(vals)

View File

@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"

View File

@@ -0,0 +1 @@
Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions.

View File

@@ -0,0 +1,5 @@
This module implements asynchronous task execution for [Cetmix Tower](https://cetmix.com/tower).
It requires the [queue_job](https://github.com/OCA/queue/queue_job) module to be installed and configured in the Odoo instance.
Please refer to the [official documentation](https://cetmix.com/tower) for detailed information.

View File

@@ -0,0 +1,34 @@
## 16.0.1.2.0 (2025-11-12)
- Features: Use the 'web_notify' module to send user notifications. (5074)
## 16.0.1.1.4 (2025-11-05)
- Bugfixes: Finish multiple commands at once. (5062)
## 16.0.1.1.3 (2025-10-13)
- Features: Terminate running flight plan manually (3410)
## 16.0.1.1.0 (2025-07-16)
- Features: cetmix_tower_server_queue: Add async file upload/download via job queue (3720)
- Features: Terminate command with error if job has failed (4718)
## 16.0.1.0.2 (2025-05-16)
- Features: 'sudo' parameter is not passed to command. (4678)
## 16.0.1.0.1 (2025-05-09)
- Bugfixes: Non-critical issues and performance improvements. (4611)
## 16.0.1.0.0
Release for Odoo 16.0

View File

@@ -0,0 +1 @@
Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,484 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Cetmix Tower Server Queue</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="cetmix-tower-server-queue">
<h1 class="title">Cetmix Tower Server Queue</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:b40d3d39da3d8e2545c72b63aa3f14bdb1aaafbfbfbbb51e07ba599400427b8d
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server_queue"><img alt="cetmix/cetmix-tower" src="https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github" /></a></p>
<p>This module implements asynchronous task execution for <a class="reference external" href="https://cetmix.com/tower">Cetmix
Tower</a>.</p>
<p>It requires the <a class="reference external" href="https://github.com/OCA/queue/queue_job">queue_job</a>
module to be installed and configured in the Odoo instance.</p>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed information.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a></li>
<li><a class="reference internal" href="#usage" id="toc-entry-2">Usage</a></li>
<li><a class="reference internal" href="#changelog" id="toc-entry-3">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-4">16.0.1.2.0 (2025-11-12)</a></li>
<li><a class="reference internal" href="#section-2" id="toc-entry-5">16.0.1.1.4 (2025-11-05)</a></li>
<li><a class="reference internal" href="#section-3" id="toc-entry-6">16.0.1.1.3 (2025-10-13)</a></li>
<li><a class="reference internal" href="#section-4" id="toc-entry-7">16.0.1.1.0 (2025-07-16)</a></li>
<li><a class="reference internal" href="#section-5" id="toc-entry-8">16.0.1.0.2 (2025-05-16)</a></li>
<li><a class="reference internal" href="#section-6" id="toc-entry-9">16.0.1.0.1 (2025-05-09)</a></li>
<li><a class="reference internal" href="#section-7" id="toc-entry-10">16.0.1.0.0</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-11">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-12">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-13">Authors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-14">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#toc-entry-1">Configuration</a></h1>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed configuration
instructions.</p>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-2">Usage</a></h1>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed usage
instructions.</p>
</div>
<div class="section" id="changelog">
<h1><a class="toc-backref" href="#toc-entry-3">Changelog</a></h1>
<div class="section" id="section-1">
<h2><a class="toc-backref" href="#toc-entry-4">16.0.1.2.0 (2025-11-12)</a></h2>
<ul class="simple">
<li>Features: Use the web_notify module to send user notifications.
(5074)</li>
</ul>
</div>
<div class="section" id="section-2">
<h2><a class="toc-backref" href="#toc-entry-5">16.0.1.1.4 (2025-11-05)</a></h2>
<ul class="simple">
<li>Bugfixes: Finish multiple commands at once. (5062)</li>
</ul>
</div>
<div class="section" id="section-3">
<h2><a class="toc-backref" href="#toc-entry-6">16.0.1.1.3 (2025-10-13)</a></h2>
<ul class="simple">
<li>Features: Terminate running flight plan manually (3410)</li>
</ul>
</div>
<div class="section" id="section-4">
<h2><a class="toc-backref" href="#toc-entry-7">16.0.1.1.0 (2025-07-16)</a></h2>
<ul class="simple">
<li>Features: cetmix_tower_server_queue: Add async file upload/download
via job queue (3720)</li>
<li>Features: Terminate command with error if job has failed (4718)</li>
</ul>
</div>
<div class="section" id="section-5">
<h2><a class="toc-backref" href="#toc-entry-8">16.0.1.0.2 (2025-05-16)</a></h2>
<ul class="simple">
<li>Features: sudo parameter is not passed to command. (4678)</li>
</ul>
</div>
<div class="section" id="section-6">
<h2><a class="toc-backref" href="#toc-entry-9">16.0.1.0.1 (2025-05-09)</a></h2>
<ul class="simple">
<li>Bugfixes: Non-critical issues and performance improvements. (4611)</li>
</ul>
</div>
<div class="section" id="section-7">
<h2><a class="toc-backref" href="#toc-entry-10">16.0.1.0.0</a></h2>
<p>Release for Odoo 16.0</p>
</div>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-11">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cetmix_tower_server_queue%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-12">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-13">Authors</a></h2>
<ul class="simple">
<li>Cetmix</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-14">Maintainers</a></h2>
<p>This module is part of the <a class="reference external" href="https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server_queue">cetmix/cetmix-tower</a> project on GitHub.</p>
<p>You are welcome to contribute.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,3 @@
from . import test_command
from . import test_command_log
from . import test_file

View File

@@ -0,0 +1,145 @@
from datetime import timedelta
from unittest.mock import patch
from odoo.fields import Datetime
from odoo.tools import mute_logger
from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon
class TestTowerCommand(TestTowerCommon):
"""Test suite for verifying zombie command detection and related
queue job cancellation.
Tests in this class verify that commands which have been running
longer than the timeout are properly detected as zombies, and their
associated queue jobs are cancelled.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Set command timeout to 10 seconds
cls.env["ir.config_parameter"].sudo().set_param(
"cetmix_tower_server.command_timeout", "10"
)
# Set old time to 20 seconds ago (older than timeout)
# to simulate running command in past
now = Datetime.now()
cls.old_time = now - timedelta(seconds=20)
def _patch_command_runner(self, command_type, runner_method):
"""Helper to patch a command runner to simulate a zombie command.
Args:
command_type: Type of command runner to patch ('ssh' or 'python_code')
runner_method: Original method to wrap
Returns:
A context manager that applies the patch
"""
def _wrapper(*args, **kwargs):
# Modify args to disable log record finishing
args = list(args)
if len(args) > 1:
args[1] = False # Set log_record to False
return runner_method(*args, **kwargs)
return patch.object(
self.registry["cx.tower.server"],
f"_command_runner_{command_type}",
_wrapper,
)
def _verify_zombie_command_job_cancellation(self, command_action):
"""Verify zombie command is detected and job is cancelled.
Args:
command_action: Action type ('ssh_command' or 'python_code')
"""
# check zombie command logs
domain = [
("is_running", "=", True),
("start_date", "=", self.old_time),
("command_action", "=", command_action),
]
zombie_command_logs = self.env["cx.tower.command.log"].search(domain)
self.assertEqual(
len(zombie_command_logs), 1, "Zombie command log should be created"
)
self.assertTrue(
zombie_command_logs.queue_job_id,
"Zombie command log should have queue job",
)
job = zombie_command_logs.queue_job_id
self.assertTrue(job.exists(), "Zombie command job should exist")
self.assertEqual(job.state, "pending", "Zombie command job should be pending")
# run process to kill zombie command
self.server_test_1._check_zombie_commands()
# check that command log is cancelled
self.assertEqual(
job.state, "cancelled", "Zombie command job should be cancelled"
)
def test_check_zombie_ssh_command_queue(self):
"""
Test that zombie ssh command is killed and job is cancelled
"""
# Create test commands
ssh_command = self.Command.create(
{
"name": "Test SSH Command",
"code": "ls -la",
"action": "ssh_command",
}
)
# patch command runner to not finish log record
cx_tower_server_obj = self.registry["cx.tower.server"]
_command_runner_ssh_super = cx_tower_server_obj._command_runner_ssh
with self._patch_command_runner("ssh", _command_runner_ssh_super):
# run zombie command with log creation in past
self.server_test_1.run_command(
ssh_command, log={"start_date": self.old_time}
)
# check zombie command logs
self._verify_zombie_command_job_cancellation("ssh_command")
@mute_logger("py.warnings")
def test_check_zombie_python_command_queue(self):
"""
Test that zombie python command is killed and job is cancelled
"""
# Create test commands
python_command = self.Command.create(
{
"name": "Test Python Command",
"code": "print('test')",
"action": "python_code",
}
)
# patch command runner to not finish log record
cx_tower_server_obj = self.registry["cx.tower.server"]
_command_runner_python_code_super = (
cx_tower_server_obj._command_runner_python_code
)
with self._patch_command_runner(
"python_code", _command_runner_python_code_super
):
# run zombie command with log creation in past
self.server_test_1.run_command(
python_command, log={"start_date": self.old_time}
)
# check zombie command logs
self._verify_zombie_command_job_cancellation("python_code")

View File

@@ -0,0 +1,37 @@
from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon
from odoo.addons.queue_job.job import Job
class TestTowerCommand(TestTowerCommon):
"""
Test cases for command log state on queue_job failure
"""
def test_command_log_state_on_job_fail(self):
command = self.env["cx.tower.command"].create(
{
"name": "Test Command",
"action": "ssh_command",
"code": "echo 'Hello World'",
}
)
self.assertTrue(command.id, "Command should be created successfully")
self.server_test_1.run_command(command=command)
command_log = self.env["cx.tower.command.log"].search(
[("command_id", "=", command.id)], order="id desc", limit=1
)
self.assertTrue(command_log, "Command log should be created")
job = command_log.queue_job_id
self.assertTrue(job, "Queue job should be associated with command log")
job_obj = Job.load(self.env, job.uuid)
job_obj.set_failed()
job_obj.store()
self.assertEqual(job.state, "failed", "Job should be in failed state")
self.assertEqual(
command_log.command_status,
self.env["queue.job"].QUEUE_JOB_ERROR,
"Command log should be in failed state",
)

View File

@@ -0,0 +1,201 @@
from odoo import exceptions
from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon
from odoo.addons.queue_job.tests.common import trap_jobs
class TestCxTowerFileQueue(TestTowerCommon):
def setUp(self):
super().setUp()
self.file_template = self.FileTemplate.create(
{
"name": "Test",
"file_name": "test.txt",
"server_dir": "/var/tmp",
"code": "Hello, world!",
}
)
def test_async_upload_operations(self):
"""Test that upload operations are processed asynchronously"""
# Create unique files specifically for this test
upload_file = self.File.create(
{
"source": "tower",
"template_id": self.file_template.id,
"server_id": self.server_test_1.id,
"name": "upload_test_1",
"auto_sync": False,
}
)
upload_file_2 = self.File.create(
{
"name": "upload_test_2",
"source": "server",
"server_id": self.server_test_1.id,
"server_dir": "/var/tmp",
"auto_sync": False,
}
)
with trap_jobs() as trap:
upload_file.upload()
upload_file_2.upload()
self.assertEqual(len(trap.enqueued_jobs), 2)
upload_file.write({"server_response": "ok", "is_being_processed": False})
upload_file_2.write({"server_response": "ok", "is_being_processed": False})
# Refresh records to get updated values
upload_file.invalidate_recordset()
upload_file_2.invalidate_recordset()
# Verify the expected state
self.assertEqual(upload_file.server_response, "ok")
self.assertFalse(upload_file.is_being_processed)
self.assertEqual(upload_file_2.server_response, "ok")
self.assertFalse(upload_file_2.is_being_processed)
def test_async_download_operations(self):
"""Test that download operations are processed asynchronously"""
# Create unique files specifically for this test
download_file = self.File.create(
{
"source": "tower",
"template_id": self.file_template.id,
"server_id": self.server_test_1.id,
"name": "download_test_1",
"auto_sync": False,
}
)
download_file_2 = self.File.create(
{
"name": "download_test_2",
"source": "server",
"server_id": self.server_test_1.id,
"server_dir": "/var/tmp",
"auto_sync": False,
}
)
with trap_jobs() as trap:
download_file.download()
download_file_2.download()
# Verify jobs were created
self.assertEqual(len(trap.enqueued_jobs), 2)
download_file.write({"server_response": "ok", "is_being_processed": False})
download_file_2.write(
{"server_response": "ok", "is_being_processed": False}
)
# Refresh records to get updated values
download_file.invalidate_recordset()
download_file_2.invalidate_recordset()
# Verify the expected state
self.assertEqual(download_file.server_response, "ok")
self.assertFalse(download_file.is_being_processed)
self.assertEqual(download_file_2.server_response, "ok")
self.assertFalse(download_file_2.is_being_processed)
def test_upload_error_handling(self):
"""Test error handling in async upload operations"""
error_file = self.File.create(
{
"source": "tower",
"template_id": self.file_template.id,
"server_id": self.server_test_1.id,
"name": "error_handling_test",
"auto_sync": False,
}
)
# Set context to force the mock in ssh_upload_file to raise error
error_context = {"raise_upload_error": "Forced upload error"}
with trap_jobs() as trap:
# This will trigger job creation but the job would fail if executed
error_file.with_context(**error_context).upload(raise_error=True)
# Verify job was created
self.assertEqual(len(trap.enqueued_jobs), 1)
# Simulate what would happen if the job executed and failed
error_file.write({"server_response": "error", "is_being_processed": False})
error_file.invalidate_recordset()
self.assertEqual(error_file.server_response, "error")
self.assertFalse(error_file.is_being_processed)
def test_download_error_handling(self):
"""Test error handling in async download operations"""
error_file = self.File.create(
{
"source": "server",
"server_id": self.server_test_1.id,
"server_dir": "/var/tmp",
"name": "download_error_test",
}
)
# Set context to force the mock in ssh_download_file to raise error
error_context = {"raise_download_error": "Forced download error"}
with trap_jobs() as trap:
# This will trigger job creation but the job would fail if executed
error_file.with_context(**error_context).download(raise_error=True)
# Verify job was created
self.assertEqual(len(trap.enqueued_jobs), 1)
# Simulate what would happen if the job executed and failed
error_file.write({"server_response": "error", "is_being_processed": False})
error_file.invalidate_recordset()
self.assertEqual(error_file.server_response, "error")
self.assertFalse(error_file.is_being_processed)
def test_already_processing_check(self):
"""Test that files being processed cannot be processed again"""
processing_file = self.File.create(
{
"source": "tower",
"template_id": self.file_template.id,
"server_id": self.server_test_1.id,
"name": "processing_test_file",
"is_being_processed": True,
}
)
self.assertTrue(processing_file.is_being_processed)
# Test with raising error
with self.assertRaises(exceptions.UserError):
processing_file.upload(raise_error=True)
# Test without raising error - should not create job
with trap_jobs() as trap:
processing_file.upload(raise_error=False)
# No job should be created since file is already being processed
self.assertEqual(len(trap.enqueued_jobs), 0)
# Verify still marked as processing
self.assertTrue(processing_file.is_being_processed)
# Same tests for download
with self.assertRaises(exceptions.UserError):
processing_file.download(raise_error=True)
with trap_jobs() as trap:
processing_file.download(raise_error=False)
# No job should be created
self.assertEqual(len(trap.enqueued_jobs), 0)
self.assertTrue(processing_file.is_being_processed)

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_command_log_view_form" model="ir.ui.view">
<field name="name">cx.tower.command.log.view.form</field>
<field name="model">cx.tower.command.log</field>
<field
name="inherit_id"
ref="cetmix_tower_server.cx_tower_command_log_view_form"
/>
<field name="arch" type="xml">
<xpath expr="//field[@name='command_id']" position="after">
<field
name="queue_job_id"
attrs="{'invisible': [('queue_job_id', '=', False)]}"
/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_file_view_form" model="ir.ui.view">
<field name="name">cx.tower.file.view.form</field>
<field name="model">cx.tower.file</field>
<field name="inherit_id" ref="cetmix_tower_server.cx_tower_file_view_form" />
<field name="arch" type="xml">
<xpath expr="//form/sheet/group" position="before">
<field name="is_being_processed" invisible="1" />
<field name="server_response" invisible="1" />
<widget
name="web_ribbon"
title="Processing"
bg_color="bg-info"
attrs="{'invisible': [('is_being_processed', '=', False)]}"
/>
<widget
name="web_ribbon"
title="Success"
bg_color="bg-success"
attrs="{'invisible': ['|', ('is_being_processed', '=', True), ('server_response', '!=', 'ok')]}"
/>
<widget
name="web_ribbon"
title="Error"
bg_color="bg-danger"
attrs="{'invisible': ['|', ('is_being_processed', '=', True), ('server_response', 'in', ('ok', False))]}"
/>
</xpath>
</field>
</record>
<record id="cx_tower_queue_file_view_tree" model="ir.ui.view">
<field name="name">cx.tower.queue.file.view.tree</field>
<field name="model">cx.tower.file</field>
<field name="inherit_id" ref="cetmix_tower_server.cx_tower_file_view_tree" />
<field name="arch" type="xml">
<xpath expr="//tree" position="inside">
<field name="is_being_processed" invisible="1" />
<field name="server_response" invisible="1" />
</xpath>
<xpath expr="//tree" position="attributes">
<attribute name="decoration-info">
is_being_processed == True
</attribute>
<attribute name="decoration-success">
is_being_processed != True and server_response == 'ok'
</attribute>
<attribute name="decoration-danger">
is_being_processed != True and server_response not in ('ok', False)
</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,190 @@
====================
Cetmix Tower Webhook
====================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:6b28bb3bec0ce3e160c08d87fdf2735a4ca2fc271dbf3e361152240f0f02437c
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github
:target: https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_webhook
:alt: cetmix/cetmix-tower
|badge1| |badge2| |badge3|
This module implements incoming webhooks for `Cetmix
Tower <https://tower.cetmix.com>`__. Webhooks are authorised using
customisable authenticators which can be pre-configured and reused
across multiple webhooks. Webhooks and authenticators can be exported
and imported using YAML format, which makes them easily sharable.
This module is a part of Cetmix Tower, however it can be used to manage
any other odoo applications.
Please refer to the `official
documentation <https://tower.cetmix.com>`__ for detailed information.
**Table of contents**
.. contents::
:local:
Use Cases / Context
===================
Although Odoo has native support of webhooks staring 17.0, they still
have some limitations. Another option is the OCA 'endpoint' module which
although is more flexible still makes it usable with Cetmix Tower more
complicated.
Configuration
=============
Configure an Authenticator
--------------------------
**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to
configure authenticators.**
- Go to "Cetmix Tower > Settings > Automation > Webhook Authenticators"
and click "New".
**Complete the following fields:**
- Name. Authenticator name
- Reference. Unique reference. Leave this field blank to auto generate
it
- Code. Code that is used to authenticate the request. You can use all
Cetmix Tower - Python command variables except for the server plus the
following webhook specific ones:
- headers: dictionary that contains the request headers
- raw_data: string with the raw HTTP request body
- payload: dictionary that contains the JSON payload or the GET
parameters of the request
**The code returns the result variable in the following format:**
.. code:: python
result = {"allowed": <bool, mandatory, default=False>, "http_code": <int, optional>, "message": <str, optional>}
eg:
.. code:: python
result = {"allowed": True}
result = {"allowed": False, "http_code": 403, "message": "Sorry..."}
Configure a Webhook
-------------------
**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to
configure webhooks.**
- Go to "Cetmix Tower > Settings > Automation > Webhooks" and click
"New".
**Complete the following fields:**
- Enabled. Uncheck this field to disable the webhook without deleting it
- Name. Authenticator name
- Reference. Unique reference. Leave this field blank to auto generate
it
- Authenticator. Select an Authenticator used for this webhook
- Endpoint. Webhook andpoint. The complete webhook URL will be
<your_tower_url>/cetmix_tower_webhooks/
- Run as User. Select a user to run the webhook on behalf of. CAREFUL!
You must realize and understand what you are doing, including all the
possible consequences when selecting a specific user.
- Code. Code that processes the request. You can use all Cetmix Tower
Python command variables (except for the server) plus the following
webhook-specific one:
- headers: dictionary that contains the request headers
- payload: dictionary that contains the JSON payload or the GET
parameters of the request
Webhook code returns a result using the Cetmix Tower Python command
pattern:
.. code:: python
result = {"exit_code": <int, default=0>, "message": <string, default=None}
**To configure the time for which the webhook call logs are stored:**
- Go to "Cetmix Tower > Settings > General Settings"
- Put a number of days into the "Keep Webhook Logs for (days)" field.
Default value is 30.
Please refer to the `official
documentation <https://tower.cetmix.com>`__ for detailed configuration
instructions.
Usage
=====
When a request is received, Cetmix Tower will search for the webhook
with the matching endpoint and authenticate the request using the
selected authenticator. In case of successful authentication webhook
code is run. Each webhook call is logged. Logs are available under the
"Cetmix Tower > Logs > Webhook Calls" menu or under the "Logs" button
directly in the Webhook.
Please refer to the `official
documentation <https://tower.cetmix.com>`__ for detailed usage
instructions.
Changelog
=========
16.0.1.0.4 (2025-12-11)
-----------------------
- Features: Improve search views, implement the search panel for
selected views. (5139)
16.0.1.0.3 (2025-10-21)
-----------------------
- Features: Use native functions to convert payload to dict (5024)
16.0.1.0.2 (2025-10-06)
-----------------------
- Bugfixes: Export related variables and secrets (4980)
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/cetmix/cetmix-tower/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cetmix_tower_webhook%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
-------
* Cetmix
Maintainers
-----------
This module is part of the `cetmix/cetmix-tower <https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_webhook>`_ project on GitHub.
You are welcome to contribute.

View File

@@ -0,0 +1,2 @@
from . import controllers
from . import models

View File

@@ -0,0 +1,28 @@
# Copyright Cetmix OÜ 2025
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Cetmix Tower Webhook",
"summary": "Webhook implementation for Cetmix Tower",
"version": "16.0.1.0.5",
"development_status": "Beta",
"category": "Productivity",
"website": "https://tower.cetmix.com",
"live_test_url": "https://tower.cetmix.com/download",
"images": ["static/description/banner.png"],
"author": "Cetmix",
"license": "AGPL-3",
"installable": True,
"depends": ["cetmix_tower_yaml"],
"data": [
"security/ir.model.access.csv",
"views/cx_tower_webhook_authenticator_views.xml",
"views/cx_tower_webhook_log_views.xml",
"views/cx_tower_webhook_views.xml",
"views/cx_tower_variable_views.xml",
"views/res_config_settings_views.xml",
"views/menuitems.xml",
],
"demo": [
"demo/demo_data.xml",
],
}

View File

@@ -0,0 +1,4 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import main

View File

@@ -0,0 +1,250 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import json
import logging
from odoo import http
from odoo.http import Response, request
_logger = logging.getLogger(__name__)
class CetmixTowerWebhookController(http.Controller):
"""
Handles incoming requests to Tower webhooks.
"""
@http.route(
["/cetmix_tower_webhooks/<string:endpoint>"],
type="http",
auth="public",
methods=["POST", "GET"],
csrf=False,
save_session=False,
)
def cetmix_webhook(self, endpoint, **kwargs):
"""
Process an incoming webhook request.
Workflow:
1. Extract request headers, body, and HTTP method.
2. Match the request against a registered webhook.
3. Authenticate the request if required.
4. Execute the webhook code.
5. Log the request and return the response.
Args:
endpoint (str): The requested webhook endpoint.
**kwargs: Additional request parameters.
Returns:
Response: HTTP JSON response containing the result message.
"""
# Step 1: Extract request data
headers = self._extract_webhook_request_headers()
raw_data = self._extract_webhook_request_raw_data()
http_method = request.httprequest.method.lower()
# Step 2. Find webhook
webhook = (
request.env["cx.tower.webhook"]
.sudo()
.search(
[
("endpoint", "=", endpoint),
("method", "=", http_method),
("active", "=", True),
],
)
)
payload = self._extract_webhook_request_payload(webhook)
log_model = request.env["cx.tower.webhook.log"].sudo()
log_values = log_model._prepare_values(
webhook=webhook,
endpoint=endpoint,
request_method=http_method,
request_payload=payload,
request_headers=headers,
authentication_status="not_required",
code_status="skipped",
)
if not webhook:
log_values.update(
{
"authentication_status": "failed",
"http_status": 404,
}
)
return self._finalize_webhook_response(
message="Webhook not found",
error_message="Webhook not found",
**log_values,
)
# Step 3. Authenticate
auth_status, auth_error, http_auth_code = "success", None, 200
if webhook.authenticator_id:
if not webhook.authenticator_id.is_ip_allowed(self._get_remote_addr()):
auth_status, auth_error, http_auth_code = (
"failed",
"Address not allowed",
403,
)
log_values.update(
{
"error_message": auth_error,
"http_status": http_auth_code,
"authentication_status": auth_status,
}
)
return self._finalize_webhook_response(
message=auth_error,
**log_values,
)
try:
with request.env.cr.savepoint():
auth_result = webhook.authenticator_id.sudo().authenticate(
headers=headers,
raw_data=raw_data,
payload=payload,
)
if not auth_result.get("allowed"):
raise Exception(
auth_result.get("message", "Authentication not allowed")
)
except Exception as e:
auth_status, auth_error, http_auth_code = "failed", str(e), 403
else:
auth_status = "not_required"
if auth_status == "failed":
# Authentication failed
log_values.update(
{
"error_message": auth_error,
"http_status": http_auth_code,
"authentication_status": auth_status,
}
)
return self._finalize_webhook_response(
message=auth_error,
**log_values,
)
# Step 4. Execute webhook code
code_status, error_message, http_code, message = "success", None, 200, "OK"
try:
with request.env.cr.savepoint():
code_result = webhook.execute(payload, headers=headers)
if code_result.get("exit_code") != 0:
raise Exception(code_result.get("message"))
message = code_result.get("message") or "OK"
except Exception as e:
code_status, error_message, http_code, message = "failed", str(e), 500, None
# Step 5. Update log
log_values.update(
{
"code_status": code_status,
"error_message": error_message,
"http_status": http_code,
"result_message": message,
"authentication_status": auth_status,
}
)
return self._finalize_webhook_response(
message=message or error_message or "", **log_values
)
def _extract_webhook_request_payload(self, webhook):
"""
Extract the request payload depending on HTTP method and content type.
Args:
webhook (cx.tower.webhook): Webhook record with configuration
(may be empty).
Returns:
dict: Parsed payload as a dictionary. Empty if parsing fails.
"""
http_method = request.httprequest.method
try:
if http_method.upper() == "POST":
content_type = webhook.content_type if webhook else "json"
return self._get_payload_by_content_type(content_type)
elif http_method.upper() == "GET":
return request.httprequest.args.to_dict(flat=True)
except Exception:
return {}
return {}
def _get_payload_by_content_type(self, content_type):
"""
Return the request payload for POST requests according to content type.
Args:
content_type (str): Payload format, e.g. "json" or "form".
Returns:
dict: Parsed payload as a dictionary.
"""
if content_type == "form":
return request.httprequest.form.to_dict(flat=True)
data = request.httprequest.data
return json.loads(data or "{}") if data else {}
def _extract_webhook_request_headers(self):
"""
Extract request headers.
Returns:
dict: Request headers as a dictionary.
"""
return dict(request.httprequest.headers)
def _extract_webhook_request_raw_data(self):
"""
Return raw request body.
Returns:
bytes: Raw HTTP request body.
"""
return request.httprequest.data
def _finalize_webhook_response(self, message, **kwargs):
"""
Create a log entry and return final HTTP response.
Args:
message (str): Response message text.
**kwargs: Log values for `cx.tower.webhook.log`.
Returns:
Response: HTTP JSON response with message and status code.
"""
try:
with request.env.cr.savepoint():
request.env["cx.tower.webhook.log"].sudo().create_from_call(**kwargs)
except Exception:
# don't break controller if logging fails
_logger.error("Failed to create log entry", exc_info=True)
return Response(
status=kwargs.get("http_status"),
response=json.dumps({"message": message or ""}),
content_type="application/json",
)
def _get_remote_addr(self):
"""
Return the remote IP address of the current request.
Returns:
str: Remote client IP address.
"""
return request.httprequest.remote_addr

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<!-- Webhook Authenticators -->
<record id="webhook_authenticator_1" model="cx.tower.webhook.authenticator">
<field name="name">Demo Webhook Authenticator 1</field>
<field name="reference">demo_webhook_authenticator_1</field>
<field name="allowed_ip_addresses">192.168.1.10,192.168.2.0/24,10.0.0.1</field>
<field name="code">result = {"allowed": True}</field>
</record>
<record id="webhook_authenticator_2" model="cx.tower.webhook.authenticator">
<field name="name">Demo Webhook Authenticator 2</field>
<field name="reference">demo_webhook_authenticator_2</field>
<field
name="code"
>result = {"allowed": False, "http_code": 403, "message": "Sorry..."}</field>
</record>
<!-- Webhooks -->
<record id="webhook_1" model="cx.tower.webhook">
<field name="name">Demo Webhook 1</field>
<field name="reference">demo_webhook_1</field>
<field name="authenticator_id" ref="webhook_authenticator_1" />
<field name="endpoint">demo_webhook_1</field>
<field name="code">result = {"exit_code": 0, "message": "OK"}</field>
</record>
<record id="webhook_2" model="cx.tower.webhook">
<field name="name">Demo Webhook 2</field>
<field name="reference">demo_webhook_2</field>
<field name="authenticator_id" ref="webhook_authenticator_2" />
<field name="endpoint">demo_webhook_2</field>
<field name="method">get</field>
<field name="code">result = {"exit_code": 0, "message": "OK"}</field>
</record>
</odoo>

View File

@@ -0,0 +1,793 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * cetmix_tower_webhook
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/constants.py:0
#, python-format
msgid ""
"\n"
"<h3>Help for Webhook Authenticator Python Code</h3>\n"
"<div style=\"margin-bottom: 10px;\">\n"
" <p>\n"
" The Python code for the webhook authenticator must return the <code>result</code> variable, which is a dictionary.<br>\n"
" <strong>Allowed keys:</strong>\n"
" <ul>\n"
" <li><code>allowed</code> (<b>bool</b>, required): Authentication result. <code>True</code> if allowed, <code>False</code> otherwise.</li>\n"
" <li><code>http_code</code> (<b>int</b>, optional): HTTP status code to return if authentication fails (default is 403).</li>\n"
" <li><code>message</code> (<b>str</b>, optional): Error message to show to the client.</li>\n"
" </ul>\n"
" <strong>Examples:</strong>\n"
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
"# Allow all requests\n"
"result = {\"allowed\": True}\n"
"\n"
"# Deny with custom code and message\n"
"result = {\"allowed\": False, \"http_code\": 401, \"message\": \"Unauthorized request\"}\n"
" </pre>\n"
" </p>\n"
" <strong>Available variables:</strong>\n"
"</div>\n"
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/constants.py:0
#, python-format
msgid ""
"\n"
"<h3>Help for Webhook Python Code</h3>\n"
"<div style=\"margin-bottom: 10px;\">\n"
" <p>\n"
" The webhook Python code must set the <code>result</code> variable, which is a dictionary.<br>\n"
" <strong>Allowed keys:</strong>\n"
" <ul>\n"
" <li><code>exit_code</code> (<b>int</b>, optional, default=0): Exit code (0 means success, other values indicate failure).</li>\n"
" <li><code>message</code> (<b>str</b>, optional): Message to return in the HTTP response and log.</li>\n"
" </ul>\n"
" <strong>Example:</strong>\n"
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
"# Simple successful result\n"
"result = {\"exit_code\": 0, \"message\": \"Webhook processed successfully\"}\n"
"\n"
"# Failure example\n"
"result = {\"exit_code\": 1, \"message\": \"Something went wrong\"}\n"
" </pre>\n"
" </p>\n"
" <strong>Available variables:</strong>\n"
"</div>\n"
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/constants.py:0
#, python-format
msgid ""
"# Please refer to the 'Help' tab and documentation for more information.\n"
"#\n"
"# You can return authenticator result in the 'result' variable which is a dictionary:\n"
"# result = {\"allowed\": <bool, mandatory, default=False>, \"http_code\": <int, optional>, \"message\": <str, optional>}\n"
"# default value is {\"allowed\": False}\n"
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/constants.py:0
#, python-format
msgid ""
"# Please refer to the 'Help' tab and documentation for more information.\n"
"#\n"
"# You can return webhook result in the 'result' variable which is a dictionary:\n"
"# result = {\"exit_code\": <int, default=0>, \"message\": <string, default=None}\n"
"# default value is {\"exit_code\": 0, \"message\": None}\n"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
msgid "10.0.0.1,192.168.1.0/24"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_action
msgid "Add a new webhook"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_authenticator_action
msgid "Add a new webhook authenticator"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "All"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__allowed_ip_addresses
msgid "Allowed IPs"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Auth Failed"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Auth Status"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__authentication_status
msgid "Authentication Status"
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid "Authentication code error: %s"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__authenticator_id
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__authenticator_id
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Authenticator"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__reference
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__reference
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__reference
msgid ""
"Can contain English letters, digits and '_'. Leave blank to autogenerate"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_variable
msgid "Cetmix Tower Variable"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__code
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "Code"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Code Failed"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__code_help
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code_help
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code_help
msgid "Code Help"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Code Status"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__allowed_ip_addresses
msgid ""
"Comma-separated list of IP addresses and/or subnets (e.g. "
"192.168.1.10,192.168.2.0/24,10.0.0.1,2001:db8::/32,2a00:1450:4001:824::200e)."
" Requests from other addresses will be denied."
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__trusted_proxy_ips
msgid ""
"Comma-separated list of trusted proxy IP addresses or CIDR ranges (e.g., "
"10.0.0.1,192.168.1.0/24). Only these proxies can set X-Forwarded-For "
"headers."
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_res_config_settings
msgid "Config Settings"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Content Type"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__country_id
msgid "Country"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__country_id
msgid "Country of the client that made the request."
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__create_uid
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__create_uid
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__create_uid
msgid "Created by"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__create_date
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__create_date
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__create_date
msgid "Created on"
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid ""
"Dictionary containing the request payload (JSON for POST, params for GET)"
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid "Dictionary of request headers"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Disabled"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__display_name
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__display_name
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__display_name
msgid "Display Name"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__active
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Enabled"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__endpoint
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__endpoint
msgid "Endpoint"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.constraint,message:cetmix_tower_webhook.constraint_cx_tower_webhook_endpoint_method_uniq
msgid "Endpoint and method must be unique!"
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
#, python-format
msgid ""
"Endpoint must start and end with a letter or digit, and may contain "
"underscores, dashes, and slashes in between"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
msgid ""
"Enter Python code here. Help about Python expression is available in the "
"help tab of this document"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid ""
"Enter Python code here. Help about Python expression is available in the "
"help tab of this document."
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
msgid "Error"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__error_message
msgid "Error Message"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__error_message
msgid "Error message in case of authentication or code failure."
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_eval_mixin
msgid "Eval context/code helper for Cetmix Tower Webhook"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.actions.act_window,name:cetmix_tower_webhook.action_cx_tower_webhook_authenticator_export_yaml
#: model:ir.actions.act_window,name:cetmix_tower_webhook.action_cx_tower_webhook_export_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "Export YAML"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__failed
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__failed
msgid "Failed"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__content_type__form
msgid "Form URL-Encoded"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__full_url
msgid "Full URL of the webhook"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__full_url
msgid "Full Url"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__method__get
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__request_method__get
msgid "GET"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Group By"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "HTTP 200"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__http_status
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "HTTP Status"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__http_status
msgid "HTTP status code returned to the client."
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_headers
msgid "Headers of the received HTTP request (JSON-encoded)."
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "Help"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__content_type
msgid ""
"How the payload is expected to be sent to this webhook: as JSON body or as "
"URL-encoded form data"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__id
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__id
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__id
msgid "ID"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__ip_address
msgid "IP Address"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__ip_address
msgid "IP address of the client that made the request."
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid "Invalid allowed IP/CIDR entry: %s"
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid "Invalid trusted proxy entry: %s"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__content_type__json
msgid "JSON"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_res_config_settings__cetmix_tower_webhook_log_duration
msgid "Keep Webhook Logs for (days)"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook____last_update
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator____last_update
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log____last_update
msgid "Last Modified on"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__write_uid
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__write_uid
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__write_uid
msgid "Last Updated by"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__write_date
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__write_date
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__write_date
msgid "Last Updated on"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__log_count
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__log_count
msgid "Log Count"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "Logs"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__result_message
msgid "Message returned by the webhook code or authenticator (if any)."
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__method
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Method"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__name
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__name
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__name
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
msgid "Name"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Name/Reference"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_log_action
msgid "No webhook logs found"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__not_required
msgid "Not Required"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__method__post
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__request_method__post
msgid "POST"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__content_type
msgid "Payload Type"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_payload
msgid "Payload/body of the received HTTP request (JSON-encoded)."
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid "Raw body of the request (bytes)"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__reference
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__reference
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__reference
msgid "Reference"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_headers
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
msgid "Request Headers"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_method
msgid "Request Method"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_payload
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
msgid "Request Payload"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
msgid "Response Payload"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__result_message
msgid "Result Message"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__authentication_status
msgid "Result of authentication for this webhook call."
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__code_status
msgid "Result of webhook code execution."
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__user_id
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__user_id
msgid "Run as User"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_search
msgid "Search Webhook Authenticators"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Search Webhooks"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__secret_ids
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__secret_ids
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__secret_ids
msgid "Secrets"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__user_id
msgid ""
"Select a user to run the webhook from behalf of. If not set, the webhook will run as the current user.\n"
"CAREFUL! You must realise and understand what you are doing including all the possible consequences when selecting a specific user"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__authenticator_id
msgid "Select an Authenticator used for this webhook"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__method
msgid "Select the HTTP method for this webhook"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_method
msgid "Select the HTTP method for this webhook."
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_res_config_settings__cetmix_tower_webhook_log_duration
msgid ""
"Set the number of days to keep webhook logs. Old logs will be deleted "
"automatically."
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.res_config_settings_view_form
msgid ""
"Set the number of days to keep webhook logs. Old logs will be deleted automatically.\n"
" <br/>"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__skipped
msgid "Skipped"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__success
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__success
msgid "Success"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__code
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code
msgid "This field will be rendered using variables"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__trusted_proxy_ips
msgid "Trusted Proxy IPs"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "User"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__user_id
msgid "User as which the webhook code was executed (if set)."
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__variable_ids
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__variable_ids
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__variable_ids
msgid "Variables"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_ids
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__webhook_id
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Webhook"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_authenticator
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_authenticator_ids
msgid "Webhook Authenticator"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_authenticator_ids_count
msgid "Webhook Authenticator Count"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_authenticator_action
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook_authenticator
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_variable_view_form
msgid "Webhook Authenticators"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_log
msgid "Webhook Call Log"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook_log
msgid "Webhook Calls"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__code_status
msgid "Webhook Code Status"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_ids_count
msgid "Webhook Count"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_log_action
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Webhook Logs"
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
#, python-format
msgid "Webhook code execution error: %(error)s"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__endpoint
msgid ""
"Webhook endpoint. The complete URL will be "
"<your_tower_url>/cetmix_tower_webhooks/<endpoint>"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__webhook_id
msgid "Webhook that received the call."
msgstr ""
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_eval_mixin.py:0
#, python-format
msgid "Webhook/Authenticator code error: result is not a dict"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_action
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_variable_view_form
msgid "Webhooks"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "YAML"
msgstr ""
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__yaml_code
msgid "Yaml Code"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
msgid "You must be a member of the \"YAML/Export\" group to export data as YAML"
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "You must be a member of the \"YAML/Export\" group to export data as YAML."
msgstr ""
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
msgid ""
"e.g.: 192.168.1.10, 192.168.2.0/24, 2001:db8::/32, 2a00:1450:4001:824::200e"
msgstr ""

View File

@@ -0,0 +1,822 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * cetmix_tower_webhook
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/constants.py:0
#, python-format
msgid ""
"\n"
"<h3>Help for Webhook Authenticator Python Code</h3>\n"
"<div style=\"margin-bottom: 10px;\">\n"
" <p>\n"
" The Python code for the webhook authenticator must return the <code>result</code> variable, which is a dictionary.<br>\n"
" <strong>Allowed keys:</strong>\n"
" <ul>\n"
" <li><code>allowed</code> (<b>bool</b>, required): Authentication result. <code>True</code> if allowed, <code>False</code> otherwise.</li>\n"
" <li><code>http_code</code> (<b>int</b>, optional): HTTP status code to return if authentication fails (default is 403).</li>\n"
" <li><code>message</code> (<b>str</b>, optional): Error message to show to the client.</li>\n"
" </ul>\n"
" <strong>Examples:</strong>\n"
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
"# Allow all requests\n"
"result = {\"allowed\": True}\n"
"\n"
"# Deny with custom code and message\n"
"result = {\"allowed\": False, \"http_code\": 401, \"message\": \"Unauthorized request\"}\n"
" </pre>\n"
" </p>\n"
" <strong>Available variables:</strong>\n"
"</div>\n"
msgstr ""
"\n"
"<h3>Aiuto per il codice Python di autenticazione webhook</h3>\n"
"<div style=\"margin-bottom: 10px;\">\n"
" <p>\n"
" Il codice Python per l'autenticazione webhook deve restituire la variabile <code>result</code>, che è un dizionario.<br>\n"
" <strong>Chiavi consentite:</strong>\n"
" <ul>\n"
" <li><code>allowed</code> (<b>bool</b>, richiesto): risulatato autenticazione. <code>True</code> se abilitato, <code>False</code> altrimenti.</li>\n"
" <li><code>http_code</code> (<b>int</b>, opzionalel): codice stato HTTP da restituire se l'autenticazione fallisce (predefinito 403).</li>\n"
" <li><code>message</code> (<b>str</b>, opzionale): messaggio di errore da visualizzare al cliente.</li>\n"
" </ul>\n"
" <strong>Esempi:</strong>\n"
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
"# Consenti tutte le richieste\n"
"result = {\"allowed\": True}\n"
"\n"
"# Nega con codie e messaggio personalizzati\n"
"result = {\"allowed\": False, \"http_code\": 401, \"message\": \"Richiesta non autorizzata\"}\n"
" </pre>\n"
" </p>\n"
" <strong>Variabili disponibili:</strong>\n"
"</div>\n"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/constants.py:0
#, python-format
msgid ""
"\n"
"<h3>Help for Webhook Python Code</h3>\n"
"<div style=\"margin-bottom: 10px;\">\n"
" <p>\n"
" The webhook Python code must set the <code>result</code> variable, which is a dictionary.<br>\n"
" <strong>Allowed keys:</strong>\n"
" <ul>\n"
" <li><code>exit_code</code> (<b>int</b>, optional, default=0): Exit code (0 means success, other values indicate failure).</li>\n"
" <li><code>message</code> (<b>str</b>, optional): Message to return in the HTTP response and log.</li>\n"
" </ul>\n"
" <strong>Example:</strong>\n"
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
"# Simple successful result\n"
"result = {\"exit_code\": 0, \"message\": \"Webhook processed successfully\"}\n"
"\n"
"# Failure example\n"
"result = {\"exit_code\": 1, \"message\": \"Something went wrong\"}\n"
" </pre>\n"
" </p>\n"
" <strong>Available variables:</strong>\n"
"</div>\n"
msgstr ""
"\n"
"<h3>Aiuto per codice Python webhook</h3>\n"
"<div style=\"margin-bottom: 10px;\">\n"
" <p>\n"
" Il codice Python webhook deve impostare la variabile <code>result</code>, che è un dizionario.<br>\n"
" <strong>Chiavi consentitie:</strong>\n"
" <ul>\n"
" <li><code>exit_code</code> (<b>int</b>, opzionale, predefinito=0): codice di uscita (0 significa successo, altri valori indicano fallimento).</li>\n"
" <li><code>message</code> (<b>str</b>, opzionale): messaggio da restituire nella risposta HTTP e nel log.</li>\n"
" </ul>\n"
" <strong>Esempio:</strong>\n"
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
"# Risultato successo semplce\n"
"result = {\"exit_code\": 0, \"message\": \"Webhook elaborato con successo\"}\n"
"\n"
"# Esempio di fallimento\n"
"result = {\"exit_code\": 1, \"message\": \"Qualcosa è andato storto\"}\n"
" </pre>\n"
" </p>\n"
" <strong>Variabili disponibili:</strong>\n"
"</div>\n"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/constants.py:0
#, python-format
msgid ""
"# Please refer to the 'Help' tab and documentation for more information.\n"
"#\n"
"# You can return authenticator result in the 'result' variable which is a dictionary:\n"
"# result = {\"allowed\": <bool, mandatory, default=False>, \"http_code\": <int, optional>, \"message\": <str, optional>}\n"
"# default value is {\"allowed\": False}\n"
msgstr ""
"# Fare riferimento alla libuetta 'Help' e alla documentazione per informazioni aggiuntive.\n"
"#\n"
"# Si può restituire il risultato dell'autenticazione nella variabile 'result' che è un dizionario:\n"
"# result = {\"allowed\": <bool, mandatory, default=False>, \"http_code\": <int, optional>, \"message\": <str, optional>}\n"
"# il valore predefinito è {\"allowed\": False}\n"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/constants.py:0
#, python-format
msgid ""
"# Please refer to the 'Help' tab and documentation for more information.\n"
"#\n"
"# You can return webhook result in the 'result' variable which is a dictionary:\n"
"# result = {\"exit_code\": <int, default=0>, \"message\": <string, default=None}\n"
"# default value is {\"exit_code\": 0, \"message\": None}\n"
msgstr ""
"# Fare riferimento alla libuetta 'Help' e alla documentazione per informazioni aggiuntive.\n"
"#\n"
"# Si può restituire il risultato del webhook nella variabile 'result' che è un dizionario:\n"
"# result = {\"exit_code\": <int, default=0>, \"message\": <string, default=None}\n"
"# valore predefinoto {\"exit_code\": 0, \"message\": None}\n"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
msgid "10.0.0.1,192.168.1.0/24"
msgstr "10.0.0.1,192.168.1.0/24"
#. module: cetmix_tower_webhook
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_action
msgid "Add a new webhook"
msgstr "Aggiungi un nuovo webhook"
#. module: cetmix_tower_webhook
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_authenticator_action
msgid "Add a new webhook authenticator"
msgstr "Aggiungi un nuovo autenticatore webhook"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "All"
msgstr "Tutti"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__allowed_ip_addresses
msgid "Allowed IPs"
msgstr "IP consentiti"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Auth Failed"
msgstr "Autenticazione fallita"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Auth Status"
msgstr "Stato autenticazione"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__authentication_status
msgid "Authentication Status"
msgstr "Stato autenticazione"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid "Authentication code error: %s"
msgstr "Codice errore autenticazione: %s"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__authenticator_id
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__authenticator_id
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Authenticator"
msgstr "Autenticatore"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__reference
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__reference
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__reference
msgid "Can contain English letters, digits and '_'. Leave blank to autogenerate"
msgstr "Può contenere lettere inglesi, numeri e '_'. Lasciare vuoto per generarlo automaticamente"
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_variable
msgid "Cetmix Tower Variable"
msgstr "Variabile Cetmix Tower"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__code
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "Code"
msgstr "Codice"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Code Failed"
msgstr "Codice fallito"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__code_help
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code_help
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code_help
msgid "Code Help"
msgstr "Aiuto codice"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Code Status"
msgstr "Stato codice"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__allowed_ip_addresses
msgid "Comma-separated list of IP addresses and/or subnets (e.g. 192.168.1.10,192.168.2.0/24,10.0.0.1,2001:db8::/32,2a00:1450:4001:824::200e). Requests from other addresses will be denied."
msgstr "Elenco indirizzi IP e/o sottoreti separati da virgola (es. 192.168.1.10,192.168.2.0/24,10.0.0.1,2001:db8::/32,2a00:1450:4001:824::200e). Richieste da altri indirizzi verranno rifiutate."
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__trusted_proxy_ips
msgid "Comma-separated list of trusted proxy IP addresses or CIDR ranges (e.g., 10.0.0.1,192.168.1.0/24). Only these proxies can set X-Forwarded-For headers."
msgstr "Elenco di indirizzi IP affidabili o intervalli CIDR separati da virgola (es. 10.0.0.1,192.168.1.0/24). Solo questi proxies possono impostare header X-Forwarded-For."
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_res_config_settings
msgid "Config Settings"
msgstr "Impostazioni configurazione"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Content Type"
msgstr "Tipo contenuto"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__country_id
msgid "Country"
msgstr "Nazione"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__country_id
msgid "Country of the client that made the request."
msgstr "Nazione del client che ha fatto la richiesta."
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__create_uid
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__create_uid
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__create_uid
msgid "Created by"
msgstr "Creato da"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__create_date
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__create_date
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__create_date
msgid "Created on"
msgstr "Creato il"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid "Dictionary containing the request payload (JSON for POST, params for GET)"
msgstr "Dizionario che contiene le informazioni richieste (JSON per POST, parametri per GET)"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid "Dictionary of request headers"
msgstr "Dizionario degli header richiesti"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Disabled"
msgstr "Disabilitato"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__display_name
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__display_name
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__display_name
msgid "Display Name"
msgstr "Nome visualizzato"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__active
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Enabled"
msgstr "Abilitato"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__endpoint
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__endpoint
msgid "Endpoint"
msgstr "Endpoint"
#. module: cetmix_tower_webhook
#: model:ir.model.constraint,message:cetmix_tower_webhook.constraint_cx_tower_webhook_endpoint_method_uniq
msgid "Endpoint and method must be unique!"
msgstr "L'endpoint e il motodo devono essere univoci!"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
#, python-format
msgid "Endpoint must start and end with a letter or digit, and may contain underscores, dashes, and slashes in between"
msgstr "L'endpoint deve iniziare e finire con una lettera o un numero e può contenere underscore, trattini e slash nel mezzo"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
msgid "Enter Python code here. Help about Python expression is available in the help tab of this document"
msgstr "Inserire qui codice Python. L'aiuto per l'espressione Python è disponibile nella linguetta di aiuto di questo documento"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "Enter Python code here. Help about Python expression is available in the help tab of this document."
msgstr "Inserire qui codice Python. L'aiuto per l'espressione Python è disponibile nella linguetta di aiuto di questo documento."
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
msgid "Error"
msgstr "Errore"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__error_message
msgid "Error Message"
msgstr "Messaggio di errore"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__error_message
msgid "Error message in case of authentication or code failure."
msgstr "Messaggio di errore nel caso di fallimento dell'autenticazione o del codice."
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_eval_mixin
msgid "Eval context/code helper for Cetmix Tower Webhook"
msgstr "Aiuto per la valutazione del context/codice per il webhook Cetmix Tower"
#. module: cetmix_tower_webhook
#: model:ir.actions.act_window,name:cetmix_tower_webhook.action_cx_tower_webhook_authenticator_export_yaml
#: model:ir.actions.act_window,name:cetmix_tower_webhook.action_cx_tower_webhook_export_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "Export YAML"
msgstr "Esporta YAML"
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__failed
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__failed
msgid "Failed"
msgstr "Fallito"
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__content_type__form
msgid "Form URL-Encoded"
msgstr "Da URL codificato"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__full_url
msgid "Full URL of the webhook"
msgstr "URL completo del webhook"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__full_url
msgid "Full Url"
msgstr "URL completo"
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__method__get
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__request_method__get
msgid "GET"
msgstr "GET"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Group By"
msgstr "Raggruppa per"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "HTTP 200"
msgstr "HTTP 200"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__http_status
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "HTTP Status"
msgstr "Stato HTTP"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__http_status
msgid "HTTP status code returned to the client."
msgstr "Codice stato HTTP restituito al client."
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_headers
msgid "Headers of the received HTTP request (JSON-encoded)."
msgstr "Header della richiesta HHTP rivevuta (codificata JSON)."
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "Help"
msgstr "Aiuto"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__content_type
msgid "How the payload is expected to be sent to this webhook: as JSON body or as URL-encoded form data"
msgstr "Come è previsto che vengano inviati i dati a questo webhook: come struttura JSON o come URL codificato dai dati"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__id
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__id
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__id
msgid "ID"
msgstr "ID"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__ip_address
msgid "IP Address"
msgstr "Indirizzo IP"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__ip_address
msgid "IP address of the client that made the request."
msgstr "Indirizzo IP del client che ha effettuato la richiesta."
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid "Invalid allowed IP/CIDR entry: %s"
msgstr "Valore IP/CIDR consentito non valido: %s"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid "Invalid trusted proxy entry: %s"
msgstr "Valore proxy validato non valido: %s"
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__content_type__json
msgid "JSON"
msgstr "JSON"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_res_config_settings__cetmix_tower_webhook_log_duration
msgid "Keep Webhook Logs for (days)"
msgstr "Mantenere i registri del webhook per (giorni)"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook____last_update
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator____last_update
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log____last_update
msgid "Last Modified on"
msgstr "Ultima modifica il"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__write_uid
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__write_uid
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__write_uid
msgid "Last Updated by"
msgstr "Ultima modifica di"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__write_date
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__write_date
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__write_date
msgid "Last Updated on"
msgstr "Ultimo aggiornamento di"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__log_count
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__log_count
msgid "Log Count"
msgstr "Conteggio registro"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "Logs"
msgstr "Registri"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__result_message
msgid "Message returned by the webhook code or authenticator (if any)."
msgstr "Messaggio restituito dal codice webhook o dall'autenticatore (se presente)."
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__method
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Method"
msgstr "Metodo"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__name
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__name
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__name
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
msgid "Name"
msgstr "nome"
#. module: cetmix_tower_webhook
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_log_action
msgid "No webhook logs found"
msgstr "Nessun registro webhook trovato"
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__not_required
msgid "Not Required"
msgstr "Non richiesto"
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__method__post
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__request_method__post
msgid "POST"
msgstr "POST"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__content_type
msgid "Payload Type"
msgstr "Tipo dati"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_payload
msgid "Payload/body of the received HTTP request (JSON-encoded)."
msgstr "Dati/corpo della richiesta HTTP ricevuta (codificata JSON)."
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
#, python-format
msgid "Raw body of the request (bytes)"
msgstr "Corpo grezzo della richiesta (vyte)"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__reference
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__reference
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__reference
msgid "Reference"
msgstr "Riferimento"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_headers
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
msgid "Request Headers"
msgstr "Geader richiesta"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_method
msgid "Request Method"
msgstr "Metodo richiesta"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_payload
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
msgid "Request Payload"
msgstr "Dati richiesta"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
msgid "Response Payload"
msgstr "Dati risposta"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__result_message
msgid "Result Message"
msgstr "Messaggio risultato"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__authentication_status
msgid "Result of authentication for this webhook call."
msgstr "Risultato dell'autenticazione di questa chiamata webhook."
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__code_status
msgid "Result of webhook code execution."
msgstr "Risultato dell'esecuzione del codice webhook."
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__user_id
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__user_id
msgid "Run as User"
msgstr "Esegui come utente"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_search
msgid "Search Webhook Authenticators"
msgstr "Cerca autenticatori webhook"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "Search Webhooks"
msgstr "Cerca webhook"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__secret_ids
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__secret_ids
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__secret_ids
msgid "Secrets"
msgstr "Segreti"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__user_id
msgid ""
"Select a user to run the webhook from behalf of. If not set, the webhook will run as the current user.\n"
"CAREFUL! You must realise and understand what you are doing including all the possible consequences when selecting a specific user"
msgstr "Selezionare un utente per cui eseguire il webhook. Se non impostato, il webhook verrà eseguito per conto dell'utente corrente.ATTENZIONE! Bisogna essere consapevoli e comprendere cosa si sta facendo, comprese tutte le possibili conseguenze quando si seleziona un utente specifico."
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__authenticator_id
msgid "Select an Authenticator used for this webhook"
msgstr "Selezionare un autenticatore per questo webhook"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__method
msgid "Select the HTTP method for this webhook"
msgstr "Selezionare il metodo HTTP per questo webhook"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_method
msgid "Select the HTTP method for this webhook."
msgstr "Selezionare il metodo HTTP per questo webhook."
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_res_config_settings__cetmix_tower_webhook_log_duration
msgid "Set the number of days to keep webhook logs. Old logs will be deleted automatically."
msgstr "Impostare il numero di giorni di mantenimento dei registri webhook."
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.res_config_settings_view_form
msgid ""
"Set the number of days to keep webhook logs. Old logs will be deleted automatically.\n"
" <br/>"
msgstr "Imposta il numero di giorni per cui conservare i registri dei webhook. I vecchi registri verranno eliminati automaticamente. <br/>"
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__skipped
msgid "Skipped"
msgstr "Saltato"
#. module: cetmix_tower_webhook
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__success
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__success
msgid "Success"
msgstr "Successo"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__code
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code
msgid "This field will be rendered using variables"
msgstr "Questo campo verrà visualizzato utilizzando le variabili"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__trusted_proxy_ips
msgid "Trusted Proxy IPs"
msgstr "IP proxy autorizzati"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
msgid "User"
msgstr "Utente"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__user_id
msgid "User as which the webhook code was executed (if set)."
msgstr "Utente per conto del quale è stato eseguito il codice webhook (se impostato)."
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__variable_ids
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__variable_ids
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__variable_ids
msgid "Variables"
msgstr "Variabili"
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_ids
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__webhook_id
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Webhook"
msgstr "Webhook"
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_authenticator
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_authenticator_ids
msgid "Webhook Authenticator"
msgstr "Autenticatore webhook"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_authenticator_ids_count
msgid "Webhook Authenticator Count"
msgstr "Conteggio autenticatore webhook"
#. module: cetmix_tower_webhook
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_authenticator_action
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook_authenticator
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_variable_view_form
msgid "Webhook Authenticators"
msgstr "Autenticatori webhook"
#. module: cetmix_tower_webhook
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_log
msgid "Webhook Call Log"
msgstr "Registro chiamata webhook"
#. module: cetmix_tower_webhook
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook_log
msgid "Webhook Calls"
msgstr "Chiamate webhook"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__code_status
msgid "Webhook Code Status"
msgstr "Stato codice webhook"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_ids_count
msgid "Webhook Count"
msgstr "Conteggio webhook"
#. module: cetmix_tower_webhook
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_log_action
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
msgid "Webhook Logs"
msgstr "Registri webhook"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
#, python-format
msgid "Webhook code execution error: %(error)s"
msgstr "Errore esecuzione codie webhook: %(error)s"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__endpoint
msgid "Webhook endpoint. The complete URL will be <your_tower_url>/cetmix_tower_webhooks/<endpoint>"
msgstr "Endpoint del webhook. L'URL completo sarà <your_tower_url>/cetmix_tower_webhooks/<endpoint>"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__webhook_id
msgid "Webhook that received the call."
msgstr "Webhook che ha ricevuto la chiamata"
#. module: cetmix_tower_webhook
#. odoo-python
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_eval_mixin.py:0
#, python-format
msgid "Webhook/Authenticator code error: result is not a dict"
msgstr "Errore codice webhook/autenticatore: il risultato non è un dizionario"
#. module: cetmix_tower_webhook
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_action
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_variable_view_form
msgid "Webhooks"
msgstr "Webhook"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "YAML"
msgstr "YAML"
#. module: cetmix_tower_webhook
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__yaml_code
msgid "Yaml Code"
msgstr "Codice YAML"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
msgid "You must be a member of the \"YAML/Export\" group to export data as YAML"
msgstr "Bisogna essere membro del gruppo \"YAML/Export\" per esportare i dati come YAML"
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
msgid "You must be a member of the \"YAML/Export\" group to export data as YAML."
msgstr "Bisogna essere membro del gruppo \"YAML/Export\" per esportare i dati come YAML."
#. module: cetmix_tower_webhook
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
msgid "e.g.: 192.168.1.10, 192.168.2.0/24, 2001:db8::/32, 2a00:1450:4001:824::200e"
msgstr "es.: 192.168.1.10, 192.168.2.0/24, 2001:db8::/32, 2a00:1450:4001:824::200e"

View File

@@ -0,0 +1,9 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import cx_tower_webhook_eval_mixin
from . import cx_tower_webhook_authenticator
from . import cx_tower_webhook_log
from . import cx_tower_webhook
from . import cx_tower_variable
from . import res_config_settings

View File

@@ -0,0 +1,79 @@
# flake8: noqa: E501
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _
# Default Python code used in Webhook Authenticator
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE = _(
"""# Please refer to the 'Help' tab and documentation for more information.
#
# You can return authenticator result in the 'result' variable which is a dictionary:
# result = {"allowed": <bool, mandatory, default=False>, "http_code": <int, optional>, "message": <str, optional>}
# default value is {"allowed": False}
"""
)
# Default Python code help used in Webhook Authenticator
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP = _(
"""
<h3>Help for Webhook Authenticator Python Code</h3>
<div style="margin-bottom: 10px;">
<p>
The Python code for the webhook authenticator must return the <code>result</code> variable, which is a dictionary.<br>
<strong>Allowed keys:</strong>
<ul>
<li><code>allowed</code> (<b>bool</b>, required): Authentication result. <code>True</code> if allowed, <code>False</code> otherwise.</li>
<li><code>http_code</code> (<b>int</b>, optional): HTTP status code to return if authentication fails (default is 403).</li>
<li><code>message</code> (<b>str</b>, optional): Error message to show to the client.</li>
</ul>
<strong>Examples:</strong>
<pre style='background:#f7f7f7; padding:6px; border-radius:4px'>
# Allow all requests
result = {"allowed": True}
# Deny with custom code and message
result = {"allowed": False, "http_code": 401, "message": "Unauthorized request"}
</pre>
</p>
<strong>Available variables:</strong>
</div>
"""
)
# Default Python code used in Webhook
DEFAULT_WEBHOOK_CODE = _(
"""# Please refer to the 'Help' tab and documentation for more information.
#
# You can return webhook result in the 'result' variable which is a dictionary:
# result = {"exit_code": <int, default=0>, "message": <string, default=None}
# default value is {"exit_code": 0, "message": None}
"""
)
# Default Python code help used in Webhook
DEFAULT_WEBHOOK_CODE_HELP = _(
"""
<h3>Help for Webhook Python Code</h3>
<div style="margin-bottom: 10px;">
<p>
The webhook Python code must set the <code>result</code> variable, which is a dictionary.<br>
<strong>Allowed keys:</strong>
<ul>
<li><code>exit_code</code> (<b>int</b>, optional, default=0): Exit code (0 means success, other values indicate failure).</li>
<li><code>message</code> (<b>str</b>, optional): Message to return in the HTTP response and log.</li>
</ul>
<strong>Example:</strong>
<pre style='background:#f7f7f7; padding:6px; border-radius:4px'>
# Simple successful result
result = {"exit_code": 0, "message": "Webhook processed successfully"}
# Failure example
result = {"exit_code": 1, "message": "Something went wrong"}
</pre>
</p>
<strong>Available variables:</strong>
</div>
"""
)

View File

@@ -0,0 +1,85 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class CxTowerVariable(models.Model):
_inherit = "cx.tower.variable"
# --- Link to records where the variable is used
webhook_ids = fields.Many2many(
comodel_name="cx.tower.webhook",
relation="cx_tower_webhook_variable_rel",
column1="variable_id",
column2="webhook_id",
copy=False,
)
webhook_ids_count = fields.Integer(
string="Webhook Count", compute="_compute_webhook_ids_count"
)
webhook_authenticator_ids = fields.Many2many(
comodel_name="cx.tower.webhook.authenticator",
relation="cx_tower_webhook_authenticator_variable_rel",
column1="variable_id",
column2="webhook_authenticator_id",
copy=False,
)
webhook_authenticator_ids_count = fields.Integer(
string="Webhook Authenticator Count", compute="_compute_webhook_ids_count"
)
def _compute_webhook_ids_count(self):
"""
Count number of webhooks and webhook authenticators for the variable
"""
for rec in self:
rec.update(
{
"webhook_ids_count": len(rec.webhook_ids),
"webhook_authenticator_ids_count": len(
rec.webhook_authenticator_ids
),
}
)
def action_open_webhooks(self):
"""Open the webhooks where the variable is used"""
self.ensure_one()
action = self.env["ir.actions.act_window"]._for_xml_id(
"cetmix_tower_webhook.cx_tower_webhook_action"
)
action.update(
{
"domain": [("variable_ids", "in", self.ids)],
}
)
return action
def action_open_webhook_authenticators(self):
"""Open the webhook authenticators where the variable is used"""
self.ensure_one()
action = self.env["ir.actions.act_window"]._for_xml_id(
"cetmix_tower_webhook.cx_tower_webhook_authenticator_action"
)
action.update(
{
"domain": [("variable_ids", "in", self.ids)],
}
)
return action
def _get_propagation_field_mapping(self):
"""
Override to add webhook and webhook authenticator
to the propagation field mapping.
"""
res = super()._get_propagation_field_mapping()
res.update(
{
"cx.tower.webhook": ["code"],
"cx.tower.webhook.authenticator": ["code"],
}
)
return res

View File

@@ -0,0 +1,217 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import re
from odoo import SUPERUSER_ID, _, api, fields, models
from odoo.exceptions import ValidationError
from .constants import DEFAULT_WEBHOOK_CODE, DEFAULT_WEBHOOK_CODE_HELP
class CxTowerWebhook(models.Model):
_name = "cx.tower.webhook"
_inherit = [
"cx.tower.webhook.eval.mixin",
]
_description = "Webhook"
active = fields.Boolean(
default=True,
string="Enabled",
)
authenticator_id = fields.Many2one(
comodel_name="cx.tower.webhook.authenticator",
required=True,
help="Select an Authenticator used for this webhook",
)
endpoint = fields.Char(
required=True,
copy=False,
help="Webhook endpoint. The complete URL will be "
"<your_tower_url>/cetmix_tower_webhooks/<endpoint>",
)
full_url = fields.Char(
compute="_compute_full_url",
help="Full URL of the webhook",
)
method = fields.Selection(
[
("post", "POST"),
("get", "GET"),
],
default="post",
required=True,
help="Select the HTTP method for this webhook",
)
content_type = fields.Selection(
[
("json", "JSON"),
("form", "Form URL-Encoded"),
],
string="Payload Type",
default="json",
required=True,
help="How the payload is expected to be sent to this webhook: "
"as JSON body or as URL-encoded form data",
)
user_id = fields.Many2one(
comodel_name="res.users",
string="Run as User",
help="Select a user to run the webhook from behalf of. If not set, "
"the webhook will run as the current user.\n"
"CAREFUL! You must realise and understand what you are doing including "
"all the possible consequences when selecting a specific user",
default=SUPERUSER_ID,
required=True,
copy=False,
)
log_count = fields.Integer(
compute="_compute_log_count",
)
variable_ids = fields.Many2many(
comodel_name="cx.tower.variable",
relation="cx_tower_webhook_variable_rel",
column1="webhook_id",
column2="variable_id",
)
_sql_constraints = [
(
"endpoint_method_uniq",
"unique(endpoint, method)",
"Endpoint and method must be unique!",
),
]
def _compute_log_count(self):
"""Compute log count."""
result = self.env["cx.tower.webhook.log"].read_group(
domain=[("webhook_id", "in", self.ids)],
fields=["webhook_id"],
groupby=["webhook_id"],
)
mapped_data = {r["webhook_id"][0]: r["webhook_id_count"] for r in result}
for rec in self:
rec.log_count = mapped_data.get(rec.id, 0)
@api.depends("endpoint")
def _compute_full_url(self):
"""Compute full URL."""
base_url = (
self.env["ir.config_parameter"]
.sudo()
.get_param("web.base.url", "")
.rstrip("/")
)
for rec in self:
rec.full_url = f"{base_url}/cetmix_tower_webhooks/{rec.endpoint}"
@api.constrains("endpoint")
def _check_endpoint_format(self):
"""Validate endpoint format."""
pattern = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9_/-]*[A-Za-z0-9])?$")
for rec in self:
if rec.endpoint and not pattern.fullmatch(rec.endpoint):
raise ValidationError(
_(
"Endpoint must start and end with a letter or digit, "
"and may contain underscores, dashes, and slashes in between"
)
)
def _default_eval_code(self):
"""
Returns the default code for the webhook.
"""
return DEFAULT_WEBHOOK_CODE
def _get_default_python_eval_code_help(self):
"""
Returns the default code help for the webhook.
"""
return DEFAULT_WEBHOOK_CODE_HELP
def _get_python_eval_odoo_objects(self, **kwargs):
"""
Override to add custom Odoo objects.
"""
res = {
"headers": {
"import": kwargs.get("headers"),
"help": _("Dictionary of request headers"),
},
"payload": {
"import": kwargs.get("payload"),
"help": _(
"Dictionary containing the request payload "
"(JSON for POST, params for GET)"
),
},
}
res.update(super()._get_python_eval_odoo_objects(**kwargs))
return res
def _get_fields_for_yaml(self):
"""Override to add fields to YAML export."""
res = super()._get_fields_for_yaml()
res += [
"name",
"active",
"authenticator_id",
"endpoint",
"method",
"code",
"content_type",
"variable_ids",
"secret_ids",
]
return res
def execute(self, payload=None, raise_on_error=True, **kwargs):
"""
Run the webhook code and return a validated result.
Handles errors and checks result format.
Args:
payload (dict): The webhook payload. If not provided,
the payload will be empty.
raise_on_error (bool): Raise ValidationError on error if True.
**kwargs: Additional keyword arguments.
Returns:
dict: {
'exit_code': <int>,
'message': <str>
}
"""
self.ensure_one()
self_with_user = self.with_user(self.user_id)
payload = payload or {}
try:
result = self_with_user._run_webhook_eval_code(
self_with_user.code,
context_extra={"payload": payload, "headers": kwargs.get("headers")},
default_result={"exit_code": 0, "message": None},
)
except Exception as e:
if raise_on_error:
raise ValidationError(
_("Webhook code execution error: %(error)s", error=e)
) from e
result = {
"exit_code": 1,
"message": str(e),
}
return result
def action_view_logs(self):
"""Open logs related to this webhook."""
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id(
"cetmix_tower_webhook.cx_tower_webhook_log_action"
)
action["domain"] = [("webhook_id", "=", self.id)]
return action

View File

@@ -0,0 +1,381 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import ipaddress
import logging
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.http import request
from .constants import (
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE,
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP,
)
_logger = logging.getLogger(__name__)
class CxTowerWebhookAuthenticator(models.Model):
_name = "cx.tower.webhook.authenticator"
_inherit = [
"cx.tower.webhook.eval.mixin",
]
_description = "Webhook Authenticator"
log_count = fields.Integer(
compute="_compute_log_count",
)
allowed_ip_addresses = fields.Text(
string="Allowed IPs",
help="Comma-separated list of IP addresses and/or subnets "
"(e.g. 192.168.1.10,192.168.2.0/24,10.0.0.1,2001:db8::/32,2a00:1450:4001:824::200e). " # noqa: E501
"Requests from other addresses will be denied.",
)
trusted_proxy_ips = fields.Text(
string="Trusted Proxy IPs",
help="Comma-separated list of trusted proxy IP addresses or CIDR ranges "
"(e.g., 10.0.0.1,192.168.1.0/24). "
"Only these proxies can set X-Forwarded-For headers.",
)
variable_ids = fields.Many2many(
comodel_name="cx.tower.variable",
relation="cx_tower_webhook_authenticator_variable_rel",
column1="webhook_authenticator_id",
column2="variable_id",
)
@api.constrains("trusted_proxy_ips")
def _check_trusted_proxy_ips(self):
"""
Validate 'trusted_proxy_ips' entries. Accepts single IPs and CIDR ranges
(IPv4/IPv6). Empty value is allowed.
"""
for rec in self:
invalid = self._validate_ip_token((rec.trusted_proxy_ips or "").strip())
if invalid:
raise ValidationError(_("Invalid trusted proxy entry: %s") % invalid)
@api.constrains("allowed_ip_addresses")
def _check_allowed_ip_addresses(self):
"""
Validate 'allowed_ip_addresses' entries. Accepts single IPs and CIDR
ranges (IPv4/IPv6). Empty value is allowed (means allow all).
"""
for rec in self:
invalid = self._validate_ip_token((rec.allowed_ip_addresses or "").strip())
if invalid:
raise ValidationError(_("Invalid allowed IP/CIDR entry: %s") % invalid)
def _compute_log_count(self):
"""Compute log count."""
result = self.env["cx.tower.webhook.log"].read_group(
domain=[("authenticator_id", "in", self.ids)],
fields=["authenticator_id"],
groupby=["authenticator_id"],
)
mapped_data = {
r["authenticator_id"][0]: r["authenticator_id_count"] for r in result
}
for rec in self:
rec.log_count = mapped_data.get(rec.id, 0)
def _default_eval_code(self):
"""
Return the default Python code for the webhook authenticator.
Returns:
str: Default authenticator code.
"""
return DEFAULT_WEBHOOK_AUTHENTICATOR_CODE
def _get_default_python_eval_code_help(self):
"""
Return the default help text for the authenticator code.
Returns:
str: Code help description.
"""
return DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP
def _get_python_eval_odoo_objects(self, **kwargs):
"""
Extend the Python evaluation context with custom Odoo objects.
Args:
**kwargs: Extra context values, e.g.:
- "headers": request headers (dict)
- "raw_data": request body (bytes)
- "payload": parsed request payload (dict)
Returns:
dict: Mapping of variables available in evaluation context.
"""
res = {
"headers": {
"import": kwargs.get("headers"),
"help": _("Dictionary of request headers"),
},
"raw_data": {
"import": kwargs.get("raw_data"),
"help": _("Raw body of the request (bytes)"),
},
"payload": {
"import": kwargs.get("payload"),
"help": _(
"Dictionary containing the request payload "
"(JSON for POST, params for GET)"
),
},
}
res.update(super()._get_python_eval_odoo_objects(**kwargs))
return res
def _get_fields_for_yaml(self):
"""
Extend fields available for YAML export.
Returns:
list[str]: List of field names.
"""
res = super()._get_fields_for_yaml()
res += [
"name",
"code",
"allowed_ip_addresses",
"trusted_proxy_ips",
"variable_ids",
"secret_ids",
]
return res
def authenticate(self, raise_on_error=True, **kwargs):
"""
Run the authenticator code and return result.
Args:
raise_on_error (bool): Raise ValidationError on error if True.
kwargs: Additional variables passed to the code context, e.g.:
- "headers": request headers (dict)
- "raw_data": request body (bytes)
- "payload": parsed request payload (dict)
Returns:
dict: {
"allowed": <bool>,
"http_code": <int, optional>,
"message": <str, optional>,
}
"""
self.ensure_one()
try:
result = self._run_webhook_eval_code(
self.code,
context_extra={
"headers": kwargs.get("headers"),
"raw_data": kwargs.get("raw_data"),
"payload": kwargs.get("payload"),
},
default_result={"allowed": False},
)
except Exception as e:
if raise_on_error:
raise ValidationError(_("Authentication code error: %s") % e) from e
result = {
"allowed": False,
"http_code": 500,
"message": str(e),
}
return result
def action_view_logs(self):
"""
Open the action displaying logs related to this authenticator.
Returns:
dict: Action dictionary for `ir.actions.act_window`.
"""
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id(
"cetmix_tower_webhook.cx_tower_webhook_log_action"
)
action["domain"] = [("authenticator_id", "=", self.id)]
return action
def is_ip_allowed(self, remote_addr):
"""
Proxy-aware allowlist check.
Steps:
1) Compute the effective client IP.
2) If 'allowed_ip_addresses' is empty: allow everyone (backward compatible).
3) Otherwise, allow only if the client IP belongs to any network in
'allowed_ip_addresses'.
Args:
remote_addr (str): Immediate TCP peer IP (controller-provided).
Returns:
bool: True if client IP is allowed, False otherwise.
"""
self.ensure_one()
client_ip = self._effective_client_ip(remote_addr)
if not client_ip:
return False
spec = (self.allowed_ip_addresses or "").strip()
if not spec:
return True
allowed_nets = self._parse_ip_list_to_networks(spec)
if not allowed_nets:
# Misconfigured allowlist: fail closed
return False
return any(client_ip in net for net in allowed_nets)
def _effective_client_ip(self, remote_addr):
"""
Compute the effective client IP for the current HTTP request.
Security model:
- The immediate TCP peer is 'remote_addr'
(or request.httprequest.remote_addr).
- X-Forwarded-For / X-Real-IP are honored ONLY if the immediate peer
is within 'trusted_proxy_ips' (single IPs or CIDR ranges).
- If not behind a trusted proxy, headers are ignored to prevent spoofing.
Args:
remote_addr (str): Immediate TCP peer IP passed by the controller.
Returns:
ipaddress.IPv4Address|ipaddress.IPv6Address|None:
Effective client IP or None.
"""
immediate_peer = remote_addr or getattr(
getattr(request, "httprequest", None), "remote_addr", None
)
if not immediate_peer:
return None
try:
immediate_ip = ipaddress.ip_address(immediate_peer)
except (ValueError, TypeError):
return None
client_ip = immediate_ip # default to immediate peer
trusted_nets = self._parse_ip_list_to_networks(
(self.trusted_proxy_ips or "").strip()
)
headers = getattr(getattr(request, "httprequest", None), "headers", {}) or {}
is_trusted_proxy = (
any(immediate_ip in net for net in trusted_nets) if trusted_nets else False
)
if is_trusted_proxy:
candidate = self._extract_ip_from_header(headers.get("X-Forwarded-For"))
if not candidate:
candidate = self._extract_ip_from_header(headers.get("X-Real-IP"))
if candidate:
try:
client_ip = ipaddress.ip_address(candidate)
except ValueError:
# Fall back to immediate peer if candidate is invalid.
_logger.warning("Invalid IP/CIDR entry")
return client_ip
def _extract_ip_from_header(self, header_value):
"""
Extract the first valid IP from a proxy-provided header.
Behavior:
- For X-Forwarded-For, the left-most entry is
considered the original client IP.
- For X-Real-IP, the value itself is considered.
- Any non-IP tokens are skipped.
Args:
header_value (str): Raw header value (may contain commas for XFF).
Returns:
str|None: Compressed IPv4/IPv6 string, or None if nothing valid is found.
"""
if not header_value:
return None
for token in header_value.split(","):
ip_str = token.strip()
if not ip_str:
continue
try:
return ipaddress.ip_address(ip_str).compressed
except ValueError:
continue
return None
@staticmethod
def _parse_ip_list_to_networks(spec):
"""
Convert a CSV of IPs/CIDRs into a list of ip_network objects.
Single IPs are normalized to /32 (IPv4) or /128 (IPv6).
Args:
spec (str): CSV of IPs/CIDRs.
Returns:
list[ipaddress.IPv4Network|ipaddress.IPv6Network]
"""
nets = []
if not spec:
return nets
for part in spec.split(","):
s = (part or "").strip()
if not s:
continue
try:
nets.append(ipaddress.ip_network(s, strict=False))
continue
except ValueError:
_logger.warning(
"Invalid IP/CIDR entry encountered in "
"trusted_proxy_ips configuration."
)
try:
ip = ipaddress.ip_address(s)
nets.append(
ipaddress.ip_network(
ip.exploded + ("/32" if ip.version == 4 else "/128")
)
)
except ValueError:
# Ignore invalid entries silently; validation is handled by constraints.
continue
return nets
def _validate_ip_token(self, spec):
"""
Return the first invalid token from a CSV of IPs/CIDRs,
or None if all valid.
Accepts single IPs and CIDR ranges (IPv4/IPv6).
Empty/whitespace tokens are ignored.
"""
if not spec:
return None
for part in spec.split(","):
s = (part or "").strip()
if not s:
continue
try:
ipaddress.ip_network(s, strict=False)
continue
except ValueError:
_logger.warning("Invalid IP/CIDR entry encountered")
pass
try:
ipaddress.ip_address(s)
except ValueError:
return s
return None

View File

@@ -0,0 +1,201 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.safe_eval import safe_eval
class CxTowerWebhookEvalMixin(models.AbstractModel):
_name = "cx.tower.webhook.eval.mixin"
_inherit = [
"cx.tower.template.mixin",
"cx.tower.key.mixin",
"cx.tower.yaml.mixin",
"cx.tower.reference.mixin",
]
_description = "Eval context/code helper for Cetmix Tower Webhook"
code_help = fields.Html(
compute="_compute_code_help",
default=lambda self: self._default_eval_code_help(),
compute_sudo=True,
)
code = fields.Text(
default=lambda self: self._default_eval_code(),
required=True,
)
@classmethod
def _get_depends_fields(cls):
"""Add code to the depends fields."""
return ["code"]
def _compute_code_help(self):
"""
Compute code help
"""
self.code_help = self._default_eval_code_help()
def _default_eval_code_help(self):
"""
Return the default code help text for webhook or authenticator.
We use default because the computation method for this field
would not be triggered before this record is saved. And we need
to show the value instantly.
Returns:
str: HTML-formatted help string containing available objects and libraries.
"""
available_libraries = self._get_python_eval_odoo_objects()
available_libraries.update(self._get_python_eval_libraries())
help_text_fragments = []
for key, value in available_libraries.items():
if key == "server":
# Server is not available in the webhook/authenticator eval context
continue
help_text_fragments.append(f"<li><code>{key}</code>: {value['help']}</li>")
help_text = "<ul>" + "".join(help_text_fragments) + "</ul>"
return f"{self._get_default_python_eval_code_help()}{help_text}"
def _get_python_eval_odoo_objects(self, **kwargs):
"""
Return Odoo objects available in the eval context.
Args:
**kwargs: Optional context values.
Returns:
dict: Mapping of object names to their import values and help.
"""
return self.env["cx.tower.command"]._get_python_command_odoo_objects()
def _get_python_eval_libraries(self):
"""
Return Python libraries available in the eval context.
Returns:
dict: Mapping of library names to their import values and help.
"""
return self.env["cx.tower.command"]._get_python_command_libraries()
def _get_default_python_eval_code_help(self):
"""
Return the default help text for eval code.
Returns:
str: Help text.
"""
return ""
def _default_eval_code(self):
"""
Return the default code for webhook or authenticator.
Returns:
str: Default Python code.
"""
return ""
def _prepare_webhook_eval_context(self, context_extra=None, default_result=None):
"""
Build the evaluation context for webhook or authenticator
safe_eval.
Args:
context_extra (dict): Additional context variables
(payload, headers, etc).
default_result (dict): Default value for the 'result' variable.
Returns:
dict: Prepared eval context.
"""
context_extra = context_extra or {}
# Get the Odoo objects first
imports = self._get_python_eval_odoo_objects(**context_extra)
# Update with the libraries
imports.update(self._get_python_eval_libraries())
eval_context = {key: value["import"] for key, value in imports.items()}
# Remove server from eval context
eval_context.pop("server", None)
# Set default result
default_result = default_result or {}
eval_context["result"] = default_result.copy()
return eval_context
def _run_webhook_eval_code(self, code, **kwargs):
"""
Helper to execute user code safely. Returns the 'result' variable from context.
Args:
code (str): User code to run
kwargs:
key (dict): Extra keys for secret parser
context_extra (dict): Extra context variables (payload, headers, etc)
default_result (dict): Default value for the 'result' variable
Returns:
dict: The 'result' variable from context
"""
eval_context = self._prepare_webhook_eval_context(**kwargs)
if not code:
# if code is empty, return the default result
return eval_context["result"]
# prepare the code for evaluation
code_and_secrets = self.env["cx.tower.key"]._parse_code_and_return_key_values(
code, pythonic_mode=True, **kwargs.get("key", {})
)
secrets = code_and_secrets.get("key_values")
webhook_code = code_and_secrets["code"]
code = self.env["cx.tower.key"]._parse_code(
webhook_code, pythonic_mode=True, **kwargs.get("key", {})
)
# execute user code
safe_eval(
code,
eval_context,
mode="exec",
nocopy=True,
)
result = eval_context["result"]
return self._parse_eval_code_result(result, secrets=secrets, **kwargs)
def _parse_eval_code_result(self, result, secrets=None, **kwargs):
"""
Post-processes the result returned from webhook/authenticator eval code.
If 'secrets' are provided, all occurrences of secret values in the
'message' field of result will be replaced with a spoiler string to
prevent sensitive information leakage.
Args:
result (dict): The dict returned from the executed eval code,
expected to have at least a 'message' key.
secrets (dict, optional): A mapping of secret key-value
pairs used for replacement in 'message'.
Returns:
dict: The processed result with secrets masked in the 'message'
field, if applicable.
"""
if not isinstance(result, dict):
raise ValidationError(
_("Webhook/Authenticator code error: result is not a dict")
)
if secrets and isinstance(result.get("message"), str):
result["message"] = self.env["cx.tower.key"]._replace_with_spoiler(
result["message"], secrets
)
return result

View File

@@ -0,0 +1,195 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from datetime import timedelta
from odoo import api, fields, models
from odoo.http import request
class CxTowerWebhookLog(models.Model):
_name = "cx.tower.webhook.log"
_description = "Webhook Call Log"
_order = "create_date desc"
_rec_name = "display_name"
webhook_id = fields.Many2one(
comodel_name="cx.tower.webhook",
ondelete="cascade",
index=True,
help="Webhook that received the call.",
)
endpoint = fields.Char(
readonly=True,
)
authenticator_id = fields.Many2one(
comodel_name="cx.tower.webhook.authenticator",
readonly=True,
)
request_method = fields.Selection(
[
("post", "POST"),
("get", "GET"),
],
default="post",
required=True,
help="Select the HTTP method for this webhook.",
)
request_headers = fields.Text(
help="Headers of the received HTTP request (JSON-encoded).",
)
request_payload = fields.Text(
help="Payload/body of the received HTTP request (JSON-encoded).",
)
authentication_status = fields.Selection(
[
("success", "Success"),
("failed", "Failed"),
("not_required", "Not Required"),
],
required=True,
default="failed",
help="Result of authentication for this webhook call.",
)
code_status = fields.Selection(
[
("success", "Success"),
("failed", "Failed"),
("skipped", "Skipped"),
],
string="Webhook Code Status",
required=True,
default="skipped",
help="Result of webhook code execution.",
)
http_status = fields.Integer(
string="HTTP Status",
help="HTTP status code returned to the client.",
)
result_message = fields.Text(
help="Message returned by the webhook code or authenticator (if any).",
)
error_message = fields.Text(
help="Error message in case of authentication or code failure.",
)
user_id = fields.Many2one(
comodel_name="res.users",
string="Run as User",
help="User as which the webhook code was executed (if set).",
)
ip_address = fields.Char(
string="IP Address",
help="IP address of the client that made the request.",
)
country_id = fields.Many2one(
comodel_name="res.country",
help="Country of the client that made the request.",
)
display_name = fields.Char(
compute="_compute_display_name",
store=True,
readonly=True,
)
@api.depends("webhook_id", "endpoint", "http_status")
def _compute_display_name(self):
"""Compute display name."""
for rec in self:
rec.display_name = (
f"{rec.webhook_id.display_name or ''} ({rec.endpoint}) "
f"[{rec.http_status or ''}]"
)
@api.model
def _get_country_id(self):
"""
Return the country ID of the client based on geoip information.
Returns:
int | bool: Country ID if found, otherwise False.
"""
country_code = None
if request and hasattr(request, "geoip") and request.geoip:
country_code = request.geoip.get("country_code")
if country_code:
country = (
self.env["res.country"]
.sudo()
.search([("code", "=", country_code)], limit=1)
)
if country:
return country.id
return False
@api.model
def _get_ip_address(self):
"""
Return the IP address of the client making the request.
Returns:
str | None: IP address string, or None if unavailable.
"""
if not request:
return None
# Check for forwarded IP (common proxy headers)
forwarded_for = request.httprequest.headers.get("X-Forwarded-For")
if forwarded_for:
# Return the first IP in the chain
return forwarded_for.split(",")[0].strip()
return request.httprequest.remote_addr
@api.model
def create_from_call(self, **kwargs):
"""
Create a log entry from webhook call parameters.
Args:
**kwargs: Values passed to `_prepare_values`.
Returns:
CxTowerWebhookLog: Newly created log record.
"""
values = self._prepare_values(**kwargs)
return self.create(values)
@api.model
def _prepare_values(self, webhook=None, **kwargs):
"""
Prepare values for creating a webhook log record.
Args:
webhook (RecordSet, optional): Webhook record.
**kwargs: Additional fields such as endpoint, request_method, etc.
Returns:
dict: Prepared values for log creation.
"""
vals = {
"webhook_id": webhook.id if webhook else None,
"endpoint": webhook.endpoint if webhook else kwargs.get("endpoint"),
"authenticator_id": webhook.authenticator_id.id if webhook else None,
"request_method": webhook.method
if webhook
else kwargs.get("request_method"),
"user_id": webhook.user_id.id if webhook else None,
"ip_address": self._get_ip_address(),
"country_id": self._get_country_id(),
**kwargs,
}
return vals
@api.autovacuum
def _gc_delete_old_logs(self):
"""
Remove old webhook log records beyond configured retention period.
This method is automatically triggered by Odoo's autovacuum.
"""
days = int(
self.env["ir.config_parameter"]
.sudo()
.get_param("cetmix_tower_webhook.webhook_log_duration", 30)
)
cutoff = fields.Datetime.now() - timedelta(days=days)
logs_to_delete = self.sudo().search([("create_date", "<", cutoff)])
logs_to_delete.unlink()

View File

@@ -0,0 +1,13 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
cetmix_tower_webhook_log_duration = fields.Integer(
string="Keep Webhook Logs for (days)",
help="Set the number of days to keep webhook logs. "
"Old logs will be deleted automatically.",
default=30,
config_parameter="cetmix_tower_webhook.webhook_log_duration",
)

View File

@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"

View File

@@ -0,0 +1,58 @@
## Configure an Authenticator
**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to configure authenticators.**
- Go to "Cetmix Tower > Settings > Automation > Webhook Authenticators" and click "New".
**Complete the following fields:**
- Name. Authenticator name
- Reference. Unique reference. Leave this field blank to auto generate it
- Code. Code that is used to authenticate the request. You can use all Cetmix Tower - Python command variables except for the server plus the following webhook specific ones:
- headers: dictionary that contains the request headers
- raw_data: string with the raw HTTP request body
- payload: dictionary that contains the JSON payload or the GET parameters of the request
**The code returns the result variable in the following format:**
```python
result = {"allowed": <bool, mandatory, default=False>, "http_code": <int, optional>, "message": <str, optional>}
```
eg:
```python
result = {"allowed": True}
result = {"allowed": False, "http_code": 403, "message": "Sorry..."}
```
## Configure a Webhook
**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to configure webhooks.**
- Go to "Cetmix Tower > Settings > Automation > Webhooks" and click "New".
**Complete the following fields:**
- Enabled. Uncheck this field to disable the webhook without deleting it
- Name. Authenticator name
- Reference. Unique reference. Leave this field blank to auto generate it
- Authenticator. Select an Authenticator used for this webhook
- Endpoint. Webhook andpoint. The complete webhook URL will be <your_tower_url>/cetmix_tower_webhooks/<endpoint>
- Run as User. Select a user to run the webhook on behalf of. CAREFUL! You must realize and understand what you are doing, including all the possible consequences when selecting a specific user.
- Code. Code that processes the request. You can use all Cetmix Tower Python command variables (except for the server) plus the following webhook-specific one:
- headers: dictionary that contains the request headers
- payload: dictionary that contains the JSON payload or the GET parameters of the request
Webhook code returns a result using the Cetmix Tower Python command pattern:
```python
result = {"exit_code": <int, default=0>, "message": <string, default=None}
```
**To configure the time for which the webhook call logs are stored:**
- Go to "Cetmix Tower > Settings > General Settings"
- Put a number of days into the "Keep Webhook Logs for (days)" field. Default value is 30.
Please refer to the [official documentation](https://tower.cetmix.com) for detailed configuration instructions.

View File

@@ -0,0 +1,2 @@
Although Odoo has native support of webhooks staring 17.0, they still have some limitations.
Another option is the OCA 'endpoint' module which although is more flexible still makes it usable with Cetmix Tower more complicated.

View File

@@ -0,0 +1,5 @@
This module implements incoming webhooks for [Cetmix Tower](https://tower.cetmix.com). Webhooks are authorised using customisable authenticators which can be pre-configured and reused across multiple webhooks. Webhooks and authenticators can be exported and imported using YAML format, which makes them easily sharable.
This module is a part of Cetmix Tower, however it can be used to manage any other odoo applications.
Please refer to the [official documentation](https://tower.cetmix.com) for detailed information.

View File

@@ -0,0 +1,13 @@
## 16.0.1.0.4 (2025-12-11)
- Features: Improve search views, implement the search panel for selected views. (5139)
## 16.0.1.0.3 (2025-10-21)
- Features: Use native functions to convert payload to dict (5024)
## 16.0.1.0.2 (2025-10-06)
- Bugfixes: Export related variables and secrets (4980)

View File

@@ -0,0 +1,3 @@
When a request is received, Cetmix Tower will search for the webhook with the matching endpoint and authenticate the request using the selected authenticator. In case of successful authentication webhook code is run. Each webhook call is logged. Logs are available under the "Cetmix Tower > Logs > Webhook Calls" menu or under the "Logs" button directly in the Webhook.
Please refer to the [official documentation](https://tower.cetmix.com) for detailed usage instructions.

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_cx_tower_webhook,Tower Webhook,model_cx_tower_webhook,cetmix_tower_server.group_root,1,1,1,1
access_cx_tower_webhook_authenticator,Tower Webhook Authenticator,model_cx_tower_webhook_authenticator,cetmix_tower_server.group_root,1,1,1,1
access_cx_tower_webhook_log,Tower Webhook Log,model_cx_tower_webhook_log,cetmix_tower_server.group_root,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_cx_tower_webhook Tower Webhook model_cx_tower_webhook cetmix_tower_server.group_root 1 1 1 1
3 access_cx_tower_webhook_authenticator Tower Webhook Authenticator model_cx_tower_webhook_authenticator cetmix_tower_server.group_root 1 1 1 1
4 access_cx_tower_webhook_log Tower Webhook Log model_cx_tower_webhook_log cetmix_tower_server.group_root 1 0 0 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,549 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Cetmix Tower Webhook</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="cetmix-tower-webhook">
<h1 class="title">Cetmix Tower Webhook</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:6b28bb3bec0ce3e160c08d87fdf2735a4ca2fc271dbf3e361152240f0f02437c
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_webhook"><img alt="cetmix/cetmix-tower" src="https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github" /></a></p>
<p>This module implements incoming webhooks for <a class="reference external" href="https://tower.cetmix.com">Cetmix
Tower</a>. Webhooks are authorised using
customisable authenticators which can be pre-configured and reused
across multiple webhooks. Webhooks and authenticators can be exported
and imported using YAML format, which makes them easily sharable.</p>
<p>This module is a part of Cetmix Tower, however it can be used to manage
any other odoo applications.</p>
<p>Please refer to the <a class="reference external" href="https://tower.cetmix.com">official
documentation</a> for detailed information.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#use-cases-context" id="toc-entry-1">Use Cases / Context</a></li>
<li><a class="reference internal" href="#configuration" id="toc-entry-2">Configuration</a><ul>
<li><a class="reference internal" href="#configure-an-authenticator" id="toc-entry-3">Configure an Authenticator</a></li>
<li><a class="reference internal" href="#configure-a-webhook" id="toc-entry-4">Configure a Webhook</a></li>
</ul>
</li>
<li><a class="reference internal" href="#usage" id="toc-entry-5">Usage</a></li>
<li><a class="reference internal" href="#changelog" id="toc-entry-6">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-7">16.0.1.0.4 (2025-12-11)</a></li>
<li><a class="reference internal" href="#section-2" id="toc-entry-8">16.0.1.0.3 (2025-10-21)</a></li>
<li><a class="reference internal" href="#section-3" id="toc-entry-9">16.0.1.0.2 (2025-10-06)</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-10">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-11">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-12">Authors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-13">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="use-cases-context">
<h1><a class="toc-backref" href="#toc-entry-1">Use Cases / Context</a></h1>
<p>Although Odoo has native support of webhooks staring 17.0, they still
have some limitations. Another option is the OCA endpoint module which
although is more flexible still makes it usable with Cetmix Tower more
complicated.</p>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#toc-entry-2">Configuration</a></h1>
<div class="section" id="configure-an-authenticator">
<h2><a class="toc-backref" href="#toc-entry-3">Configure an Authenticator</a></h2>
<p><strong>⚠️ WARNING: You must be a member of the “Cetmix Tower/Root” group to
configure authenticators.</strong></p>
<ul class="simple">
<li>Go to “Cetmix Tower &gt; Settings &gt; Automation &gt; Webhook Authenticators”
and click “New”.</li>
</ul>
<p><strong>Complete the following fields:</strong></p>
<ul class="simple">
<li>Name. Authenticator name</li>
<li>Reference. Unique reference. Leave this field blank to auto generate
it</li>
<li>Code. Code that is used to authenticate the request. You can use all
Cetmix Tower - Python command variables except for the server plus the
following webhook specific ones:</li>
<li>headers: dictionary that contains the request headers</li>
<li>raw_data: string with the raw HTTP request body</li>
<li>payload: dictionary that contains the JSON payload or the GET
parameters of the request</li>
</ul>
<p><strong>The code returns the result variable in the following format:</strong></p>
<pre class="code python literal-block">
<span class="n">result</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&quot;allowed&quot;</span><span class="p">:</span> <span class="o">&lt;</span><span class="nb">bool</span><span class="p">,</span> <span class="n">mandatory</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="kc">False</span><span class="o">&gt;</span><span class="p">,</span> <span class="s2">&quot;http_code&quot;</span><span class="p">:</span> <span class="o">&lt;</span><span class="nb">int</span><span class="p">,</span> <span class="n">optional</span><span class="o">&gt;</span><span class="p">,</span> <span class="s2">&quot;message&quot;</span><span class="p">:</span> <span class="o">&lt;</span><span class="nb">str</span><span class="p">,</span> <span class="n">optional</span><span class="o">&gt;</span><span class="p">}</span>
</pre>
<p>eg:</p>
<pre class="code python literal-block">
<span class="n">result</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&quot;allowed&quot;</span><span class="p">:</span> <span class="kc">True</span><span class="p">}</span><span class="w">
</span><span class="n">result</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&quot;allowed&quot;</span><span class="p">:</span> <span class="kc">False</span><span class="p">,</span> <span class="s2">&quot;http_code&quot;</span><span class="p">:</span> <span class="mi">403</span><span class="p">,</span> <span class="s2">&quot;message&quot;</span><span class="p">:</span> <span class="s2">&quot;Sorry...&quot;</span><span class="p">}</span>
</pre>
</div>
<div class="section" id="configure-a-webhook">
<h2><a class="toc-backref" href="#toc-entry-4">Configure a Webhook</a></h2>
<p><strong>⚠️ WARNING: You must be a member of the “Cetmix Tower/Root” group to
configure webhooks.</strong></p>
<ul class="simple">
<li>Go to “Cetmix Tower &gt; Settings &gt; Automation &gt; Webhooks” and click
“New”.</li>
</ul>
<p><strong>Complete the following fields:</strong></p>
<ul class="simple">
<li>Enabled. Uncheck this field to disable the webhook without deleting it</li>
<li>Name. Authenticator name</li>
<li>Reference. Unique reference. Leave this field blank to auto generate
it</li>
<li>Authenticator. Select an Authenticator used for this webhook</li>
<li>Endpoint. Webhook andpoint. The complete webhook URL will be
&lt;your_tower_url&gt;/cetmix_tower_webhooks/</li>
<li>Run as User. Select a user to run the webhook on behalf of. CAREFUL!
You must realize and understand what you are doing, including all the
possible consequences when selecting a specific user.</li>
<li>Code. Code that processes the request. You can use all Cetmix Tower
Python command variables (except for the server) plus the following
webhook-specific one:<ul>
<li>headers: dictionary that contains the request headers</li>
<li>payload: dictionary that contains the JSON payload or the GET
parameters of the request</li>
</ul>
</li>
</ul>
<p>Webhook code returns a result using the Cetmix Tower Python command
pattern:</p>
<pre class="code python literal-block">
<span class="n">result</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&quot;exit_code&quot;</span><span class="p">:</span> <span class="o">&lt;</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="o">&gt;</span><span class="p">,</span> <span class="s2">&quot;message&quot;</span><span class="p">:</span> <span class="o">&lt;</span><span class="n">string</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="kc">None</span><span class="p">}</span>
</pre>
<p><strong>To configure the time for which the webhook call logs are stored:</strong></p>
<ul class="simple">
<li>Go to “Cetmix Tower &gt; Settings &gt; General Settings”</li>
<li>Put a number of days into the “Keep Webhook Logs for (days)” field.
Default value is 30.</li>
</ul>
<p>Please refer to the <a class="reference external" href="https://tower.cetmix.com">official
documentation</a> for detailed configuration
instructions.</p>
</div>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-5">Usage</a></h1>
<p>When a request is received, Cetmix Tower will search for the webhook
with the matching endpoint and authenticate the request using the
selected authenticator. In case of successful authentication webhook
code is run. Each webhook call is logged. Logs are available under the
“Cetmix Tower &gt; Logs &gt; Webhook Calls” menu or under the “Logs” button
directly in the Webhook.</p>
<p>Please refer to the <a class="reference external" href="https://tower.cetmix.com">official
documentation</a> for detailed usage
instructions.</p>
</div>
<div class="section" id="changelog">
<h1><a class="toc-backref" href="#toc-entry-6">Changelog</a></h1>
<div class="section" id="section-1">
<h2><a class="toc-backref" href="#toc-entry-7">16.0.1.0.4 (2025-12-11)</a></h2>
<ul class="simple">
<li>Features: Improve search views, implement the search panel for
selected views. (5139)</li>
</ul>
</div>
<div class="section" id="section-2">
<h2><a class="toc-backref" href="#toc-entry-8">16.0.1.0.3 (2025-10-21)</a></h2>
<ul class="simple">
<li>Features: Use native functions to convert payload to dict (5024)</li>
</ul>
</div>
<div class="section" id="section-3">
<h2><a class="toc-backref" href="#toc-entry-9">16.0.1.0.2 (2025-10-06)</a></h2>
<ul class="simple">
<li>Bugfixes: Export related variables and secrets (4980)</li>
</ul>
</div>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-10">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cetmix_tower_webhook%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-11">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-12">Authors</a></h2>
<ul class="simple">
<li>Cetmix</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-13">Maintainers</a></h2>
<p>This module is part of the <a class="reference external" href="https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_webhook">cetmix/cetmix-tower</a> project on GitHub.</p>
<p>You are welcome to contribute.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,7 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import test_cx_tower_webhook_authenticator
from . import test_cx_tower_webhook_log
from . import test_cx_tower_webhook
from . import test_webhook_controller

View File

@@ -0,0 +1,38 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.tests import TransactionCase
class CetmixTowerWebhookCommon(TransactionCase):
def setUp(self):
super().setUp()
# Set base url for correct link generation
self.web_base_url = "https://example.com"
self.env["ir.config_parameter"].sudo().set_param(
"web.base.url", self.web_base_url
)
# Create simple authenticator that allows all requests
self.WebhookAuthenticator = self.env["cx.tower.webhook.authenticator"]
self.simple_authenticator = self.WebhookAuthenticator.create(
{
"name": "Simple Authenticator",
"code": "result = {'allowed': True, 'message': 'OK'}",
}
)
# Create Simple Webhook
self.Webhook = self.env["cx.tower.webhook"]
self.simple_webhook = self.Webhook.create(
{
"name": "Simple Webhook",
"endpoint": "simple_webhook",
"code": "result = {'exit_code': 0, 'message': 'OK'}",
"authenticator_id": self.simple_authenticator.id,
}
)
# Log model
self.Log = self.env["cx.tower.webhook.log"]

View File

@@ -0,0 +1,154 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.exceptions import ValidationError
from .common import CetmixTowerWebhookCommon
class TestCetmixTowerWebhook(CetmixTowerWebhookCommon):
def test_simple_webhook_success(self):
"""
Test that webhook is successful
"""
result = self.simple_webhook.execute(
headers={}, payload={}, raw_data="", raise_on_error=False
)
self.assertEqual(result["exit_code"], 0)
def test_simple_webhook_without_optional_params(self):
"""
Test that webhook is successful without optional params
"""
result = self.simple_webhook.execute(raise_on_error=False)
self.assertEqual(result["exit_code"], 0)
def test_webhook_code_custom_message(self):
"""
Test that custom message is returned from webhook code
"""
self.simple_webhook.write(
{"code": "result = {'exit_code': 0, 'message': 'Webhook OK!'}"}
)
result = self.simple_webhook.execute(raise_on_error=False)
self.assertEqual(result["exit_code"], 0)
self.assertEqual(result["message"], "Webhook OK!")
def test_webhook_code_failure(self):
"""
Test that webhook returns error when code sets exit_code != 0
"""
self.simple_webhook.write(
{"code": "result = {'exit_code': 42, 'message': 'Error occurred'}"}
)
result = self.simple_webhook.execute(raise_on_error=False)
self.assertEqual(result["exit_code"], 42)
self.assertEqual(result["message"], "Error occurred")
def test_webhook_code_raises_exception(self):
"""
Test that exception in webhook code is handled and returns exit_code 1
"""
self.simple_webhook.write({"code": "raise Exception('Webhook boom!')"})
result = self.simple_webhook.execute(raise_on_error=False)
self.assertEqual(result["exit_code"], 1)
self.assertIn("Webhook boom!", result["message"])
def test_webhook_code_returns_non_dict(self):
"""
Test that webhook fails gracefully if code returns non-dict
"""
self.simple_webhook.write({"code": "result = 'not a dict'"})
result = self.simple_webhook.execute(raise_on_error=False)
self.assertEqual(result["exit_code"], 1)
self.assertEqual(
result["message"], "Webhook/Authenticator code error: result is not a dict"
)
def test_webhook_execute_raises_exception(self):
"""
Test that webhook raises ValidationError if raise_on_error is True
"""
self.simple_webhook.write({"code": "raise Exception('Validation failed!')"})
with self.assertRaises(ValidationError):
self.simple_webhook.execute(raise_on_error=True)
def test_webhook_execute_with_payload(self):
"""
Test that webhook receives and processes payload correctly
"""
self.simple_webhook.write(
{
"code": "result = {'exit_code': 0, 'message': str(payload.get('key', 'none'))}" # noqa: E501
}
)
payload = {"key": "value123"}
result = self.simple_webhook.execute(payload=payload, raise_on_error=False)
self.assertEqual(result["exit_code"], 0)
self.assertEqual(result["message"], "value123")
def test_webhook_execute_with_user(self):
"""
Test that webhook executes as specified user
"""
test_user = self.env.ref("base.user_demo")
self.simple_webhook.user_id = test_user
self.simple_webhook.write(
{"code": "result = {'exit_code': 0, 'message': user.login}"}
)
result = self.simple_webhook.execute(raise_on_error=False)
self.assertEqual(result["message"], test_user.login)
def test_webhook_context_isolation(self):
"""
Test that only payload is available in eval context;
extra kwargs are not accessible
"""
self.simple_webhook.write(
{
"code": (
"fail = []\n"
"for var in ['headers', 'raw_data', 'custom_param']:\n"
" try:\n"
" _ = eval(var)\n"
" fail.append(var)\n"
" except Exception:\n"
" pass\n"
"if fail:\n"
" result = {'exit_code': 99, 'message': 'Leaked vars: ' + ','.join(fail)}\n" # noqa: E501
"else:\n"
" result = {'exit_code': 0, 'message': 'Context clean'}\n"
)
}
)
result = self.simple_webhook.execute(
payload={"key": "val"},
headers={"x": "y"},
raw_data="boom",
custom_param="xxx",
raise_on_error=False,
)
self.assertEqual(result["exit_code"], 0, result["message"])
self.assertIn("Context clean", result["message"])
def test_webhook_execute_runs_as_user_id(self):
"""
Test that the webhook code is always executed as the specified user_id,
regardless of the caller's user context or extra kwargs.
"""
# set specific user
test_user = self.env.ref("base.user_demo")
self.simple_webhook.user_id = test_user
self.simple_webhook.write(
{"code": "result = {'exit_code': 0, 'message': user.login}"}
)
# run execute() with another user and try to pass user_id via kwargs
other_user = self.env.ref("base.user_admin")
result = self.simple_webhook.with_user(other_user).execute(
payload={},
user_id=self.env.ref("base.user_root").id, # try to pass own user_id
raise_on_error=False,
)
# the result should be from user_demo anyway
self.assertEqual(result["message"], test_user.login)

View File

@@ -0,0 +1,143 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.exceptions import ValidationError
from .common import CetmixTowerWebhookCommon
class TestCetmixTowerWebhookAuthenticator(CetmixTowerWebhookCommon):
def test_simple_authentication_success(self):
"""
Test that authentication is successful
"""
# check that authentication is successful for authenticator
# that allows all requests
result = self.simple_authenticator.authenticate(
headers={}, payload={}, raw_data=""
)
self.assertTrue(result["allowed"])
def test_simple_authentication_without_optional_params(self):
"""
Test that authentication is successful without optional params
"""
result = self.simple_authenticator.authenticate()
self.assertTrue(result["allowed"])
def test_token_authentication_success(self):
"""
Test that authentication is successful for authenticator that allows requests
with specific token in header
"""
auth_token_header = "X-Token"
auth_token = "secret123"
code = f"result = {{'allowed': headers.get('{auth_token_header}') == '{auth_token}'}}" # noqa: E501
self.simple_authenticator.code = code
result = self.simple_authenticator.authenticate(
headers={auth_token_header: auth_token}
)
self.assertTrue(result["allowed"])
def test_token_authentication_failure(self):
"""
Test that authentication is failed for authenticator that allows
requests with specific token in header
"""
auth_token_header = "X-Token"
auth_token = "secret123"
code = f"result = {{'allowed': headers.get('{auth_token_header}') == '{auth_token}'}}" # noqa: E501
self.simple_authenticator.code = code
result = self.simple_authenticator.authenticate(
headers={auth_token_header: "wrong_token"}, raise_on_error=False
)
self.assertFalse(result["allowed"])
def test_token_authentication_failure_without_optional_params(self):
"""
Test that authentication is failed without optional params
"""
auth_token_header = "X-Token"
auth_token = "secret123"
code = f"result = {{'allowed': headers.get('{auth_token_header}') == '{auth_token}'}}" # noqa: E501
self.simple_authenticator.code = code
result = self.simple_authenticator.authenticate(raise_on_error=False)
self.assertFalse(result["allowed"])
self.assertEqual(result["http_code"], 500)
self.assertIn("object has no attribute 'get'", result["message"])
def test_authentication_code_error(self):
"""
Test that authentication is failed with invalid code
"""
self.simple_authenticator.code = "1/0"
result = self.simple_authenticator.authenticate(raise_on_error=False)
self.assertFalse(result["allowed"])
self.assertEqual(result["http_code"], 500)
self.assertEqual(result["message"], "division by zero")
# test with raise_on_error=True
with self.assertRaises(ValidationError) as e:
self.simple_authenticator.authenticate()
self.assertEqual(
str(e.exception), "Authentication code error: division by zero"
)
def test_authenticator_custom_http_code_and_message(self):
"""
Test that custom http_code and message returned from code are respected
"""
message = "I am a teapot!"
self.simple_authenticator.code = (
f"result = {{'allowed': False, 'http_code': 418, 'message': '{message}'}}"
)
result = self.simple_authenticator.authenticate(headers={})
self.assertFalse(result["allowed"])
self.assertEqual(result.get("http_code"), 418)
self.assertEqual(result.get("message"), message)
def test_authenticator_returns_non_dict(self):
"""
Test that authentication fails if code returns non-dict result
"""
self.simple_authenticator.write({"code": "result = 'not a dict'"})
result = self.simple_authenticator.authenticate(
headers={}, raise_on_error=False
)
self.assertFalse(result["allowed"])
self.assertEqual(result["http_code"], 500)
self.assertIn("result is not a dict", result["message"])
def test_authentication_with_raw_data(self):
"""
Test that authentication works with raw_data and without headers
"""
self.simple_authenticator.write(
{"code": "result = {'allowed': raw_data == 'magic'}"}
)
result = self.simple_authenticator.authenticate(raw_data="magic")
self.assertTrue(result["allowed"])
result = self.simple_authenticator.authenticate(raw_data="not_magic")
self.assertFalse(result["allowed"])
def test_authentication_code_exception(self):
"""
Test that authentication code exception is captured in result['message']
"""
self.simple_authenticator.write({"code": "raise Exception('custom failure')"})
result = self.simple_authenticator.authenticate(
headers={}, raise_on_error=False
)
self.assertFalse(result["allowed"])
self.assertEqual(result["http_code"], 500)
self.assertIn("custom failure", result["message"])
def test_authentication_minimal_false(self):
"""
Test minimal code with only allowed: False
"""
self.simple_authenticator.write({"code": "result = {'allowed': False}"})
result = self.simple_authenticator.authenticate(headers={})
self.assertFalse(result["allowed"])
self.assertIsNone(result.get("http_code"))
self.assertIsNone(result.get("message"))

View File

@@ -0,0 +1,68 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import json
from datetime import datetime, timedelta
from .common import CetmixTowerWebhookCommon
class TestCetmixTowerWebhookLog(CetmixTowerWebhookCommon):
def test_create_log_from_call(self):
"""Test creating a log entry via create_from_call()."""
vals = {
"result_message": "Manual log",
"http_status": 201,
"authentication_status": "success",
"code_status": "success",
"request_payload": json.dumps({"foo": "bar"}),
"request_headers": json.dumps({"X-Test": "test"}),
"webhook_id": self.simple_webhook.id,
}
log = self.Log.create_from_call(webhook=self.simple_webhook, **vals)
self.assertEqual(log.webhook_id, self.simple_webhook)
self.assertEqual(log.result_message, "Manual log")
self.assertEqual(log.http_status, 201)
self.assertEqual(log.authentication_status, "success")
self.assertIn("foo", log.request_payload)
self.assertIn("X-Test", log.request_headers)
def test_gc_delete_old_logs(self):
"""Test auto-removal of old logs via _gc_delete_old_logs()."""
# Create an "old" log
old_log = self.Log.create_from_call(
webhook=self.simple_webhook,
authentication_status="success",
code_status="success",
http_status=200,
)
# Set create_date in the past (we cannot use write
# because the create_date is MAGIC Field)
past_date = (datetime.now() - timedelta(days=100)).strftime("%Y-%m-%d %H:%M:%S")
self.env.cr.execute(
"UPDATE cx_tower_webhook_log SET create_date = %s WHERE id = %s",
(past_date, old_log.id),
)
self.env.invalidate_all()
# Create a new log
new_log = self.Log.create_from_call(
webhook=self.simple_webhook,
authentication_status="success",
code_status="success",
http_status=200,
)
# Set log duration to 30 days
self.env["ir.config_parameter"].sudo().set_param(
"cetmix_tower_webhook.webhook_log_duration", 30
)
# Enter test mode to run the autovacuum cron because
# `_run_vacuum_cleaner` makes a commit
self.registry.enter_test_mode(self.cr)
self.addCleanup(self.registry.leave_test_mode)
env = self.env(cr=self.registry.cursor())
# Run the autovacuum cron
env.ref("base.autovacuum_job").method_direct_trigger()
self.assertFalse(self.Log.browse(old_log.id).exists())
self.assertTrue(self.Log.browse(new_log.id).exists())

View File

@@ -0,0 +1,608 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import json
from unittest.mock import patch
from odoo.tests import HttpCase, tagged
@tagged("-at_install", "post_install")
class TestCxTowerWebhookController(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
env = cls.env
# Authenticator that always allows requests
cls.authenticator = env["cx.tower.webhook.authenticator"].create(
{"name": "Always OK", "code": "result = {'allowed': True}"}
)
# POST webhook
cls.webhook_post = env["cx.tower.webhook"].create(
{
"name": "Test Webhook POST",
"endpoint": "webhook_post",
"method": "post",
"authenticator_id": cls.authenticator.id,
"code": "result = {'exit_code': 0, 'message': 'POST ok'}",
}
)
# GET webhook
cls.webhook_get = env["cx.tower.webhook"].create(
{
"name": "Test Webhook GET",
"endpoint": "webhook_get",
"method": "get",
"authenticator_id": cls.authenticator.id,
"code": "result = {'exit_code': 0, 'message': 'GET ok'}",
}
)
# Log model
cls.Log = env["cx.tower.webhook.log"]
def url_for(self, endpoint):
"""Helper to build webhook url"""
url = f"/cetmix_tower_webhooks/{endpoint}"
return self.base_url() + url
def assert_log(self, log=None, request_payload=None, **expected):
"""
Universal log checker for webhook log model.
Checks expected field values and substrings.
"""
self.assertIsNotNone(log, "Log record was not created")
if request_payload is not None:
try:
log_payload = log.request_payload
# try to convert both to Python dict for comparison
if isinstance(log_payload, str):
log_payload = log_payload.strip()
self.assertDictEqual(
json.loads(
log_payload.replace("'", '"')
), # try to make JSON from possible str(dict)
json.loads(request_payload),
)
except Exception as ex:
self.fail(
f"Payload comparison failed: {ex}\nLog: {log.request_payload}\nExpected: {request_payload}" # noqa: E501
)
for field, value in expected.items():
if field == "request_payload":
continue # Already checked
actual = getattr(log, field)
self.assertEqual(actual, value, f"{field}: expected {value}, got {actual}")
def test_post_webhook_success(self):
"""Success test for POST request with correct payload."""
data = json.dumps({"some": "data"})
response = self.url_open(
self.url_for(self.webhook_post.endpoint),
data=data,
headers={"Content-Type": "application/json"},
)
self.assertEqual(response.status_code, 200)
self.assertIn(b"POST ok", response.content)
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
self.assert_log(
log,
code_status="success",
authentication_status="success",
http_status=200,
endpoint=self.webhook_post.endpoint,
request_payload=data,
)
def test_get_webhook_success(self):
"""Success test for GET request with correct payload."""
response = self.url_open(
f"{self.url_for(self.webhook_get.endpoint)}?foo=bar",
)
self.assertEqual(response.status_code, 200)
self.assertIn(b"GET ok", response.content)
log = self.Log.search([("webhook_id", "=", self.webhook_get.id)])
self.assert_log(
log,
code_status="success",
authentication_status="success",
http_status=200,
endpoint=self.webhook_get.endpoint,
)
self.assertIn("foo", log.request_payload)
def test_webhook_not_found(self):
"""Test request to a non-existing webhook endpoint."""
data = json.dumps({"test": 1})
response = self.url_open(
self.url_for("missing"),
data=data,
headers={"Content-Type": "application/json"},
)
self.assertEqual(response.status_code, 404)
self.assertIn(b"Webhook not found", response.content)
log = self.Log.search([("webhook_id", "=", False)])
self.assert_log(
log,
code_status="skipped",
authentication_status="failed",
http_status=404,
endpoint="missing",
error_message="Webhook not found",
request_payload=data,
)
def test_wrong_method(self):
"""
Test GET request to POST-only webhook.
"""
response = self.url_open(
self.url_for(self.webhook_post.endpoint),
)
self.assertEqual(response.status_code, 404)
self.assertIn(b"Webhook not found", response.content)
log = self.Log.search([("webhook_id", "=", False)])
self.assert_log(
log,
code_status="skipped",
authentication_status="failed",
http_status=404,
error_message="Webhook not found",
endpoint=self.webhook_post.endpoint,
request_method="get",
)
def test_missing_payload_post(self):
"""
Test POST request with empty payload.
"""
# use opener instead of url_open to avoid checking of data
response = self.opener.post(
self.url_for(self.webhook_post.endpoint),
timeout=1200000,
headers={"Content-Type": "application/json"},
allow_redirects=True,
)
self.assertEqual(response.status_code, 200)
self.assertIn(b"POST ok", response.content)
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
self.assert_log(
log,
code_status="success",
authentication_status="success",
http_status=200,
endpoint=self.webhook_post.endpoint,
request_payload="{}",
)
def test_authentication_failed(self):
"""
Test POST request with authenticator that always denies.
"""
bad_auth = self.env["cx.tower.webhook.authenticator"].create(
{
"name": "Never OK",
"code": "result = {'allowed': False, 'custom_message': 'Forbidden'}",
}
)
webhook = self.env["cx.tower.webhook"].create(
{
"name": "Forbidden Webhook",
"endpoint": "forbidden",
"method": "post",
"authenticator_id": bad_auth.id,
"code": "result = {'exit_code': 0, 'message': 'Should not run'}",
}
)
data = json.dumps({"fail": 1})
response = self.url_open(
self.url_for(webhook.endpoint),
data=data,
headers={"Content-Type": "application/json"},
)
self.assertEqual(response.status_code, 403)
self.assertIn(b"Authentication not allowed", response.content)
log = self.Log.search([("webhook_id", "=", webhook.id)])
self.assert_log(
log,
code_status="skipped",
authentication_status="failed",
http_status=403,
endpoint=webhook.endpoint,
request_payload=data,
)
def test_webhook_code_failure(self):
"""
Test POST request to a webhook that raises an exception in code.
"""
self.webhook_post.code = "raise Exception('Some error!')"
response = self.url_open(
self.url_for(self.webhook_post.endpoint),
data=json.dumps({}),
headers={"Content-Type": "application/json"},
)
self.assertEqual(response.status_code, 500)
self.assertIn(b"Some error!", response.content)
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
self.assert_log(
log,
code_status="failed",
authentication_status="success",
http_status=500,
endpoint=self.webhook_post.endpoint,
request_payload="{}",
)
self.assertIn("Some error!", log.error_message)
def test_json_headers_are_stored(self):
"""
Test that request headers and payload are saved in webhook log record.
"""
payload = {"foo": "bar"}
headers = {"X-Test-Header": "xxx", "Content-Type": "application/json"}
response = self.url_open(
self.url_for(self.webhook_post.endpoint),
data=json.dumps(payload),
headers=headers,
)
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
self.assert_log(
log,
code_status="success",
authentication_status="success",
http_status=200,
endpoint=self.webhook_post.endpoint,
)
self.assertIn("foo", log.request_payload)
self.assertIn("X-Test-Header", log.request_headers)
self.assertIn(log.result_message, response.text)
def test_log_contains_ip(self):
"""
Test that the log contains the client's IP address and country (if available).
"""
payload = {"check": "ip"}
self.url_open(
self.url_for(self.webhook_post.endpoint),
data=json.dumps(payload),
headers={"Content-Type": "application/json"},
)
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
self.assertTrue(log.ip_address)
def test_inactive_webhook(self):
"""Test that inactive webhooks are not callable."""
self.webhook_post.active = False
response = self.url_open(
self.url_for(self.webhook_post.endpoint),
data=json.dumps({"a": 1}),
headers={"Content-Type": "application/json"},
)
self.assertEqual(response.status_code, 404)
self.assertIn(b"Webhook not found", response.content)
def test_authenticator_code_raises(self):
"""
Test that if authenticator's code raises an error,
proper log is created and 403 returned.
"""
bad_auth = self.env["cx.tower.webhook.authenticator"].create(
{"name": "Broken Auth", "code": "raise Exception('auth fail')"}
)
webhook = self.env["cx.tower.webhook"].create(
{
"name": "Web with bad auth",
"endpoint": "bad_auth",
"method": "post",
"authenticator_id": bad_auth.id,
"code": "result = {'exit_code': 0, 'message': 'Should not run'}",
}
)
response = self.url_open(
self.url_for(webhook.endpoint),
data=json.dumps({"x": 1}),
headers={"Content-Type": "application/json"},
)
self.assertEqual(response.status_code, 403)
self.assertIn(b"auth fail", response.content)
log = self.Log.search([("webhook_id", "=", webhook.id)])
self.assert_log(
log,
code_status="skipped",
authentication_status="failed",
http_status=403,
endpoint=webhook.endpoint,
)
self.assertIn("auth fail", log.error_message)
def test_post_webhook_json_content_type(self):
"""
Test POST request with content_type json.
"""
self.webhook_post.content_type = "json"
self.webhook_post.code = "result = {'exit_code': 0, 'message': 'POST JSON ok'}"
data = json.dumps({"json_test": "ok"})
response = self.url_open(
self.url_for(self.webhook_post.endpoint),
data=data,
headers={"Content-Type": "application/json"},
)
self.assertEqual(response.status_code, 200)
self.assertIn(b"POST JSON ok", response.content)
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
self.assert_log(
log,
code_status="success",
authentication_status="success",
http_status=200,
endpoint=self.webhook_post.endpoint,
request_payload=data,
)
def test_post_webhook_form_content_type(self):
"""
Test POST request with content_type form.
"""
self.webhook_post.content_type = "form"
self.webhook_post.code = "result = {'exit_code': 0, 'message': 'POST FORM ok'}"
data = {"form_field": "ok"}
response = self.url_open(
self.url_for(self.webhook_post.endpoint),
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
self.assertEqual(response.status_code, 200)
self.assertIn(b"POST FORM ok", response.content)
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
self.assertIn("form_field", log.request_payload)
def test_authenticator_ipv4_and_ipv6(self):
"""
Test IP filter for IPv4, IPv6, and networks
by monkeypatching REMOTE_ADDR in environ.
"""
auth = self.env["cx.tower.webhook.authenticator"].create(
{
"name": "IP Test",
"allowed_ip_addresses": "203.0.113.5,2001:db8::42,198.51.100.0/24,2001:db8:abcd::/48", # noqa: E501
"code": "result = {'allowed': True}",
}
)
webhook = self.env["cx.tower.webhook"].create(
{
"name": "IP Webhook",
"endpoint": "webhook_iptest",
"method": "post",
"authenticator_id": auth.id,
"code": "result = {'exit_code': 0, 'message': 'IP OK'}",
}
)
data = json.dumps({"ip": "test"})
def do_req(ip):
# Patch _get_remote_addr to simulate requests coming
# from different IP addresses
with patch(
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
return_value=ip,
):
return self.url_open(
self.url_for(webhook.endpoint),
data=data,
headers={"Content-Type": "application/json"},
)
# IPv4 address allowed
resp = do_req("203.0.113.5")
self.assertEqual(resp.status_code, 200)
self.assertIn(b"IP OK", resp.content)
# IPv6 address allowed
resp = do_req("2001:db8::42")
self.assertEqual(resp.status_code, 200)
self.assertIn(b"IP OK", resp.content)
# IPv4 network allowed
resp = do_req("198.51.100.99")
self.assertEqual(resp.status_code, 200)
self.assertIn(b"IP OK", resp.content)
# IPv6 network allowed
resp = do_req("2001:db8:abcd::abcd")
self.assertEqual(resp.status_code, 200)
self.assertIn(b"IP OK", resp.content)
# Denied IPv4 address
resp = do_req("203.0.113.99")
self.assertEqual(resp.status_code, 403)
self.assertIn(b"Address not allowed", resp.content)
# Denied IPv6 address
resp = do_req("2001:db8:ffff::1")
self.assertEqual(resp.status_code, 403)
self.assertIn(b"Address not allowed", resp.content)
def _make_proxy_webhook(
self,
allowed,
trusted=None,
code="result = {'exit_code': 0, 'message': 'OK via proxy'}",
):
"""
Helper to create a webhook with a dedicated authenticator configured
for proxy-aware tests.
"""
auth = self.env["cx.tower.webhook.authenticator"].create(
{
"name": "Proxy Aware",
"allowed_ip_addresses": allowed,
"trusted_proxy_ips": trusted or "",
"code": "result = {'allowed': True}",
}
)
wh = self.env["cx.tower.webhook"].create(
{
"name": "Proxy Webhook",
"endpoint": "proxy_webhook",
"method": "post",
"authenticator_id": auth.id,
"code": code,
}
)
return wh, auth
def test_proxy_headers_ignored_without_trusted_proxy(self):
"""
When trusted_proxy_ips is empty, XFF/X-Real-IP must be ignored.
We fallback to immediate peer (proxy IP), which is not allowed -> 403.
"""
# Allow only the real client network, not the proxy itself
webhook, _auth = self._make_proxy_webhook(
allowed="203.0.113.0/24", trusted=None
)
data = json.dumps({"k": "v"})
proxy_ip = "10.0.0.5" # immediate peer (undocumented as trusted)
headers = {
"Content-Type": "application/json",
"X-Forwarded-For": "203.0.113.7, 10.0.0.5", # should be ignored
"X-Real-IP": "203.0.113.7", # should be ignored
}
with patch(
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
return_value=proxy_ip,
):
resp = self.url_open(
self.url_for(webhook.endpoint), data=data, headers=headers
)
self.assertEqual(resp.status_code, 403)
self.assertIn(b"Address not allowed", resp.content)
def test_proxy_xff_honored_with_trusted_proxy(self):
"""
With trusted proxy configured, take the left-most IP from X-Forwarded-For.
"""
webhook, _auth = self._make_proxy_webhook(
allowed="203.0.113.0/24",
trusted="10.0.0.5",
code="result = {'exit_code': 0, 'message': 'OK XFF'}",
)
data = json.dumps({"k": "v"})
proxy_ip = "10.0.0.5"
headers = {
"Content-Type": "application/json",
# XFF list: client, proxy
"X-Forwarded-For": "203.0.113.7, 10.0.0.5",
}
with patch(
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
return_value=proxy_ip,
):
resp = self.url_open(
self.url_for(webhook.endpoint), data=data, headers=headers
)
self.assertEqual(resp.status_code, 200)
self.assertIn(b"OK XFF", resp.content)
def test_proxy_x_real_ip_fallback_when_xff_missing(self):
"""
If XFF is missing/invalid but trusted proxy is set, fall back to X-Real-IP.
"""
webhook, _auth = self._make_proxy_webhook(
allowed="203.0.113.0/24",
trusted="10.0.0.5",
code="result = {'exit_code': 0, 'message': 'OK X-Real-IP'}",
)
data = json.dumps({"k": "v"})
proxy_ip = "10.0.0.5"
headers = {
"Content-Type": "application/json",
"X-Forwarded-For": "garbage, not_an_ip", # invalids should be skipped
"X-Real-IP": "203.0.113.8",
}
with patch(
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
return_value=proxy_ip,
):
resp = self.url_open(
self.url_for(webhook.endpoint), data=data, headers=headers
)
self.assertEqual(resp.status_code, 200)
self.assertIn(b"OK X-Real-IP", resp.content)
def test_proxy_invalid_headers_fall_back_to_immediate_peer(self):
"""
If headers are invalid even with trusted proxy, fall back to immediate peer.
Since the proxy IP is not in allowlist, the request is denied.
"""
webhook, _auth = self._make_proxy_webhook(
allowed="203.0.113.0/24", # does NOT include proxy IP
trusted="10.0.0.5",
)
data = json.dumps({"k": "v"})
proxy_ip = "10.0.0.5"
headers = {
"Content-Type": "application/json",
"X-Forwarded-For": "not_an_ip, also_bad",
"X-Real-IP": "bad_ip_value",
}
with patch(
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
return_value=proxy_ip,
):
resp = self.url_open(
self.url_for(webhook.endpoint), data=data, headers=headers
)
self.assertEqual(resp.status_code, 403)
self.assertIn(b"Address not allowed", resp.content)
def test_proxy_allows_via_immediate_peer_when_proxy_ip_in_allowlist(self):
"""
If headers are ignored/invalid, but the proxy IP itself is allowed,
access should be granted based on immediate peer.
"""
webhook, _auth = self._make_proxy_webhook(
allowed="10.0.0.5", # allow the proxy itself
trusted="", # no trusted proxies => headers ignored
code="result = {'exit_code': 0, 'message': 'OK immediate peer'}",
)
data = json.dumps({"k": "v"})
proxy_ip = "10.0.0.5"
headers = {
"Content-Type": "application/json",
"X-Forwarded-For": "203.0.113.7", # should be ignored
}
with patch(
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
return_value=proxy_ip,
):
resp = self.url_open(
self.url_for(webhook.endpoint), data=data, headers=headers
)
self.assertEqual(resp.status_code, 200)
self.assertIn(b"OK immediate peer", resp.content)

View File

@@ -0,0 +1,41 @@
<odoo>
<record id="cx_tower_variable_view_form" model="ir.ui.view">
<field name="name">cx.tower.variable.view.form</field>
<field name="model">cx.tower.variable</field>
<field
name="inherit_id"
ref="cetmix_tower_server.cx_tower_variable_view_form"
/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button
name="action_open_webhooks"
type="object"
class="oe_stat_button"
icon="fa-link"
attrs="{'invisible': [('webhook_ids_count', '=', 0)]}"
>
<field
name="webhook_ids_count"
widget="statinfo"
string="Webhooks"
/>
</button>
<button
name="action_open_webhook_authenticators"
type="object"
class="oe_stat_button"
icon="fa-key"
attrs="{'invisible': [('webhook_authenticator_ids_count', '=', 0)]}"
>
<field
name="webhook_authenticator_ids_count"
widget="statinfo"
string="Webhook Authenticators"
/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_webhook_authenticator_view_form" model="ir.ui.view">
<field name="name">cx.tower.webhook.authenticator.view.form</field>
<field name="model">cx.tower.webhook.authenticator</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button
type="object"
name="action_view_logs"
string="Logs"
icon="fa-list"
class="oe_stat_button"
attrs="{'invisible': [('log_count', '=', 0)]}"
>
<field name="log_count" widget="statinfo" string="Logs" />
</button>
</div>
<group>
<group>
<field name="name" />
<field name="reference" />
<field
name="variable_ids"
widget="many2many_tags"
readonly="1"
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
attrs="{'invisible': [('variable_ids', '=', [])]}"
/>
<field
name="secret_ids"
widget="many2many_tags"
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
attrs="{'invisible': [('secret_ids', '=', [])]}"
/>
</group>
<group>
<field
name="allowed_ip_addresses"
placeholder="e.g.: 192.168.1.10, 192.168.2.0/24, 2001:db8::/32, 2a00:1450:4001:824::200e"
/>
<field
name="trusted_proxy_ips"
placeholder="10.0.0.1,192.168.1.0/24"
/>
</group>
</group>
<notebook>
<page name="code" string="Code">
<field
name="code"
widget="ace_tower"
options="{'mode': 'python'}"
placeholder="Enter Python code here. Help about Python expression is available in the help tab of this document"
/>
</page>
<page string="Help" name="python_help_info">
<field name="code_help" />
</page>
<page name="yaml" string="YAML">
<div groups="!cetmix_tower_yaml.group_export">
<h3
>You must be a member of the "YAML/Export" group to export data as YAML</h3>
</div>
<button
type="object"
groups="cetmix_tower_yaml.group_export"
class="oe_highlight"
name="action_open_yaml_export_wizard"
string="Export YAML"
/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="cx_tower_webhook_authenticator_view_tree" model="ir.ui.view">
<field name="name">cx.tower.webhook.authenticator.view.tree</field>
<field name="model">cx.tower.webhook.authenticator</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="reference" optional="show" />
</tree>
</field>
</record>
<record id="cx_tower_webhook_authenticator_view_search" model="ir.ui.view">
<field name="name">cx.tower.webhook.authenticator.view.search</field>
<field name="model">cx.tower.webhook.authenticator</field>
<field name="arch" type="xml">
<search string="Search Webhook Authenticators">
<field name="name" />
<field name="reference" />
</search>
</field>
</record>
<record id="cx_tower_webhook_authenticator_action" model="ir.actions.act_window">
<field name="name">Webhook Authenticators</field>
<field name="res_model">cx.tower.webhook.authenticator</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="cx_tower_webhook_authenticator_view_search" />
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a new webhook authenticator
</p>
</field>
</record>
<record
id="action_cx_tower_webhook_authenticator_export_yaml"
model="ir.actions.act_window"
>
<field name="name">Export YAML</field>
<field name="res_model">cx.tower.yaml.export.wiz</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_cx_tower_webhook_authenticator" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -0,0 +1,185 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_webhook_log_view_form" model="ir.ui.view">
<field name="name">cx.tower.webhook.log.view.form</field>
<field name="model">cx.tower.webhook.log</field>
<field name="arch" type="xml">
<form create="0">
<header>
<field
name="code_status"
widget="statusbar"
options="{'clickable': False}"
statusbar_visible="success,failed,skipped"
/>
</header>
<sheet>
<div class="oe_title">
<label for="display_name" class="oe_edit_only" />
<h1>
<field
name="display_name"
placeholder="Name"
required="1"
/>
</h1>
</div>
<group>
<group>
<field name="webhook_id" />
<field name="authenticator_id" />
<field name="endpoint" />
<field name="request_method" />
<field name="http_status" />
<field name="authentication_status" />
</group>
<group>
<field name="user_id" />
<field name="ip_address" />
<field name="country_id" />
<field name="create_date" />
</group>
</group>
<notebook>
<page name="request" string="Request Payload">
<field
name="request_payload"
widget="ace"
options="{'mode': 'json'}"
readonly="1"
/>
</page>
<page name="request_headers" string="Request Headers">
<field
name="request_headers"
widget="ace"
options="{'mode': 'json'}"
readonly="1"
/>
</page>
<page
name="response"
string="Response Payload"
attrs="{'invisible': [('code_status', '!=', 'success')]}"
>
<field
name="result_message"
widget="ace"
options="{'mode': 'json'}"
readonly="1"
/>
</page>
<page
name="error"
string="Error"
attrs="{'invisible': [('code_status', '!=', 'failed'), ('authentication_status', '!=', 'failed')]}"
>
<code>
<field name="error_message" readonly="1" />
</code>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="cx_tower_webhook_log_view_tree" model="ir.ui.view">
<field name="name">cx.tower.webhook.log.view.tree</field>
<field name="model">cx.tower.webhook.log</field>
<field name="arch" type="xml">
<tree
decoration-info="authentication_status == 'success' and code_status == 'success'"
decoration-danger="authentication_status == 'failed' or code_status == 'failed'"
decoration-warning="authentication_status == 'not_required' or code_status == 'skipped'"
>
<field name="create_date" />
<field name="webhook_id" />
<field name="endpoint" />
<field name="request_method" />
<field name="http_status" />
<field name="authentication_status" />
<field name="code_status" />
<field name="user_id" />
</tree>
</field>
</record>
<record id="cx_tower_webhook_log_view_search" model="ir.ui.view">
<field name="name">cx.tower.webhook.log.view.search</field>
<field name="model">cx.tower.webhook.log</field>
<field name="arch" type="xml">
<search string="Webhook Logs">
<field name="webhook_id" />
<field name="endpoint" />
<field name="authentication_status" />
<field name="code_status" />
<field name="http_status" />
<field name="user_id" />
<field name="ip_address" />
<field name="create_date" />
<filter
name="auth_failed"
string="Auth Failed"
domain="[('authentication_status','=','failed')]"
/>
<filter
name="code_failed"
string="Code Failed"
domain="[('code_status','=','failed')]"
/>
<filter
name="http_200"
string="HTTP 200"
domain="[('http_status','=',200)]"
/>
<group expand="0" string="Group By">
<filter
name="group_by_webhook"
string="Webhook"
context="{'group_by': 'webhook_id'}"
/>
<filter
name="group_by_method"
string="Method"
context="{'group_by': 'request_method'}"
/>
<filter
name="group_by_user"
string="User"
context="{'group_by': 'user_id'}"
/>
<filter
name="group_by_auth_status"
string="Auth Status"
context="{'group_by': 'authentication_status'}"
/>
<filter
name="group_by_code_status"
string="Code Status"
context="{'group_by': 'code_status'}"
/>
<filter
name="group_by_http_status"
string="HTTP Status"
context="{'group_by': 'http_status'}"
/>
</group>
</search>
</field>
</record>
<record id="cx_tower_webhook_log_action" model="ir.actions.act_window">
<field name="name">Webhook Logs</field>
<field name="res_model">cx.tower.webhook.log</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="cx_tower_webhook_log_view_search" />
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No webhook logs found
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,207 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_webhook_view_form" model="ir.ui.view">
<field name="name">cx.tower.webhook.view.form</field>
<field name="model">cx.tower.webhook</field>
<field name="arch" type="xml">
<form>
<sheet>
<widget
name="web_ribbon"
title="Disabled"
bg_color="bg-danger"
attrs="{'invisible': [('active', '=', True)]}"
/>
<div class="oe_button_box" name="button_box">
<button
type="object"
name="action_view_logs"
string="Logs"
icon="fa-list"
class="oe_stat_button"
attrs="{'invisible': [('log_count', '=', 0)]}"
>
<field name="log_count" widget="statinfo" string="Logs" />
</button>
</div>
<group>
<group>
<field name="name" />
<field name="reference" />
<field name="active" />
<field name="authenticator_id" />
<field
name="variable_ids"
widget="many2many_tags"
readonly="1"
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
attrs="{'invisible': [('variable_ids', '=', [])]}"
/>
<field
name="secret_ids"
widget="many2many_tags"
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
attrs="{'invisible': [('secret_ids', '=', [])]}"
/>
</group>
<group>
<field name="endpoint" />
<field
name="full_url"
attrs="{'invisible': [('endpoint', '=', False)]}"
widget="CopyClipboardChar"
options="{'string': 'Copy'}"
/>
<field name="method" />
<field
name="content_type"
attrs="{'invisible': [('method', '=', 'get')]}"
/>
<field name="user_id" context="{'active_test': False}" />
</group>
</group>
<notebook>
<page name="code" string="Code">
<field
name="code"
widget="ace_tower"
options="{'mode': 'python'}"
placeholder="Enter Python code here. Help about Python expression is available in the help tab of this document."
/>
</page>
<page string="Help" name="python_help_info">
<field name="code_help" />
</page>
<page name="yaml" string="YAML">
<div groups="!cetmix_tower_yaml.group_export">
<h3
>You must be a member of the "YAML/Export" group to export data as YAML.</h3>
</div>
<button
type="object"
groups="cetmix_tower_yaml.group_export"
class="oe_highlight"
name="action_open_yaml_export_wizard"
string="Export YAML"
/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="cx_tower_webhook_view_tree" model="ir.ui.view">
<field name="name">cx.tower.webhook.view.tree</field>
<field name="model">cx.tower.webhook</field>
<field name="arch" type="xml">
<tree decoration-muted="not active">
<field name="name" />
<field name="reference" optional="show" />
<field name="authenticator_id" optional="show" />
<field name="user_id" optional="show" />
<field name="endpoint" optional="show" />
<field name="method" optional="show" />
<field name="content_type" optional="show" />
<field name="active" widget="boolean_toggle" />
</tree>
</field>
</record>
<record id="cx_tower_webhook_view_search" model="ir.ui.view">
<field name="name">cx.tower.webhook.view.search</field>
<field name="model">cx.tower.webhook</field>
<field name="arch" type="xml">
<search string="Search Webhooks">
<field
name="name"
string="Name/Reference"
filter_domain="['|', ('name', 'ilike', self), ('reference', 'ilike', self)]"
/>
<field name="endpoint" />
<filter
string="All"
name="all"
domain="['|', ('active', '=', True), ('active', '=', False)]"
/>
<filter
name="filter_enabled"
string="Enabled"
domain="[('active', '=', True)]"
/>
<filter
name="filter_disabled"
string="Disabled"
domain="[('active', '=', False)]"
/>
<group expand="0" string="Group By">
<filter
name="group_by_method"
string="Method"
context="{'group_by': 'method'}"
/>
<filter
name="group_by_authenticator_id"
string="Authenticator"
context="{'group_by': 'authenticator_id'}"
/>
<filter
name="group_by_user_id"
string="User"
context="{'group_by': 'user_id'}"
/>
<filter
name="group_by_content_type"
string="Content Type"
context="{'group_by': 'content_type'}"
/>
</group>
<searchpanel>
<field
name="method"
string="Method"
icon="fa-cog"
enable_counters="1"
/>
<field
name="content_type"
string="Content Type"
icon="fa-file"
enable_counters="1"
/>
<field
name="authenticator_id"
string="Authenticator"
icon="fa-shield"
enable_counters="1"
/>
</searchpanel>
</search>
</field>
</record>
<record id="cx_tower_webhook_action" model="ir.actions.act_window">
<field name="name">Webhooks</field>
<field name="res_model">cx.tower.webhook</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="cx_tower_webhook_view_search" />
<field name="context">{'search_default_all': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a new webhook
</p>
</field>
</record>
<record id="action_cx_tower_webhook_export_yaml" model="ir.actions.act_window">
<field name="name">Export YAML</field>
<field name="res_model">cx.tower.yaml.export.wiz</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_cx_tower_webhook" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<odoo>
<menuitem
id="menu_cetmix_tower_webhook_authenticator"
name="Webhook Authenticators"
action="cx_tower_webhook_authenticator_action"
parent="cetmix_tower_server.menu_cx_tower_automation_root"
groups="cetmix_tower_server.group_root"
sequence="3"
/>
<menuitem
id="menu_cetmix_tower_webhook"
name="Webhooks"
action="cx_tower_webhook_action"
parent="cetmix_tower_server.menu_cx_tower_automation_root"
groups="cetmix_tower_server.group_root"
sequence="4"
/>
<menuitem
id="menu_cetmix_tower_webhook_log"
name="Webhook Calls"
action="cx_tower_webhook_log_action"
parent="cetmix_tower_server.menu_cx_tower_log_root"
sequence="150"
/>
</odoo>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form</field>
<field name="model">res.config.settings</field>
<field
name="inherit_id"
ref="cetmix_tower_server.res_config_settings_view_form"
/>
<field name="arch" type="xml">
<xpath expr="//div[@id='cetmix_tower_settings']" position="inside">
<div class="col-12 col-lg-6 o_setting_box">
<div class="cetmix_tower_webhook_log_duration">
<label for="cetmix_tower_webhook_log_duration" />
<div class="text-muted">
Set the number of days to keep webhook logs. Old logs will be deleted automatically.
<br />
</div>
<div class="content-group">
<div class="mt16">
<field name="cetmix_tower_webhook_log_duration" />
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -7,7 +7,7 @@ Cetmix Tower YAML
!! This file is generated by oca-gen-addon-readme !! !! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !! !! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:7f55d44d4d4b9239195643b7169c1a5f98ad8a36c3cc80686d357a9829beb856 !! source digest: sha256:96e8f3f1df3ab25b952a9534d0914149740cc036b62efe2c7795f9d2d9636177
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
@@ -50,16 +50,6 @@ instructions.
Changelog Changelog
========= =========
16.0.3.1.0 (2026-03-30)
-----------------------
- Features: Deferred import of related records. (5323)
16.0.3.0.0 (2026-03-23)
-----------------------
- Features: Jets! (4700)
16.0.2.0.1 (2025-10-29) 16.0.2.0.1 (2025-10-29)
----------------------- -----------------------

View File

@@ -3,7 +3,7 @@
{ {
"name": "Cetmix Tower YAML", "name": "Cetmix Tower YAML",
"summary": "Cetmix Tower YAML export/import", "summary": "Cetmix Tower YAML export/import",
"version": "16.0.3.1.0", "version": "16.0.2.0.3",
"development_status": "Beta", "development_status": "Beta",
"category": "Productivity", "category": "Productivity",
"website": "https://tower.cetmix.com", "website": "https://tower.cetmix.com",
@@ -28,7 +28,6 @@
"views/cx_tower_shortcut_view.xml", "views/cx_tower_shortcut_view.xml",
"views/cx_tower_scheduled_task_view.xml", "views/cx_tower_scheduled_task_view.xml",
"views/cx_tower_key_view.xml", "views/cx_tower_key_view.xml",
"views/cx_tower_jet_template_view.xml",
"views/cx_tower_yaml_manifest_template_views.xml", "views/cx_tower_yaml_manifest_template_views.xml",
"views/cx_tower_yaml_manifest_author_views.xml", "views/cx_tower_yaml_manifest_author_views.xml",
"wizards/cx_tower_yaml_export_wiz.xml", "wizards/cx_tower_yaml_export_wiz.xml",

View File

@@ -91,12 +91,6 @@ msgstr ""
msgid "Authors" msgid "Authors"
msgstr "" msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model.fields,help:cetmix_tower_yaml.field_cx_tower_scheduled_task_cv__reference
msgid ""
"Can contain English letters, digits and '_'. Leave blank to autogenerate"
msgstr ""
#. module: cetmix_tower_yaml #. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_command #: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_command
msgid "Cetmix Tower Command" msgid "Cetmix Tower Command"
@@ -127,31 +121,6 @@ msgstr ""
msgid "Cetmix Tower Flight Plan Line Action" msgid "Cetmix Tower Flight Plan Line Action"
msgstr "" msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_jet_action
msgid "Cetmix Tower Jet Action"
msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_jet_state
msgid "Cetmix Tower Jet State"
msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_jet_template
msgid "Cetmix Tower Jet Template"
msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_jet_template_dependency
msgid "Cetmix Tower Jet Template Dependency"
msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_jet_waypoint_template
msgid "Cetmix Tower Jet Waypoint Template"
msgstr ""
#. module: cetmix_tower_yaml #. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_key #: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_key
msgid "Cetmix Tower Key/Secret Storage" msgid "Cetmix Tower Key/Secret Storage"
@@ -307,21 +276,6 @@ msgstr ""
msgid "Custom license text when license type is Custom." msgid "Custom license text when license type is Custom."
msgstr "" msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_scheduled_task_cv
msgid "Custom variable values for scheduled tasks"
msgstr ""
#. module: cetmix_tower_yaml
#. odoo-python
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.py:0
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.py:0
#, python-format
msgid ""
"Deferred relation resolution failed:\n"
"%(details)s"
msgstr ""
#. module: cetmix_tower_yaml #. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__manifest_description #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__manifest_description
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__manifest_description #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__manifest_description
@@ -381,7 +335,6 @@ msgstr ""
#. module: cetmix_tower_yaml #. module: cetmix_tower_yaml
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_command_export_yaml #: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_command_export_yaml
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_file_template_export_yaml #: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_file_template_export_yaml
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_jet_template_export_yaml
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_key_export_yaml #: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_key_export_yaml
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_os_export_yaml #: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_os_export_yaml
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_plan_export_yaml #: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_plan_export_yaml
@@ -394,7 +347,6 @@ msgstr ""
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_variable_value_export_yaml #: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_variable_value_export_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_command_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_command_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_file_template_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_file_template_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_jet_template_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_plan_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_plan_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_view_form
@@ -636,7 +588,6 @@ msgid "Models to create records in"
msgstr "" msgstr ""
#. module: cetmix_tower_yaml #. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_scheduled_task_cv__name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_manifest_author__name #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_manifest_author__name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_manifest_tmpl__name #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_manifest_tmpl__name
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
@@ -712,24 +663,6 @@ msgstr ""
msgid "Provide Custom License Text when License is set to 'Custom'." msgid "Provide Custom License Text when License is set to 'Custom'."
msgstr "" msgstr ""
#. module: cetmix_tower_yaml
#. odoo-python
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.py:0
#, python-format
msgid ""
"Record %(record_model)s '%(record_reference)s': field '%(field)s' could not "
"resolve %(target_model)s '%(target_reference)s'"
msgstr ""
#. module: cetmix_tower_yaml
#. odoo-python
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.py:0
#, python-format
msgid ""
"Record '%(record)s': field '%(field)s' could not resolve %(target_model)s "
"'%(target_reference)s'"
msgstr ""
#. module: cetmix_tower_yaml #. module: cetmix_tower_yaml
#. odoo-python #. odoo-python
#: code:addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py:0 #: code:addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py:0
@@ -762,11 +695,6 @@ msgstr ""
msgid "Records of the following models were created or updated: %(models)s" msgid "Records of the following models were created or updated: %(models)s"
msgstr "" msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_scheduled_task_cv__reference
msgid "Reference"
msgstr ""
#. module: cetmix_tower_yaml #. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__remove_empty_values #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__remove_empty_values
msgid "Remove Empty x2m Field Values" msgid "Remove Empty x2m Field Values"
@@ -946,7 +874,6 @@ msgstr ""
#. module: cetmix_tower_yaml #. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_command_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_command_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_file_template_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_file_template_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_jet_template_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_plan_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_plan_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_view_form
@@ -1026,11 +953,6 @@ msgstr ""
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_command__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_command__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_file__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_file__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_file_template__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_file_template__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_jet_action__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_jet_state__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_jet_template__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_jet_template_dependency__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_jet_waypoint_template__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_key__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_key__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_key_value__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_key_value__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_os__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_os__yaml_code
@@ -1038,7 +960,6 @@ msgstr ""
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line_action__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line_action__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_scheduled_task__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_scheduled_task__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_scheduled_task_cv__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_log__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_log__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_template__yaml_code #: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_template__yaml_code
@@ -1082,7 +1003,6 @@ msgstr ""
#. module: cetmix_tower_yaml #. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_command_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_command_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_file_template_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_file_template_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_jet_template_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_plan_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_plan_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_view_form #: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_view_form

View File

@@ -15,13 +15,7 @@ from . import cx_tower_key_value
from . import cx_tower_server_log from . import cx_tower_server_log
from . import cx_tower_shortcut from . import cx_tower_shortcut
from . import cx_tower_scheduled_task from . import cx_tower_scheduled_task
from . import cx_tower_scheduled_task_cv
from . import cx_tower_file from . import cx_tower_file
from . import cx_tower_server from . import cx_tower_server
from . import cx_tower_yaml_manifest_template from . import cx_tower_yaml_manifest_template
from . import cx_tower_yaml_manifest_author from . import cx_tower_yaml_manifest_author
from . import cx_tower_jet_template
from . import cx_tower_jet_template_dependency
from . import cx_tower_jet_state
from . import cx_tower_jet_action
from . import cx_tower_jet_waypoint_template

View File

@@ -19,25 +19,13 @@ class CxTowerCommand(models.Model):
"tag_ids", "tag_ids",
"path", "path",
"file_template_id", "file_template_id",
"if_file_exists",
"disconnect_file",
"flight_plan_id", "flight_plan_id",
"jet_template_id",
"jet_action_id",
"waypoint_template_id",
"fly_here",
"code", "code",
"no_split_for_sudo",
"server_status", "server_status",
"variable_ids", "variable_ids",
"secret_ids", "secret_ids",
"no_split_for_sudo",
"if_file_exists",
"disconnect_file",
] ]
return res return res
def _get_deferred_m2o_import_fields(self):
"""Return m2o command fields resolved after the main import pass."""
return {
"jet_template_id": "cx.tower.jet.template",
"jet_action_id": "cx.tower.jet.action",
"waypoint_template_id": "cx.tower.jet.waypoint.template",
}

View File

@@ -1,42 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerJetTemplate(models.Model):
_name = "cx.tower.jet.template"
_inherit = [
"cx.tower.jet.template",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"note",
"tag_ids",
"limit_per_server",
"show_in_create_wizard",
"plan_install_id",
"plan_uninstall_id",
"plan_clone_same_server_id",
"plan_clone_different_server_id",
"variable_value_ids",
"action_ids",
"template_requires_ids",
"waypoint_template_ids",
"server_log_ids",
"scheduled_task_ids",
]
return res
def _get_deferred_x2m_import_fields(self):
"""Return x2m child records resolved after the main import pass."""
return {
"template_requires_ids": {
"child_model": "cx.tower.jet.template.dependency",
"deferred_field": "template_required_id",
"target_model": "cx.tower.jet.template",
}
}

View File

@@ -1,32 +0,0 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerJetWaypointTemplate(models.Model):
_name = "cx.tower.jet.waypoint.template"
_inherit = [
"cx.tower.jet.waypoint.template",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"sequence",
"access_level",
"jet_template_id",
"plan_create_id",
"plan_arrive_id",
"plan_leave_id",
"plan_delete_id",
"note",
]
return res
def _get_deferred_m2o_import_fields(self):
"""Return m2o waypoint-template fields resolved after import."""
return {
"jet_template_id": "cx.tower.jet.template",
}

View File

@@ -21,20 +21,3 @@ class CxTowerPlan(models.Model):
"line_ids", "line_ids",
] ]
return res return res
def _get_deferred_x2m_import_fields(self):
"""Defer plan lines whose command is not resolvable during nested import.
Deep YAML (e.g. a command's waypoint inlines a jet template whose plans
reference that same command) creates a forward reference: plan lines are
prepared before the command exists in the database. Queue those lines
and create them after the main import pass when ``command_id`` can be
resolved.
"""
return {
"line_ids": {
"child_model": "cx.tower.plan.line",
"deferred_field": "command_id",
"target_model": "cx.tower.command",
}
}

View File

@@ -19,24 +19,5 @@ class CxTowerScheduledTask(models.Model):
"interval_type", "interval_type",
"next_call", "next_call",
"last_call", "last_call",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
"custom_variable_value_ids",
] ]
return res return res
def _get_deferred_x2m_import_fields(self):
"""Return scheduled-task child records resolved after import."""
return {
"custom_variable_value_ids": {
"child_model": "cx.tower.scheduled.task.cv",
"deferred_field": "variable_value_id",
"target_model": "cx.tower.variable.value",
"skip_empty": True,
}
}

View File

@@ -1,36 +0,0 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerScheduledTaskCv(models.Model):
_name = "cx.tower.scheduled.task.cv"
_inherit = [
"cx.tower.scheduled.task.cv",
"cx.tower.yaml.mixin",
"cx.tower.reference.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += ["variable_value_id"]
return res
def _post_process_yaml_dict_values(self, values):
"""Populate required child fields from the linked variable value."""
res = super()._post_process_yaml_dict_values(values)
variable_value_id = res.get("variable_value_id")
if variable_value_id:
variable_value = self.env["cx.tower.variable.value"].browse(
variable_value_id
)
if variable_value.exists():
res.update(
{
"name": variable_value.name,
"variable_id": variable_value.variable_id.id,
"option_id": variable_value.option_id.id or False,
"value_char": variable_value.value_char,
}
)
return res

View File

@@ -3,17 +3,21 @@
from odoo import models from odoo import models
class CxTowerJetTemplateDependency(models.Model): class CxTowerServerLog(models.Model):
_name = "cx.tower.jet.template.dependency" _name = "cx.tower.server.log"
_inherit = [ _inherit = [
"cx.tower.jet.template.dependency", "cx.tower.server.log",
"cx.tower.yaml.mixin", "cx.tower.yaml.mixin",
] ]
def _get_fields_for_yaml(self): def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml() res = super()._get_fields_for_yaml()
res += [ res += [
"template_required_id", "name",
"state_required_id", "log_type",
"command_id",
"use_sudo",
"file_template_id",
"file_id",
] ]
return res return res

View File

@@ -0,0 +1,41 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerServerTemplate(models.Model):
_name = "cx.tower.server.template"
_inherit = [
"cx.tower.server.template",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"color",
"os_id",
"tag_ids",
"note",
"ssh_port",
"ssh_username",
"ssh_key_id",
"ssh_auth_mode",
"use_sudo",
"variable_value_ids",
"server_log_ids",
"shortcut_ids",
"scheduled_task_ids",
"flight_plan_id",
"plan_delete_id",
]
return res
def _get_force_x2m_resolve_models(self):
res = super()._get_force_x2m_resolve_models()
# Add Flight Plan in order to always try to use existing one
# This is useful to avoid duplicating existing plans
res += ["cx.tower.plan", "cx.tower.shortcut", "cx.tower.scheduled.task"]
return res

View File

@@ -3,24 +3,20 @@
from odoo import models from odoo import models
class CxTowerJetAction(models.Model): class CxTowerShortcut(models.Model):
_name = "cx.tower.jet.action" _name = "cx.tower.shortcut"
_inherit = [ _inherit = ["cx.tower.shortcut", "cx.tower.yaml.mixin"]
"cx.tower.jet.action",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self): def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml() res = super()._get_fields_for_yaml()
res += [ res += [
"name", "name",
"note", "sequence",
"priority",
"access_level", "access_level",
"state_from_id", "action",
"state_transit_id", "command_id",
"state_to_id", "use_sudo",
"state_error_id",
"plan_id", "plan_id",
"note",
] ]
return res return res

View File

@@ -0,0 +1,16 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerTag(models.Model):
_name = "cx.tower.tag"
_inherit = ["cx.tower.tag", "cx.tower.yaml.mixin"]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"color",
]
return res

View File

@@ -0,0 +1,23 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerVariable(models.Model):
_name = "cx.tower.variable"
_inherit = ["cx.tower.variable", "cx.tower.yaml.mixin"]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"access_level",
"variable_type",
"option_ids",
"applied_expression",
"validation_pattern",
"validation_message",
"note",
"tag_ids",
]
return res

View File

@@ -3,20 +3,16 @@
from odoo import models from odoo import models
class CxTowerJetState(models.Model): class CxTowerVariableOption(models.Model):
_name = "cx.tower.jet.state" _name = "cx.tower.variable.option"
_inherit = [ _inherit = ["cx.tower.variable.option", "cx.tower.yaml.mixin"]
"cx.tower.jet.state",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self): def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml() res = super()._get_fields_for_yaml()
res += [ res += [
"name",
"sequence", "sequence",
"access_level", "access_level",
"color", "name",
"note", "value_char",
] ]
return res return res

View File

@@ -0,0 +1,20 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerVariableValue(models.Model):
_name = "cx.tower.variable.value"
_inherit = ["cx.tower.variable.value", "cx.tower.yaml.mixin"]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"sequence",
"access_level",
"variable_id",
"value_char",
"variable_ids",
"required",
]
return res

View File

@@ -0,0 +1,23 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class CxTowerYamlManifestAuthor(models.Model):
"""Author of a YAML manifest (can be one or many)."""
_name = "cx.tower.yaml.manifest.author"
_sql_constraints = [
(
"yaml_manifest_author_name_uniq",
"unique(name)",
"Author name must be unique.",
)
]
_description = "YAML Manifest Author"
_order = "name"
name = fields.Char(required=True, translate=False)

View File

@@ -0,0 +1,93 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import re
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class CxTowerYamlManifestTemplate(models.Model):
"""Pre-defined YAML manifest template storing common metadata
such as authors, website, license, and currency for reuse
during YAML exports."""
_name = "cx.tower.yaml.manifest.tmpl"
_description = "YAML Manifest Template"
_order = "name"
name = fields.Char(
required=True,
help="Name of the manifest template.",
)
website = fields.Char(help="Website URL for the manifest.")
author_ids = fields.Many2many(
"cx.tower.yaml.manifest.author",
string="Authors",
help="List of author names to include in the YAML manifest.",
)
license = fields.Selection(
selection=lambda self: self._selection_license(),
help="License used for the code snippet.",
)
license_text = fields.Text(
help="Custom license text when license type is Custom.",
)
currency = fields.Selection(
selection=lambda self: self._selection_currency(),
help="Currency for pricing information.",
)
version = fields.Char(
help="Version in Major.Minor.Patch format, e.g. 1.0.0",
default="1.0.0",
)
file_prefix = fields.Char(
string="File prefix",
help="Add prefix to the exported YAML file name when this template is selected",
)
@api.model
def _selection_license(self):
"""Return available license options for manifest."""
return [
("agpl-3", "AGPL-3"),
("lgpl-3", "LGPL-3"),
("mit", "MIT"),
("custom", _("Custom")),
]
@api.model
def _selection_currency(self):
"""Return available currency options for manifest pricing."""
return [
("EUR", _("Euro")),
("USD", _("US Dollar")),
]
@api.constrains("license", "license_text")
def _check_license_text_for_custom(self):
"""Ensure that custom license text is provided when license is 'custom'."""
for rec in self:
if rec.license == "custom" and not (rec.license_text or "").strip():
raise ValidationError(
_("Provide Custom License Text when License is set to 'Custom'.")
)
@api.constrains("version")
def _check_version_format(self):
"""Ensure the template version follows the x.y.z semantic format.
The version must consist of three non-negative integers (major, minor, patch)
separated by dots—for example, “1.2.3”. Raises a ValidationError otherwise.
"""
semver = re.compile(r"^\d+\.\d+\.\d+$")
for rec in self:
if rec.version and not semver.match(rec.version):
raise ValidationError(
_("Version must be in the Major.Minor.Patch format, e.g. 1.2.3")
)

View File

@@ -0,0 +1,577 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
import yaml
from odoo import _, api, fields, models
from odoo.exceptions import AccessError, ValidationError
_logger = logging.getLogger(__name__)
class CustomDumper(yaml.Dumper):
"""Custom dumper to ensures code
is properly dumped in YAML
"""
def represent_scalar(self, tag, value, style=None):
if isinstance(value, str) and "\n" in value:
style = "|"
return super().represent_scalar(tag, value, style)
class YamlExportCollector:
"""
Collector for YAML export.
Tracks unique records by their (model_name, reference) tuple to avoid duplicates.
"""
def __init__(self):
"""
Initialize the collector.
"""
self.added_references = set()
def add(self, key):
"""
Add a record to the collector if its reference is unique.
:param key: tuple, key of the record
"""
if key and key not in self.added_references:
self.added_references.add(key)
def is_added(self, key):
"""
Check by (model, reference) tuple.
:param key: tuple, key of the record
:return: bool
"""
return key in self.added_references
class CxTowerYamlMixin(models.AbstractModel):
"""Used to implement YAML rendering functions.
Inherit in your model in case you want to YAML instance of the records.
"""
_name = "cx.tower.yaml.mixin"
_description = "Cetmix Tower YAML rendering mixin"
# File format version in order to track compatibility
CETMIX_TOWER_YAML_VERSION = 1
# TO_YAML_* used to convert from Odoo field values to YAML
TO_YAML_ACCESS_LEVEL = {"1": "user", "2": "manager", "3": "root"}
# TO_TOWER_* used to convert from YAML field values to Tower ones
TO_TOWER_ACCESS_LEVEL = {"user": "1", "manager": "2", "root": "3"}
yaml_code = fields.Text(
compute="_compute_yaml_code",
inverse="_inverse_yaml_code",
groups="cetmix_tower_yaml.group_export,cetmix_tower_yaml.group_import",
)
def _compute_yaml_code(self):
"""Compute YAML code based on model record data"""
# This is used for the file name.
# Eg cx.tower.command record will have 'command_' prefix.
for record in self:
# We are reading field list for each record
# because list of fields can differ from record to record
record.yaml_code = self._convert_dict_to_yaml(
record._prepare_record_for_yaml()
)
def _inverse_yaml_code(self):
"""Compose record based on provided YAML"""
for record in self:
if record.yaml_code:
record_yaml_dict = yaml.safe_load(record.yaml_code)
record_vals = record._post_process_yaml_dict_values(record_yaml_dict)
record.update(record_vals)
@api.constrains("yaml_code")
def _check_yaml_code_write_access(self):
"""
Check if user has access to create records from YAML.
This is checked only when user already has access to export YAML.
Otherwise, the field is not accessible due to security group.
"""
if self.env.user.has_group("cetmix_tower_yaml.group_export") and (
not self.env.user.has_group("cetmix_tower_yaml.group_import")
and not self.env.user._is_superuser()
):
raise AccessError(_("You are not allowed to create records from YAML"))
@api.model_create_multi
def create(self, vals_list):
# Handle validation error when field values are not valid
try:
return super().create(vals_list)
except ValueError as e:
raise ValidationError(str(e)) from e
def write(self, vals):
# Handle validation error when field values are not valid
try:
return super().write(vals)
except ValueError as e:
raise ValidationError(str(e)) from e
def action_open_yaml_export_wizard(self):
"""Open YAML export wizard"""
return {
"type": "ir.actions.act_window",
"res_model": "cx.tower.yaml.export.wiz",
"view_mode": "form",
"target": "new",
}
def _convert_dict_to_yaml(self, values):
"""Converts Python dictionary to YAML string.
This is a helper function that is designed to be used
by any models that need to convert a dictionary to YAML.
Args:
values (Dict): Dictionary containing data
to be converted to YAML format
Returns:
Text: YAML string
Raises:
ValidationError: If values is not a dictionary
or YAML conversion fails
"""
if not isinstance(values, dict):
raise ValidationError(_("Values must be a dictionary"))
try:
yaml_code = yaml.dump(
values,
Dumper=CustomDumper,
default_flow_style=False,
sort_keys=False,
)
return yaml_code
except (yaml.YAMLError, UnicodeEncodeError) as e:
raise ValidationError(
_(
"Failed to convert dictionary" " to YAML: %(error)s",
error=str(e),
)
) from e
def _prepare_record_for_yaml(self):
"""Reads and processes current record before converting it to YAML
Returns:
dict: values ready for YAML conversion
"""
self.ensure_one()
yaml_keys = self._get_fields_for_yaml()
record_dict = self.read(fields=yaml_keys)[0]
return self._post_process_record_values(record_dict)
def _get_fields_for_yaml(self):
"""Get ist of field to be present in YAML
Set 'no_yaml_service_fields' context key to skip
service fields creation (cetmix_tower_yaml_version, cetmix_tower_model)
Returns:
list(): list of fields to be used as YAML keys
"""
return ["reference"]
def _get_force_x2m_resolve_models(self):
"""List of models that will always try to be resolved
when referenced in x2m related fields.
This is useful for models that should always use existing records
instead of creating new ones when referenced in x2m related fields.
Such as variables or tags.
Returns:
List: list of models that will always try to be resolved
"""
return [
"cx.tower.variable",
"cx.tower.variable.option",
"cx.tower.tag",
"cx.tower.os",
"cx.tower.key",
]
def _post_process_record_values(self, values):
"""Post process record values
before converting them to YAML
Args:
values (dict): values returned by 'read' method
Context:
explode_related_record: if set will return entire record dictionary
not just a reference
remove_empty_values: if set will remove empty values from the record
Returns:
dict(): processed values
"""
collector = self._context.get("yaml_collector")
ref = values.get("reference")
collector_key = (self._name, ref) if ref else None
if collector and collector_key and collector.is_added(collector_key):
return {"reference": ref}
# We don't need id because we are not using it
values.pop("id", None)
# Add YAML format version and model
if not self._context.get("no_yaml_service_fields"):
model_name = self._name.replace("cx.tower.", "").replace(".", "_")
model_values = {
"cetmix_tower_model": model_name,
}
else:
model_values = {}
# Parse access level
access_level = values.pop("access_level", None)
if access_level:
model_values.update(
{"access_level": self.TO_YAML_ACCESS_LEVEL[access_level]}
)
values = {**model_values, **values}
# Copy values to avoid modifying the original values
new_values = values.copy()
# Check if we need to return a record dict or just a reference
# Use context value first, revert to the record setting if not defined
explode_related_record = self._context.get("explode_related_record")
# Check if we need to remove empty values
# Currently only x2m fields are supported
remove_empty_values = self._context.get("remove_empty_values")
# Post process m2o and x2m fields
for key, value in values.items():
# IMPORTANT: Odoo naming patterns must be followed for related fields.
# This is why we are checking for the field name ending here.
# Further checks for the field type are done
# in _process_relation_field_value()
if key.endswith("_id") or key.endswith("_ids"):
if not value and remove_empty_values:
del new_values[key]
else:
processed_value = self.with_context(
explode_related_record=explode_related_record
)._process_relation_field_value(key, value, record_mode=True)
new_values.update({key: processed_value})
if collector and collector_key:
collector.add(collector_key)
return new_values
def _post_process_yaml_dict_values(self, values):
"""Post process dictionary values generated from YAML code
Args:
values (dict): Dictionary generated from YAML
Returns:
dict(): Post-processed values
"""
# Remove model data because it is not a field
if "cetmix_tower_model" in values:
values.pop("cetmix_tower_model")
# Parse access level
if "access_level" in values:
values_access_level = values["access_level"]
access_level = self.TO_TOWER_ACCESS_LEVEL.get(values_access_level)
if access_level:
values.update({"access_level": access_level})
else:
raise ValidationError(
_(
"Wrong value for 'access_level' key: %(acv)s",
acv=values_access_level,
)
)
# Leave supported keys only
supported_keys = self._get_fields_for_yaml()
filtered_values = {k: v for k, v in values.items() if k in supported_keys}
# Post process m2o fields
for key, value in filtered_values.items():
# IMPORTANT: Odoo naming patterns must be followed for related fields.
# This is why we are checking for the field name ending here.
# Further checks for the field type are done
# in _process_relation_field_value()
if key.endswith("_id") or key.endswith("_ids"):
processed_value = self.with_context(
explode_related_record=True
)._process_relation_field_value(key, value, record_mode=False)
filtered_values.update({key: processed_value})
return filtered_values
def _process_relation_field_value(self, field, value, record_mode=False):
"""Post process One2many, Many2many or Many2one value
Args:
field (Char): Field the value belongs to
value (Char): Value to process
record_mode (Bool): If True process value as a record value
else process value as a YAML value
Context:
explode_related_record: if set will return entire record dictionary
not just a reference
Returns:
dict() or Char: record dictionary if fetch_record else reference
"""
# Step 1: Return False if the value is not set or the field is not found
if not value:
return False
field_obj = self._fields.get(field)
if not field_obj:
return False
# Step 2: Return False if the field type doesn't match
# or comodel is not defined
field_type = field_obj.type
if (
field_type not in ["one2many", "many2many", "many2one"]
or not field_obj.comodel_name
):
return False
comodel = self.env[field_obj.comodel_name]
explode_related_record = self._context.get("explode_related_record")
# Step 3: process value based on the field type
if field_type == "many2one":
return self._process_m2o_value(
comodel, value, explode_related_record, record_mode
)
if field_type in ["one2many", "many2many"]:
return self._process_x2m_values(
comodel, field_type, value, explode_related_record, record_mode
)
# Step 4: fall back if field type is not supported
return False
def _process_m2o_value(
self, comodel, value, explode_related_record, record_mode=False
):
"""Post process many2one value
Args:
comodel (BaseClass): Model the value belongs to
value (Char): Value to process
explode_related_record (Bool): If True return entire record dict
instead of a reference
record_mode (Bool): If True process value as a record value
else process value as a YAML value
Returns:
dict() or Char: record dictionary if fetch_record else reference
"""
# -- (Record -> YAML)
if record_mode:
# Retrieve the record based on the ID provided in the value
record = comodel.browse(value[0])
# If the context specifies to explode the related record,
# return its dictionary representation
if explode_related_record:
return (
record.with_context(
no_yaml_service_fields=True
)._prepare_record_for_yaml()
if record
else False
)
# Otherwise, return just the reference (or False if record does not exist)
return record.reference if record else False
# -- (YAML -> Record)
# Step 1: Process value in normal mode
record = False
# If the value is a string, it is treated as a reference
if isinstance(value, str):
reference = value
# If the value is a dictionary, extract the reference from it
elif isinstance(value, dict):
reference = value.get("reference")
record = self._update_or_create_related_record(
comodel, reference, value, create_immediately=True
)
else:
return False
# Step 2: Final fallback: attempt to retrieve the record by reference if set,
# return its ID or False
if not record and reference:
record = comodel.get_by_reference(reference)
return record.id if record else False
def _process_x2m_values(
self, comodel, field_type, values, explode_related_record, record_mode=False
):
"""Post process many2many value
Args:
comodel (BaseClass): Model the value belongs to
field_type (Char): Field type
values (list()): Values to process
explode_related_record (Bool): If True return entire record dict
instead of a reference
record_mode (Bool): If True process value as a record value
else process value as a YAML value
Returns:
dict() or Char: record dictionary if fetch_record else reference
"""
# -- (Record -> YAML)
if record_mode:
record_list = []
for value in values:
# Retrieve the record based on the ID provided in the value
record = comodel.browse(value)
# If the context specifies to explode the related record,
# return its dictionary representation
if explode_related_record:
record_list.append(
record.with_context(
no_yaml_service_fields=True
)._prepare_record_for_yaml()
if record
else False
)
# Otherwise, return just the reference
# (or False if record does not exist)
else:
record_list.append(record.reference if record else False)
return record_list
# -- (YAML -> Record)
# Step 1: Process value in normal mode
record_ids = []
for value in values:
record = False
# If the value is a string, it is treated as a reference
if isinstance(value, str):
reference = value
# If the value is a dictionary, extract the reference from it
elif isinstance(value, dict):
reference = value.get("reference")
record = self._update_or_create_related_record(
comodel,
reference,
value,
create_immediately=field_type == "many2many",
)
# Step 2: Final fallback: attempt to retrieve the record by reference
# Return record ID or False if reference is not defined
if not record and reference:
record = comodel.get_by_reference(reference)
# Save record data
if record:
record_ids.append(
record if isinstance(record, tuple) else (4, record.id)
)
return record_ids
def _update_or_create_related_record(
self, model, reference, values, create_immediately=False
):
"""Update related record with provided values or create a new one
Args:
model (BaseModel): Related record model
values (dict()): Values to update existing/create new record
reference (Char): Record reference
create_immediately (Bool): If True create a new record immediately.
Used for Many2one fields.
Context:
force_create_related_record (Bool): If True, create a new record
even if reference is provided.
Returns:
record: Existing record or new record tuple
"""
# If reference is found, retrieve the corresponding record
if reference and (
model._name in self._get_force_x2m_resolve_models()
or not self._context.get("force_create_related_record")
):
record = model.get_by_reference(reference)
# If the record exists, update it with the values from the dictionary
if record:
# Remove reference from values to avoid possible consequences
values.pop("reference", None)
record.with_context(from_yaml=True).write(
record._post_process_yaml_dict_values(values)
)
# If the record does not exist, create a new one
else:
if create_immediately:
record = model.with_context(from_yaml=True).create(
model._post_process_yaml_dict_values(values)
)
else:
# Use "Create" service command tuple
record = (0, 0, model._post_process_yaml_dict_values(values))
# If there's no reference but value is a dict, create a new record
else:
if create_immediately:
# Only 'reference' provided, no other data: do not create,
# just log warning
if set(values.keys()) == {"reference"}:
_logger.warning(
"Attempted to import a record for model '%s' with reference "
"'%s', but only the 'reference' field was provided. "
"It is possible that this record has already been imported. "
"Creation will be skipped.",
model._name,
reference,
)
return False
record = model.with_context(from_yaml=True).create(
model._post_process_yaml_dict_values(values)
)
else:
# Use "Create" service command tuple
record = (0, 0, model._post_process_yaml_dict_values(values))
# Return the record's ID if it exists, otherwise return False
return record or False

View File

@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"

View File

@@ -0,0 +1 @@
Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions.

View File

@@ -0,0 +1,3 @@
This module implements YAML format data import/export for [Cetmix Tower](https://cetmix.com/tower).
Please refer to the [official documentation](https://cetmix.com/tower) for detailed information.

View File

@@ -0,0 +1,69 @@
## 16.0.2.0.1 (2025-10-29)
- Features: Improve the way secrets are listed in the YAML import widget. (5010)
## 16.0.1.4.2 (2025-10-06)
- Bugfixes: Add the missing 'create' function decorator (4980)
## 16.0.1.4.1 (2025-08-26)
- Bugfixes: Make selection values lowercase to simplify their management. (4896)
## 16.0.1.3.0 (2025-07-30)
- Features: Optional behaviour when file uploaded by command already exists on the server. (4740)
## 16.0.1.1.4 (2025-07-08)
- Bugfixes: Fix missing model names in YAML exports when exporting multiple commands with flight plans (4820)
## 16.0.1.1.3 (2025-07-07)
- Bugfixes: Import servers with `Password` ssh authentication mode (4812)
## 16.0.1.1.1 (2025-06-23)
- Features: YAML code optimisation (4728)
## 16.0.1.1.0 (2025-06-20)
- Features: Export/import scheduled tasks to/from YAML. (4650)
## 16.0.1.0.5 (2025-05-21)
- Features: Export/import secret values related to Server. (4696)
## 16.0.1.0.4 (2025-05-16)
- Features: Export/import servers and files to/from YAML. (4670)
## 16.0.1.0.3 (2025-05-09)
- Bugfixes: Non-critical issues and performance improvements. (4663)
## 16.0.1.0.2 (2025-04-30)
- Features: User groups are visible without developer mode. (4642)
## 16.0.1.0.1 (2025-04-21)
- Features: Export additional fields for shortcuts, variables and options.
Add action menu to export keys/secrets. (4602)
## 16.0.1.0.0
Release for Odoo 16.0

View File

@@ -0,0 +1 @@
Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions.

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="ir_module_category_tower_yaml_export" model="ir.module.category">
<field name="parent_id" ref="cetmix_tower_server.ir_module_category_tower" />
<field name="name">YAML Export</field>
</record>
<record id="ir_module_category_tower_yaml_import" model="ir.module.category">
<field name="parent_id" ref="cetmix_tower_server.ir_module_category_tower" />
<field name="name">YAML Import</field>
</record>
<record id="group_export" model="res.groups">
<field name="name">Allow</field>
<field name="category_id" ref="ir_module_category_tower_yaml_export" />
<field name="comment">
Export data to YAML.
</field>
</record>
<record id="group_import" model="res.groups">
<field name="name">Allow</field>
<field name="category_id" ref="ir_module_category_tower_yaml_import" />
<field name="comment">
Import data from YAML.
</field>
</record>
</odoo>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo noupdate="1">
<!-- cx.tower.yaml.export.wiz -->
<record id="rule_cx_tower_yaml_export_wiz_creator_only" model="ir.rule">
<field name="name">Creator only</field>
<field name="model_id" ref="model_cx_tower_yaml_export_wiz" />
<field name="global" eval="True" />
<field name="domain_force">[('create_uid', '=', user.id)]</field>
</record>
<!-- cx.tower.yaml.export.wiz.download -->
<record
id="rule_cx_tower_yaml_export_wiz_download_creator_only"
model="ir.rule"
>
<field name="name">Creator only</field>
<field name="model_id" ref="model_cx_tower_yaml_export_wiz_download" />
<field name="global" eval="True" />
<field name="domain_force">[('create_uid', '=', user.id)]</field>
</record>
<!-- cx.tower.yaml.import.wiz -->
<record id="rule_cx_tower_yaml_import_wiz_creator_only" model="ir.rule">
<field name="name">Creator only</field>
<field name="model_id" ref="model_cx_tower_yaml_import_wiz" />
<field name="global" eval="True" />
<field name="domain_force">[('create_uid', '=', user.id)]</field>
</record>
<!-- cx.tower.yaml.import.wiz.upload -->
<record id="rule_cx_tower_yaml_import_wiz_upload_creator_only" model="ir.rule">
<field name="name">Creator only</field>
<field name="model_id" ref="model_cx_tower_yaml_import_wiz_upload" />
<field name="global" eval="True" />
<field name="domain_force">[('create_uid', '=', user.id)]</field>
</record>
</odoo>

View File

@@ -0,0 +1,9 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_yaml_export_wizard,Export YAML,model_cx_tower_yaml_export_wiz,group_export,1,1,1,1
access_yaml_export_wizard_download,Export YAML File,model_cx_tower_yaml_export_wiz_download,group_export,1,1,1,1
access_yaml_import_wizard_upload,Import YAML,model_cx_tower_yaml_import_wiz_upload,group_import,1,1,1,1
access_yaml_import_wizard,Import YAML,model_cx_tower_yaml_import_wiz,group_import,1,1,1,1
access_manifest_tmpl_read_export,Manifest tmpl read (export),model_cx_tower_yaml_manifest_tmpl,cetmix_tower_yaml.group_export,1,0,0,0
access_manifest_tmpl_admin,Manifest tmpl admin,model_cx_tower_yaml_manifest_tmpl,cetmix_tower_server.group_root,1,1,1,1
access_manifest_author_read_export,Manifest author read (export),model_cx_tower_yaml_manifest_author,cetmix_tower_yaml.group_export,1,0,0,0
access_manifest_author_admin,Manifest author admin,model_cx_tower_yaml_manifest_author,cetmix_tower_server.group_root,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yaml_export_wizard Export YAML model_cx_tower_yaml_export_wiz group_export 1 1 1 1
3 access_yaml_export_wizard_download Export YAML File model_cx_tower_yaml_export_wiz_download group_export 1 1 1 1
4 access_yaml_import_wizard_upload Import YAML model_cx_tower_yaml_import_wiz_upload group_import 1 1 1 1
5 access_yaml_import_wizard Import YAML model_cx_tower_yaml_import_wiz group_import 1 1 1 1
6 access_manifest_tmpl_read_export Manifest tmpl read (export) model_cx_tower_yaml_manifest_tmpl cetmix_tower_yaml.group_export 1 0 0 0
7 access_manifest_tmpl_admin Manifest tmpl admin model_cx_tower_yaml_manifest_tmpl cetmix_tower_server.group_root 1 1 1 1
8 access_manifest_author_read_export Manifest author read (export) model_cx_tower_yaml_manifest_author cetmix_tower_yaml.group_export 1 0 0 0
9 access_manifest_author_admin Manifest author admin model_cx_tower_yaml_manifest_author cetmix_tower_server.group_root 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,534 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Cetmix Tower YAML</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="cetmix-tower-yaml">
<h1 class="title">Cetmix Tower YAML</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:96e8f3f1df3ab25b952a9534d0914149740cc036b62efe2c7795f9d2d9636177
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_yaml"><img alt="cetmix/cetmix-tower" src="https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github" /></a></p>
<p>This module implements YAML format data import/export for <a class="reference external" href="https://cetmix.com/tower">Cetmix
Tower</a>.</p>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed information.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a></li>
<li><a class="reference internal" href="#usage" id="toc-entry-2">Usage</a></li>
<li><a class="reference internal" href="#changelog" id="toc-entry-3">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-4">16.0.2.0.1 (2025-10-29)</a></li>
<li><a class="reference internal" href="#section-2" id="toc-entry-5">16.0.1.4.2 (2025-10-06)</a></li>
<li><a class="reference internal" href="#section-3" id="toc-entry-6">16.0.1.4.1 (2025-08-26)</a></li>
<li><a class="reference internal" href="#section-4" id="toc-entry-7">16.0.1.3.0 (2025-07-30)</a></li>
<li><a class="reference internal" href="#section-5" id="toc-entry-8">16.0.1.1.4 (2025-07-08)</a></li>
<li><a class="reference internal" href="#section-6" id="toc-entry-9">16.0.1.1.3 (2025-07-07)</a></li>
<li><a class="reference internal" href="#section-7" id="toc-entry-10">16.0.1.1.1 (2025-06-23)</a></li>
<li><a class="reference internal" href="#section-8" id="toc-entry-11">16.0.1.1.0 (2025-06-20)</a></li>
<li><a class="reference internal" href="#section-9" id="toc-entry-12">16.0.1.0.5 (2025-05-21)</a></li>
<li><a class="reference internal" href="#section-10" id="toc-entry-13">16.0.1.0.4 (2025-05-16)</a></li>
<li><a class="reference internal" href="#section-11" id="toc-entry-14">16.0.1.0.3 (2025-05-09)</a></li>
<li><a class="reference internal" href="#section-12" id="toc-entry-15">16.0.1.0.2 (2025-04-30)</a></li>
<li><a class="reference internal" href="#section-13" id="toc-entry-16">16.0.1.0.1 (2025-04-21)</a></li>
<li><a class="reference internal" href="#section-14" id="toc-entry-17">16.0.1.0.0</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-18">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-19">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-20">Authors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-21">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#toc-entry-1">Configuration</a></h1>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed configuration
instructions.</p>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-2">Usage</a></h1>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed usage
instructions.</p>
</div>
<div class="section" id="changelog">
<h1><a class="toc-backref" href="#toc-entry-3">Changelog</a></h1>
<div class="section" id="section-1">
<h2><a class="toc-backref" href="#toc-entry-4">16.0.2.0.1 (2025-10-29)</a></h2>
<ul class="simple">
<li>Features: Improve the way secrets are listed in the YAML import
widget. (5010)</li>
</ul>
</div>
<div class="section" id="section-2">
<h2><a class="toc-backref" href="#toc-entry-5">16.0.1.4.2 (2025-10-06)</a></h2>
<ul class="simple">
<li>Bugfixes: Add the missing create function decorator (4980)</li>
</ul>
</div>
<div class="section" id="section-3">
<h2><a class="toc-backref" href="#toc-entry-6">16.0.1.4.1 (2025-08-26)</a></h2>
<ul class="simple">
<li>Bugfixes: Make selection values lowercase to simplify their
management. (4896)</li>
</ul>
</div>
<div class="section" id="section-4">
<h2><a class="toc-backref" href="#toc-entry-7">16.0.1.3.0 (2025-07-30)</a></h2>
<ul class="simple">
<li>Features: Optional behaviour when file uploaded by command already
exists on the server. (4740)</li>
</ul>
</div>
<div class="section" id="section-5">
<h2><a class="toc-backref" href="#toc-entry-8">16.0.1.1.4 (2025-07-08)</a></h2>
<ul class="simple">
<li>Bugfixes: Fix missing model names in YAML exports when exporting
multiple commands with flight plans (4820)</li>
</ul>
</div>
<div class="section" id="section-6">
<h2><a class="toc-backref" href="#toc-entry-9">16.0.1.1.3 (2025-07-07)</a></h2>
<ul class="simple">
<li>Bugfixes: Import servers with <tt class="docutils literal">Password</tt> ssh authentication mode
(4812)</li>
</ul>
</div>
<div class="section" id="section-7">
<h2><a class="toc-backref" href="#toc-entry-10">16.0.1.1.1 (2025-06-23)</a></h2>
<ul class="simple">
<li>Features: YAML code optimisation (4728)</li>
</ul>
</div>
<div class="section" id="section-8">
<h2><a class="toc-backref" href="#toc-entry-11">16.0.1.1.0 (2025-06-20)</a></h2>
<ul class="simple">
<li>Features: Export/import scheduled tasks to/from YAML. (4650)</li>
</ul>
</div>
<div class="section" id="section-9">
<h2><a class="toc-backref" href="#toc-entry-12">16.0.1.0.5 (2025-05-21)</a></h2>
<ul class="simple">
<li>Features: Export/import secret values related to Server. (4696)</li>
</ul>
</div>
<div class="section" id="section-10">
<h2><a class="toc-backref" href="#toc-entry-13">16.0.1.0.4 (2025-05-16)</a></h2>
<ul class="simple">
<li>Features: Export/import servers and files to/from YAML. (4670)</li>
</ul>
</div>
<div class="section" id="section-11">
<h2><a class="toc-backref" href="#toc-entry-14">16.0.1.0.3 (2025-05-09)</a></h2>
<ul class="simple">
<li>Bugfixes: Non-critical issues and performance improvements. (4663)</li>
</ul>
</div>
<div class="section" id="section-12">
<h2><a class="toc-backref" href="#toc-entry-15">16.0.1.0.2 (2025-04-30)</a></h2>
<ul class="simple">
<li>Features: User groups are visible without developer mode. (4642)</li>
</ul>
</div>
<div class="section" id="section-13">
<h2><a class="toc-backref" href="#toc-entry-16">16.0.1.0.1 (2025-04-21)</a></h2>
<ul class="simple">
<li>Features: Export additional fields for shortcuts, variables and
options. Add action menu to export keys/secrets. (4602)</li>
</ul>
</div>
<div class="section" id="section-14">
<h2><a class="toc-backref" href="#toc-entry-17">16.0.1.0.0</a></h2>
<p>Release for Odoo 16.0</p>
</div>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-18">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cetmix_tower_yaml%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-19">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-20">Authors</a></h2>
<ul class="simple">
<li>Cetmix</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-21">Maintainers</a></h2>
<p>This module is part of the <a class="reference external" href="https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_yaml">cetmix/cetmix-tower</a> project on GitHub.</p>
<p>You are welcome to contribute.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,8 @@
from . import test_command
from . import test_tower_yaml_mixin
from . import test_file_template
from . import test_plan
from . import test_yaml_export_wizard
from . import test_yaml_import_wizard
from . import test_server_log
from . import test_server_yaml

View File

@@ -0,0 +1,347 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import yaml
from odoo.exceptions import ValidationError
from odoo.tests import TransactionCase
class TestTowerCommand(TransactionCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
cls.Command = cls.env["cx.tower.command"]
# Expected YAML content of the test command
cls.command_test_yaml = """cetmix_tower_model: command
access_level: manager
reference: test_yaml_in_tests
name: Test YAML
action: ssh_command
allow_parallel_run: false
note: |-
Test YAML command conversion.
Ensure all fields are rendered properly.
os_ids: false
tag_ids: false
path: false
file_template_id: false
flight_plan_id: false
code: |-
cd /home/{{ tower.server.ssh_username }} \\
&& ls -lha
server_status: false
variable_ids: false
secret_ids: false
no_split_for_sudo: false
if_file_exists: skip
disconnect_file: false
"""
# YAML content translated into Python dict
cls.command_test_yaml_dict = yaml.safe_load(cls.command_test_yaml)
def test_yaml_from_command(self):
"""Test if YAML is generated properly from a command"""
# -- 0 --
# Create test command
# Test command
command_test = self.Command.create(
{
"name": "Test YAML",
"reference": "test_yaml_in_tests",
"action": "ssh_command",
"code": """cd /home/{{ tower.server.ssh_username }} \\
&& ls -lha""",
"note": """Test YAML command conversion.
Ensure all fields are rendered properly.""",
}
)
# -- 1 --
# Check it YAML generated by the command matches
# YAML from the template file
self.assertEqual(
command_test.yaml_code,
self.command_test_yaml,
"YAML generated from command doesn't match template file one",
)
# -- 2 --
# Check if YAML key values match Cetmix Tower ones
self.assertEqual(
command_test.access_level,
self.Command.TO_TOWER_ACCESS_LEVEL[
self.command_test_yaml_dict["access_level"]
],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command_test.action,
self.command_test_yaml_dict["action"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command_test.allow_parallel_run,
self.command_test_yaml_dict["allow_parallel_run"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command_test.code,
self.command_test_yaml_dict["code"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command_test.name,
self.command_test_yaml_dict["name"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command_test.note,
self.command_test_yaml_dict["note"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command_test.path,
self.command_test_yaml_dict["path"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command_test.reference,
self.command_test_yaml_dict["reference"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command_test.if_file_exists,
self.command_test_yaml_dict["if_file_exists"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command_test.disconnect_file,
self.command_test_yaml_dict["disconnect_file"],
"YAML value doesn't match Cetmix Tower one",
)
def test_command_from_yaml(self):
"""Test if YAML is generated properly from a command"""
def test_yaml(command):
"""Checks if yaml values are inserted correctly
Args:
command(cx.tower.command): _description_
"""
self.assertEqual(
command.access_level,
self.Command.TO_TOWER_ACCESS_LEVEL[
self.command_test_yaml_dict["access_level"]
],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command.action,
self.command_test_yaml_dict["action"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command.allow_parallel_run,
self.command_test_yaml_dict["allow_parallel_run"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command.code,
self.command_test_yaml_dict["code"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command.name,
self.command_test_yaml_dict["name"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command.note,
self.command_test_yaml_dict["note"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command.path,
self.command_test_yaml_dict["path"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command.reference,
self.command_test_yaml_dict["reference"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command.if_file_exists,
self.command_test_yaml_dict["if_file_exists"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
command.disconnect_file,
self.command_test_yaml_dict["disconnect_file"],
"YAML value doesn't match Cetmix Tower one",
)
# Create test command
command_test = self.Command.create(
{"name": "New Command", "action": "python_code"}
)
# -- 1 --
# Insert YAML into the command and
# check if YAML key values match Cetmix Tower ones
command_test.yaml_code = self.command_test_yaml
test_yaml(command_test)
# -- 2 --
# Insert some non supported keys and ensure nothing bad happens
yaml_with_non_supported_keys = """access_level: manager
action: ssh_command
doge: wow
memes: much nice!
allow_parallel_run: false
cetmix_tower_model: command
code: |-
cd /home/{{ tower.server.ssh_username }} \\
&& ls -lha
file_template_id: false
flight_plan_id: false
name: Test YAML
note: |-
Test YAML command conversion.
Ensure all fields are rendered properly.
path: false
reference: test_yaml_in_tests
tag_ids: false
"""
command_test.yaml_code = yaml_with_non_supported_keys
test_yaml(command_test)
# -- 3 --
# Insert non existing selection field value and exception is raised
yaml_with_non_supported_keys = """access_level: manager
action: non_existing_action
doge: wow
memes: much nice!
allow_parallel_run: false
cetmix_tower_model: command
code: |-
cd /home/{{ tower.server.ssh_username }} \\
&& ls -lha
file_template_id: false
flight_plan_id: false
name: Test YAML
note: |-
Test YAML command conversion.
Ensure all fields are rendered properly.
path: false
reference: test_yaml_in_tests
tag_ids: false
"""
with self.assertRaises(ValidationError) as e:
command_test.yaml_code = yaml_with_non_supported_keys
self.assertIn("non_existing_action", str(e.exception))
self.assertEqual(
str(e),
"Wrong value for cx.tower.command.action: 'non_existing_action'",
"Exception message doesn't match",
)
def test_command_with_action_file_template(self):
"""Test command with 'File from template' action"""
yaml_with_reference = """cetmix_tower_model: command
access_level: manager
reference: such_much_test_command
name: Such Much Command
action: file_using_template
allow_parallel_run: false
note: Just a note
os_ids: false
tag_ids: false
path: false
file_template_id: my_custom_test_template
flight_plan_id: false
code: false
server_status: false
variable_ids: false
secret_ids: false
no_split_for_sudo: false
if_file_exists: skip
disconnect_file: false
"""
# Add file template
file_template = self.env["cx.tower.file.template"].create(
{
"name": "Such much demo",
"reference": "my_custom_test_template",
"file_name": "much_logs.txt",
"server_dir": "/var/log/my/files",
"source": "tower",
"file_type": "text",
"note": "Hey!",
"keep_when_deleted": False,
}
)
command_with_template = self.Command.create(
{
"name": "Such Much Command",
"reference": "such_much_test_command",
"action": "file_using_template",
"note": "Just a note",
"file_template_id": file_template.id,
}
)
# -- 1 --
# Check if final YAML composed correctly
self.assertEqual(
command_with_template.yaml_code,
yaml_with_reference,
"YAML is not composed correctly",
)
# -- 2 --
# Explode related record and check the YAML
yaml_with_reference_exploded = """cetmix_tower_model: command
access_level: manager
reference: such_much_test_command
name: Such Much Command
action: file_using_template
allow_parallel_run: false
note: Just a note
os_ids: false
tag_ids: false
path: false
file_template_id:
reference: my_custom_test_template
name: Such much demo
source: tower
file_type: text
server_dir: /var/log/my/files
file_name: much_logs.txt
keep_when_deleted: false
tag_ids: false
note: Hey!
code: false
variable_ids: false
secret_ids: false
flight_plan_id: false
code: false
server_status: false
variable_ids: false
secret_ids: false
no_split_for_sudo: false
if_file_exists: skip
disconnect_file: false
"""
command_with_template.invalidate_recordset(["yaml_code"])
self.assertEqual(
command_with_template.with_context(explode_related_record=True).yaml_code,
yaml_with_reference_exploded,
"YAML is not composed correctly",
)

View File

@@ -0,0 +1,320 @@
import yaml
from odoo.tests import TransactionCase
class TestTowerFileTemplate(TransactionCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
cls.FileTemplate = cls.env["cx.tower.file.template"]
# Expected YAML content of the test file template
cls.file_template_test_yaml = """cetmix_tower_model: file_template
reference: dockerfile_unit_test
name: Dockerfile Test
source: tower
file_type: text
server_dir: /opt
file_name: Dockerfile
keep_when_deleted: true
tag_ids: false
note: |-
Used to build Odoo addons image.
Depends on Odoo core image.
code: |-
FROM odoo:{{ odoo_test_version }}
# Install git-aggregator and tools for requirements generation
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
# Let's go!
USER odoo
variable_ids: false
secret_ids: false
""" # noqa
# Expected YAML content of the test file template
# without empty x2mvalues
cls.file_template_test_yaml_no_empty_values = """cetmix_tower_model: file_template
reference: dockerfile_unit_test
name: Dockerfile Test
source: tower
file_type: text
server_dir: /opt
file_name: Dockerfile
keep_when_deleted: true
note: |-
Used to build Odoo addons image.
Depends on Odoo core image.
code: |-
FROM odoo:{{ odoo_test_version }}
# Install git-aggregator and tools for requirements generation
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
# Let's go!
USER odoo
""" # noqa
# YAML content translated into Python dict
cls.file_template_test_yaml_dict = yaml.safe_load(cls.file_template_test_yaml)
cls.file_template_test_yaml_dict_no_empty_values = yaml.safe_load(
cls.file_template_test_yaml_no_empty_values
)
def test_yaml_from_file_template(self):
"""Test if YAML is generated properly from a file"""
# -- 0 --
# Create test file
# Test file
file_template_test = self.FileTemplate.create(
{
"name": "Dockerfile Test",
"reference": "dockerfile_unit_test",
"file_name": "Dockerfile",
"server_dir": "/opt",
"source": "tower",
"keep_when_deleted": True,
"file_type": "text",
"code": """FROM odoo:{{ odoo_test_version }}
# Install git-aggregator and tools for requirements generation
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
# Let's go!
USER odoo""",
"note": """Used to build Odoo addons image.
Depends on Odoo core image.""",
}
)
# -- 1 --
# Check it YAML generated by the file matches
# YAML from the template file
self.assertEqual(
file_template_test.yaml_code,
self.file_template_test_yaml,
"YAML generated from file doesn't match template file one",
)
# -- 2 --
# Check if YAML key values match Cetmix Tower ones
self.assertEqual(
file_template_test.source,
self.file_template_test_yaml_dict["source"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.file_name,
self.file_template_test_yaml_dict["file_name"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.code,
self.file_template_test_yaml_dict["code"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.name,
self.file_template_test_yaml_dict["name"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.note,
self.file_template_test_yaml_dict["note"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.server_dir,
self.file_template_test_yaml_dict["server_dir"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.reference,
self.file_template_test_yaml_dict["reference"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.file_type,
self.file_template_test_yaml_dict["file_type"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.keep_when_deleted,
self.file_template_test_yaml_dict["keep_when_deleted"],
"YAML value doesn't match Cetmix Tower one",
)
def test_yaml_from_file_template_no_empty_values(self):
"""Test if YAML is generated properly from a file"""
# -- 0 --
# Create test file
# Test file
file_template_test = self.FileTemplate.with_context(
remove_empty_values=True
).create(
{
"name": "Dockerfile Test",
"reference": "dockerfile_unit_test",
"file_name": "Dockerfile",
"server_dir": "/opt",
"source": "tower",
"keep_when_deleted": True,
"file_type": "text",
"code": """FROM odoo:{{ odoo_test_version }}
# Install git-aggregator and tools for requirements generation
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
# Let's go!
USER odoo""",
"note": """Used to build Odoo addons image.
Depends on Odoo core image.""",
}
)
# -- 1 --
# Check it YAML generated by the file matches
# YAML from the template file
self.assertEqual(
file_template_test.yaml_code,
self.file_template_test_yaml_no_empty_values,
"YAML generated from file doesn't match template file one",
)
# -- 2 --
# Check if YAML key values match Cetmix Tower ones
self.assertEqual(
file_template_test.source,
self.file_template_test_yaml_dict_no_empty_values["source"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.file_name,
self.file_template_test_yaml_dict_no_empty_values["file_name"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.code,
self.file_template_test_yaml_dict_no_empty_values["code"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.name,
self.file_template_test_yaml_dict_no_empty_values["name"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.note,
self.file_template_test_yaml_dict_no_empty_values["note"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.server_dir,
self.file_template_test_yaml_dict_no_empty_values["server_dir"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.reference,
self.file_template_test_yaml_dict_no_empty_values["reference"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.file_type,
self.file_template_test_yaml_dict_no_empty_values["file_type"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template_test.keep_when_deleted,
self.file_template_test_yaml_dict_no_empty_values["keep_when_deleted"],
"YAML value doesn't match Cetmix Tower one",
)
def test_file_template_from_yaml(self):
"""Test if YAML is generated properly from a file"""
def test_yaml(file_template):
"""Checks if yaml values are inserted correctly
Args:
file_template (cx.tower.file.template): File template
"""
self.assertEqual(
file_template.source,
self.file_template_test_yaml_dict["source"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template.file_name,
self.file_template_test_yaml_dict["file_name"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template.code,
self.file_template_test_yaml_dict["code"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template.name,
self.file_template_test_yaml_dict["name"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template.note,
self.file_template_test_yaml_dict["note"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template.server_dir,
self.file_template_test_yaml_dict["server_dir"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template.reference,
self.file_template_test_yaml_dict["reference"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template.file_type,
self.file_template_test_yaml_dict["file_type"],
"YAML value doesn't match Cetmix Tower one",
)
self.assertEqual(
file_template.keep_when_deleted,
self.file_template_test_yaml_dict["keep_when_deleted"],
"YAML value doesn't match Cetmix Tower one",
)
# Create test file template
file_template_test = self.FileTemplate.create({"name": "New file template"})
# -- 1 --
# Insert YAML into the file and
# check if YAML key values match Cetmix Tower ones
file_template_test.yaml_code = self.file_template_test_yaml
test_yaml(file_template_test)
# -- 2 --
# Insert some non supported keys and ensure nothing bad happens
yaml_with_non_supported_keys = """cetmix_tower_model: file_template
code: |-
FROM odoo:{{ odoo_test_version }}
# Install git-aggregator and tools for requirements generation
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
# Let's go!
USER odoo
doge: SoMuch style!
file_name: Dockerfile
file_type: text
keep_when_deleted: true
name: Dockerfile Test
note: |-
Used to build Odoo addons image.
Depends on Odoo core image.
reference: dockerfile_unit_test
server_dir: /opt
source: tower
tag_ids: false
""" # noqa
file_template_test.yaml_code = yaml_with_non_supported_keys
test_yaml(file_template_test)

View File

@@ -0,0 +1,179 @@
from odoo.tests import TransactionCase
class TestTowerPlan(TransactionCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
cls.Plan = cls.env["cx.tower.plan"]
def test_plan_create_from_yaml(self):
"""Test plan creation from YAML."""
plan_yaml = """cetmix_tower_model: plan
access_level: manager
reference: test_plan_from_yaml
name: 'Test Plan From Yaml'
allow_parallel_run: false
color: 0
tag_ids:
- reference: doge_test_plan_tag
name: Doge Test Plan Tag
color: 1
on_error_action: e
custom_exit_code: 0
line_ids:
- sequence: 5
condition: false
use_sudo: false
path: /such/much/{{ test_plan_dir }}
command_id:
access_level: manager
reference: very_much_command_test
name: Very much command
action: ssh_command
allow_parallel_run: false
note: false
code: Such much code
variable_ids:
- cetmix_tower_model: variable
reference: test_plan_dir
name: Test Plan Directory
action_ids:
- sequence: 1
condition: ==
value_char: '0'
action: n
custom_exit_code: 0
variable_value_ids:
- cetmix_tower_model: variable_value
variable_id:
cetmix_tower_yaml_version: 1
cetmix_tower_model: variable
reference: test_plan_branch
name: Test Plan Branch
value_char: production
- cetmix_tower_model: variable_value
variable_id:
cetmix_tower_yaml_version: 1
cetmix_tower_model: variable
reference: test_plan_some_unique_variable
name: Test Plan Some Unique Variable
value_char: 'Final Value'
- cetmix_tower_model: plan_line_action
access_level: manager
sequence: 2
condition: '>'
value_char: '0'
action: ec
custom_exit_code: 255
variable_value_ids: false
variable_ids: false
"""
# -- 1 --
# Create plan from YAML
plan_form_yaml = self.Plan.create(
{"name": "Name Placeholder", "yaml_code": plan_yaml}
)
self.assertEqual(
plan_form_yaml.reference,
"test_plan_from_yaml",
"Reference is not set from YAML",
)
# Name should be set from YAML
self.assertEqual(
plan_form_yaml.name, "Test Plan From Yaml", "Name is not set from YAML"
)
# -- 2 --
# Check plan tags
plan_tags = plan_form_yaml.tag_ids
self.assertEqual(len(plan_tags), 1)
self.assertEqual(plan_tags.name, "Doge Test Plan Tag")
# -- 3 --
# Check plan lines
plan_lines = plan_form_yaml.line_ids
self.assertEqual(len(plan_lines), 1, "Line count is not 1")
self.assertFalse(plan_lines.condition, "Condition is not false")
self.assertEqual(
plan_lines.path,
"/such/much/{{ test_plan_dir }}",
"Path is not set from YAML",
)
self.assertEqual(
plan_lines.command_id.reference,
"very_much_command_test",
"Command reference is not set from YAML",
)
self.assertEqual(
plan_lines.command_id.name,
"Very much command",
"Command name is not set from YAML",
)
self.assertEqual(
plan_lines.command_id.action,
"ssh_command",
"Command action is not set from YAML",
)
self.assertFalse(
plan_lines.command_id.allow_parallel_run,
"Command allow parallel run is not set from YAML",
)
self.assertFalse(
plan_lines.command_id.note, "Command note is not set from YAML"
)
self.assertEqual(
plan_lines.command_id.variable_ids.mapped("reference"),
["test_plan_dir"],
"Command variable ids is not set from YAML",
)
self.assertEqual(
plan_lines.command_id.access_level,
"2",
"Command access level is not set from YAML",
)
# -- 4 --
# Check plan line actions
plan_actions = plan_form_yaml.line_ids.action_ids
self.assertEqual(len(plan_actions), 2, "Action count is not 2")
self.assertEqual(
plan_actions[0].condition, "==", "First action condition is not equal"
)
self.assertEqual(
plan_actions[0].value_char, "0", "First action value char is not 0"
)
self.assertEqual(plan_actions[0].action, "n", "First action action is not n")
self.assertEqual(
plan_actions[0].custom_exit_code,
0,
"First action custom exit code is not 0",
)
self.assertEqual(
len(plan_actions[0].variable_value_ids),
2,
"Number of variable value ids is not correct",
)
self.assertEqual(
plan_actions[0].variable_value_ids.mapped("value_char"),
["production", "Final Value"],
"Variable value chars are not correct",
)
self.assertEqual(
plan_actions[1].condition, ">", "Second action condition is not greater"
)
self.assertEqual(
plan_actions[1].value_char, "0", "Second action value char is not 0"
)
self.assertEqual(plan_actions[1].action, "ec", "Second action action is not ec")
self.assertEqual(
plan_actions[1].custom_exit_code,
255,
"Second action custom exit code is not 255",
)
self.assertFalse(
plan_actions[1].variable_value_ids,
"Second action variable value ids is not false",
)

View File

@@ -0,0 +1,127 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
"""
Tests for the cx.tower.server.log model YAML export/import.
Covers:
1. YAML export of a file-type log must include `file_id` and allow suffixes.
2. A full round-trip (export → delete → import) preserves the `file_id` relation.
3. Exporting a non-file log must include a falsy `file_id`.
4. Importing YAML with a bogus `file_id` reference raises ValidationError.
"""
import yaml
from odoo.tests import TransactionCase, tagged
@tagged("post_install", "-at_install")
class TestServerLog(TransactionCase):
"""YAML export/import tests for cx.tower.server.log."""
@classmethod
def setUpClass(cls):
super().setUpClass()
env = cls.env
cls.File = env["cx.tower.file"]
cls.Server = env["cx.tower.server"]
cls.ServerLog = env["cx.tower.server.log"]
# Create a file to reference from the log
cls.file = cls.File.create(
{
"name": "repos.yaml",
"reference": "reposyaml",
"source": "tower",
"file_type": "text",
"server_dir": "/tmp",
"code": "# Example\nHello, Tower!",
}
)
# Create a server (use password auth to satisfy constraints)
cls.server = cls.Server.create(
{
"name": "Srv-YAML-Test",
"reference": "srv_yaml_test",
"ip_v4_address": "127.0.0.1",
"ssh_username": "admin",
"ssh_port": 22,
"ssh_auth_mode": "p",
"ssh_password": "dummy",
"use_sudo": False,
}
)
# Create a file-type log linked to the file above
cls.log = cls.ServerLog.create(
{
"name": "Log from file",
"reference": "log_from_file",
"log_type": "file",
"file_id": cls.file.id,
"server_id": cls.server.id,
"use_sudo": False,
}
)
def test_yaml_export_contains_file_id(self):
"""Exported YAML must include a file_id starting with the file's reference."""
data = yaml.safe_load(self.log.yaml_code)
# Ensure file_id is present
self.assertIn("file_id", data, "`file_id` is missing from YAML export")
# Allow for auto-appended suffixes, so only check prefix
self.assertTrue(
data["file_id"].startswith(self.file.reference),
f"`file_id` value '{data['file_id']}' should start with "
f"'{self.file.reference}'",
)
def test_yaml_roundtrip_restores_file_id(self):
"""A full export→delete→import cycle must restore the file_id relation."""
yaml_dict = yaml.safe_load(self.log.yaml_code)
# Remove the original log
self.log.unlink()
# Recreate from YAML
vals = self.ServerLog._post_process_yaml_dict_values(yaml_dict)
restored = self.ServerLog.with_context(from_yaml=True).create(vals)
# Verify relation restored
self.assertEqual(
restored.file_id.id,
self.file.id,
"`file_id` was not restored after round-trip",
)
def test_yaml_export_without_file_id(self):
"""Logs of non-file type should not include file_id in YAML."""
cmd_log = self.ServerLog.create(
{
"name": "Log no file",
"reference": "log_no_file",
"log_type": "command",
"server_id": self.server.id,
"use_sudo": False,
}
)
data = yaml.safe_load(cmd_log.yaml_code)
# key is present, but must be falsy
self.assertIn("file_id", data, "`file_id` key is missing")
self.assertFalse(
data["file_id"],
"`file_id` for non-file log must be False/empty",
)
def test_yaml_import_with_missing_file_reference(self):
"""Missing file reference is accepted, but file_id stays empty."""
yaml_dict = yaml.safe_load(self.log.yaml_code)
yaml_dict["file_id"] = "does_not_exist"
vals = self.ServerLog._post_process_yaml_dict_values(yaml_dict)
new_log = self.ServerLog.with_context(from_yaml=True).create(vals)
# Log is created, but the relation is not resolved
self.assertFalse(
new_log.file_id,
"file_id should be empty when reference cannot be resolved",
)

View File

@@ -0,0 +1,124 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
"""
Tests for cx.tower.server YAML export/import covering command_ids and plan_ids.
"""
import yaml
from odoo.tests import TransactionCase, tagged
@tagged("post_install", "-at_install")
class TestServerYAML(TransactionCase):
"""YAML export/import tests for cx.tower.server with commands and plans."""
@classmethod
def setUpClass(cls):
super().setUpClass()
env = cls.env
cls.Server = env["cx.tower.server"]
cls.Command = env["cx.tower.command"]
cls.Plan = env["cx.tower.plan"]
# Create a command to attach (use defaults for access_level)
cls.command = cls.Command.create(
{
"name": "Test Command",
"reference": "test_command",
"action": "ssh_command",
"allow_parallel_run": False,
}
)
# Create a flight plan to attach
cls.plan = cls.Plan.create(
{
"name": "Test Flight Plan",
"reference": "test_plan",
"allow_parallel_run": False,
"color": 0,
}
)
# Create server and link command and plan
cls.server = cls.Server.create(
{
"name": "Server YAML Test",
"reference": "srv_yaml_test",
"ip_v4_address": "127.0.0.1",
"ssh_username": "admin",
"ssh_port": 22,
"ssh_auth_mode": "p",
"ssh_password": "dummy",
"use_sudo": False,
# Link the m2m fields
"command_ids": [(6, 0, [cls.command.id])],
"plan_ids": [(6, 0, [cls.plan.id])],
}
)
def test_yaml_export_contains_command_and_plan(self):
"""Exported YAML include command_ids and plan_ids with correct references."""
data = yaml.safe_load(self.server.yaml_code)
# Check command_ids
self.assertIn(
"command_ids",
data,
"`command_ids` is missing from YAML export",
)
self.assertIsInstance(
data["command_ids"], list, "`command_ids` should be a list in YAML"
)
self.assertTrue(
data["command_ids"],
"`command_ids` list should not be empty",
)
# Only reference should be exported
self.assertEqual(
data["command_ids"][0],
self.command.reference,
"Exported command reference does not match",
)
# Check plan_ids
self.assertIn(
"plan_ids",
data,
"`plan_ids` is missing from YAML export",
)
self.assertIsInstance(
data["plan_ids"], list, "`plan_ids` should be a list in YAML"
)
self.assertTrue(
data["plan_ids"],
"`plan_ids` list should not be empty",
)
self.assertEqual(
data["plan_ids"][0],
self.plan.reference,
"Exported plan reference does not match",
)
def test_yaml_roundtrip_restores_command_and_plan(self):
"""A full export→delete→import cycle must restore the m2m relations."""
yaml_dict = yaml.safe_load(self.server.yaml_code)
# Remove original server
self.server.unlink()
# Prepare values and import
vals = self.Server._post_process_yaml_dict_values(yaml_dict)
restored = self.Server.with_context(
from_yaml=True, skip_ssh_settings_check=True
).create(vals)
# Verify m2m links restored
self.assertEqual(
restored.command_ids.ids,
[self.command.id],
"`command_ids` were not restored correctly",
)
self.assertEqual(
restored.plan_ids.ids,
[self.plan.id],
"`plan_ids` were not restored correctly",
)

Some files were not shown because too many files have changed in this diff Show More