434 Commits

Author SHA1 Message Date
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
c945b52671 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:34 +00:00
146d71319e Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:33 +00:00
d6d2136df6 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:33 +00:00
9c9cc898a4 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:32 +00:00
be07a3b18d Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:31 +00:00
9f312687b1 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:31 +00:00
eadb83779e Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:30 +00:00
e244e8279b Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:30 +00:00
47fe5ea7a5 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:29 +00:00
05724afff0 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:28 +00:00
5578fb365a Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:28 +00:00
30a3b0dc4e Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:27 +00:00
51c5cb3bdb Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:27 +00:00
0849ae6161 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:26 +00:00
81ee76ce21 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:26 +00:00
dc76af271e Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:25 +00:00
5f868f7610 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:24 +00:00
cf31963487 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:24 +00:00
f1b923ae7f Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:23 +00:00
0484142dd5 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:23 +00:00
806c7ce8b8 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:22 +00:00
424742714d Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:21 +00:00
87b5247726 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:21 +00:00
e225e7b2a2 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:20 +00:00
1877e3c1ae Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:20 +00:00
3ea304cb45 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:19 +00:00
d49b02938a Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:18 +00:00
8db12c649f Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:18 +00:00
9c16569b69 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:17 +00:00
c2813bc9b3 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:17 +00:00
5111caa738 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:16 +00:00
2f302772e3 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:16 +00:00
0deb721477 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:15 +00:00
178f8e137e Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:14 +00:00
0769cb0756 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:14 +00:00
95485e2558 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:13 +00:00
ab144b1350 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:13 +00:00
4c6fd5e470 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:12 +00:00
05e045267a Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:11 +00:00
f5a9261856 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:11 +00:00
764642fbf1 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:10 +00:00
9170934142 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:10 +00:00
a0877d3ba4 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:09 +00:00
d236c96001 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:08 +00:00
5f76fc4ad5 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:08 +00:00
db4e11225b Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:07 +00:00
28987afc7d Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:07 +00:00
eff6288a42 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:06 +00:00
af344b5014 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:05 +00:00
e97a22516c Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:05 +00:00
d99c2f23a9 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:04 +00:00
49b0220cc1 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:04 +00:00
8f87c713f3 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:03 +00:00
eb2ad30e64 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:03 +00:00
1ebf77f1aa Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:02 +00:00
32e517b5ec Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:01 +00:00
fe243328a0 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:00 +00:00
070314632d Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:44:00 +00:00
b9c4a621dc Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:59 +00:00
5f9fb1597b Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:58 +00:00
62edb14057 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:58 +00:00
2b1c121be9 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:57 +00:00
51efac175a Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:57 +00:00
7e737b5877 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:56 +00:00
2beb85437a Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:56 +00:00
cfdd00e264 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:55 +00:00
034ea5c0bd Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:54 +00:00
8621fac655 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:54 +00:00
8a5b68926c Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:53 +00:00
22885f7fdd Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:53 +00:00
e3e51b8367 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:52 +00:00
019224ba4c Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:51 +00:00
833346a1a8 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:51 +00:00
c501af7d45 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:50 +00:00
aa1b8801ce Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:49 +00:00
b48081c8e2 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:49 +00:00
a366d1b52c Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:48 +00:00
5cb28ea01a Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:48 +00:00
30f1f2df49 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:47 +00:00
c416aabc44 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:47 +00:00
17d150a45f Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:46 +00:00
6211de488b Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:46 +00:00
5fd192356f Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:45 +00:00
929448f1ca Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:44 +00:00
8733b3cb61 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:44 +00:00
c5fa399627 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:43 +00:00
4ce9f94318 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:43 +00:00
281c0167b1 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:42 +00:00
96d4ad7ef7 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:42 +00:00
6ce7c48b2d Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:41 +00:00
c3bdb2c14d Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:40 +00:00
898b423feb Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:40 +00:00
7264942e8d Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:39 +00:00
3f23cfecf3 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:39 +00:00
23e386b526 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:38 +00:00
f0193a9307 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:38 +00:00
bbddf942a2 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:37 +00:00
f7d3a429a5 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:36 +00:00
89bba86349 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:36 +00:00
0a2076df37 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:35 +00:00
7da0bc5c93 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:34 +00:00
2c7bea7e69 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:34 +00:00
cc4bde613b Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:33 +00:00
a3e7e80ffb Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:33 +00:00
3d2174b4e8 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:32 +00:00
436bc160e3 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:31 +00:00
693821eb53 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:31 +00:00
495bb536f1 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:30 +00:00
a81ee9d711 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:30 +00:00
13f88ed1ed Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:29 +00:00
9c92bd8a2d Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:28 +00:00
9815bfd407 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:28 +00:00
42256b6283 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:27 +00:00
4d4b874ee0 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:27 +00:00
e741fd3d1c Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:26 +00:00
23db6fae45 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:26 +00:00
0cac17c395 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:25 +00:00
9bea4833ca Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:24 +00:00
d70d24cb7a Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:24 +00:00
ab129128b3 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:23 +00:00
8c292c2217 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:23 +00:00
0b504afdca Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:22 +00:00
fa76207199 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:22 +00:00
a6d3222ffc Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:21 +00:00
e7b8c1fc11 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:21 +00:00
226ecfa11e Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:20 +00:00
92a34a2292 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:19 +00:00
19b6b2caca Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:19 +00:00
448f814aae Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:18 +00:00
2537f4e58c Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:18 +00:00
d0059616aa Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:17 +00:00
8c8199abbd Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:17 +00:00
64825c8e84 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:16 +00:00
62cd370099 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:16 +00:00
9b528e38fc Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:15 +00:00
c45450ed87 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:14 +00:00
cc53a55c96 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:14 +00:00
9573216bfd Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:13 +00:00
2839e110fe Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:13 +00:00
ba9ce2ad88 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:12 +00:00
eb4d7a5477 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:11 +00:00
42d21b989a Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:11 +00:00
23cf3ad81b Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:10 +00:00
cce324dbfb Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:10 +00:00
9e1dcd02dd Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:09 +00:00
bfbe68ff88 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:09 +00:00
e07234573c Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:08 +00:00
5b2f53b33a Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:08 +00:00
14e7468ca7 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:07 +00:00
7df06465a8 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:07 +00:00
cb2eb054eb Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:06 +00:00
3ef2cc50fe Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:05 +00:00
d12d454c70 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:05 +00:00
9a17bcd25e Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:04 +00:00
73ebe069f6 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:03 +00:00
2755e373fd Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:03 +00:00
9e43910cc8 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:02 +00:00
1646ace09b Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:02 +00:00
c6c6570800 Tower: upload cetmix_tower_server 16.0.2.2.9 (via marketplace) 2026-04-27 08:43:01 +00:00
21576ec28f Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:43:00 +00:00
a5b60a5d3b Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:59 +00:00
abcb71d469 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:58 +00:00
4fdf6333f2 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:58 +00:00
c18aba668b Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:57 +00:00
0a8333e1e2 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:57 +00:00
3d19db5049 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:56 +00:00
62e7767925 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:56 +00:00
070c89e75e Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:55 +00:00
cf0d897dfa Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:54 +00:00
9fc1c6bd65 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:54 +00:00
342e616963 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:53 +00:00
a4c6f5c561 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:53 +00:00
811d32c5be Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:52 +00:00
135074c040 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:51 +00:00
af55099d83 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:51 +00:00
2d9f32fc2f Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:50 +00:00
d361711043 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:50 +00:00
426c0e0792 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:49 +00:00
ae451e5911 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:48 +00:00
31bcb48704 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:48 +00:00
25703173fb Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:47 +00:00
eab2080115 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:47 +00:00
5f99227e6c Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:46 +00:00
4a547632ac Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:45 +00:00
8cd9bae8ea Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:45 +00:00
ddadefa9a6 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:44 +00:00
7276688114 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:43 +00:00
66450d4d02 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:43 +00:00
2d0bda98b1 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:42 +00:00
89943c26eb Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:42 +00:00
0ac25c7405 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:41 +00:00
b54c955847 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:41 +00:00
857ec4fceb Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:40 +00:00
83ff1a0ec5 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:39 +00:00
ef85be3808 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:39 +00:00
25b80d98ce Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:38 +00:00
1871e1ffe9 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:38 +00:00
4440daa0a4 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:37 +00:00
6e018447b2 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:37 +00:00
5c4949bf5b Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:36 +00:00
90cb176847 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:36 +00:00
bdf8278b7f Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:35 +00:00
da1f2fd426 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:34 +00:00
4b1cbbc86b Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:34 +00:00
0957e4d55b Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:33 +00:00
6509c2136f Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:33 +00:00
52877f9b2c Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:32 +00:00
6176d27861 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:31 +00:00
37a160148d Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:31 +00:00
ee1501034b Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:30 +00:00
92e62ae21b Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:30 +00:00
7a5d6aa254 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:29 +00:00
42a4abb176 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:29 +00:00
440324c078 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:28 +00:00
7e9e92a179 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:27 +00:00
ad62d49f3d Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:27 +00:00
a562808d99 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:26 +00:00
7f6a00a8f7 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:26 +00:00
f04db1b076 Tower: upload cetmix_tower_git 16.0.2.0.2 (via marketplace) 2026-04-27 08:42:25 +00:00
421 changed files with 98480 additions and 150 deletions

View File

@@ -0,0 +1,125 @@
================
Cetmix Tower Git
================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:583744e8956f294682a551fc082f086b174b8d2b72652c21b1dd68f3933e7211
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |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_git
:alt: cetmix/cetmix-tower
|badge1| |badge2| |badge3|
This module implements Git Management functionality for `Cetmix
Tower <https://cetmix.com/tower>`__.
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.2.0.1 (2025-12-11)
-----------------------
- Features: Improve search views, implement the search panel for
selected views. (5139)
16.0.2.0.0 (2025-10-27)
-----------------------
- Features: Major refactoring: implement Git repository entity. (4914)
16.0.1.0.6 (2025-08-18)
-----------------------
- Features: Link or copy a git project when uploading the linked file
using command (4759)
16.0.1.0.5 (2025-08-17)
-----------------------
- Features: Search servers by git reference (4838)
16.0.1.0.4 (2025-07-29)
-----------------------
- Features: Export related commands and flight plans together with
server (4849)
16.0.1.0.3 (2025-05-23)
-----------------------
- Bugfixes: Duplicated file is created when importing a YAML file with a
git project. (4715)
16.0.1.0.2 (2025-05-16)
-----------------------
- Features: Record references for git relations. (4670)
16.0.1.0.1 (2025-05-09)
-----------------------
- Bugfixes: Non-critical issues and performance improvements. (4663)
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_git%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_git>`_ project on GitHub.
You are welcome to contribute.

View File

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

View File

@@ -0,0 +1,40 @@
# Copyright Cetmix OU
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Cetmix Tower Git",
"summary": "Cetmix Tower Git Management Tools",
"version": "16.0.2.0.2",
"development_status": "Beta",
"category": "Productivity",
"website": "https://tower.cetmix.com",
"author": "Cetmix",
"license": "AGPL-3",
"application": False,
"depends": ["cetmix_tower_yaml"],
"external_dependencies": {
"python": ["giturlparse==0.12.0"],
},
"data": [
"security/ir.model.access.csv",
"security/cx_tower_git_project_security.xml",
"security/cx_tower_git_source_security.xml",
"security/cx_tower_git_remote_security.xml",
"security/cx_tower_git_repo_security.xml",
"security/cx_tower_git_repo_owner_security.xml",
"security/cx_tower_git_project_rel_security.xml",
"security/cx_tower_git_project_file_template_rel_security.xml",
"views/cx_tower_git_project_views.xml",
"views/cx_tower_git_source_views.xml",
"views/cx_tower_git_remote_views.xml",
"views/cx_tower_git_repo_views.xml",
"views/cx_tower_git_repo_owner_views.xml",
"views/cx_tower_file_views.xml",
"views/cx_tower_file_template_views.xml",
"views/cx_tower_server_view.xml",
"views/cx_tower_plan_line_view.xml",
"views/menuitems.xml",
],
"demo": [
"demo/demo_data.xml",
],
}

View File

@@ -0,0 +1,166 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<!-- Git Project -->
<record id="git_project_demo" model="cx.tower.git.project">
<field name="name">Demo Git Project</field>
<field name="reference">demo_git_project</field>
<field name="note">This is a demo git project.</field>
</record>
<!-- Repositories -->
<record id="repo_demo_cetmix_tower" model="cx.tower.git.repo">
<field name="url">https://github.com/cetmix-demo/cetmix-tower-demo.git</field>
</record>
<record id="repo_demo_oca_web" model="cx.tower.git.repo">
<field name="url">https://github.com/oca-demo/web-demo.git</field>
</record>
<record id="repo_demo_odoo_enterprise" model="cx.tower.git.repo">
<field name="url">https://github.com/odoo-demo/enterprise-demo.git</field>
<field name="is_private" eval="True" />
</record>
<record id="repo_demo_gitlab_private" model="cx.tower.git.repo">
<field name="url">https://gitlab.com/cetmix-demo/cetmix-tower-demo.git</field>
<field name="is_private" eval="True" />
</record>
<record id="repo_demo_bitbucket_private" model="cx.tower.git.repo">
<field
name="url"
>https://bitbucket.com/cetmix-demo/cetmix-tower-demo-enterprise.git</field>
<field name="is_private" eval="True" />
</record>
<!-- Sources -->
<!-- Cetmix Tower -->
<record id="source_demo_cetmix_tower" model="cx.tower.git.source">
<field name="name">Cetmix Tower</field>
<field name="reference">cetmix_tower</field>
<field name="git_project_id" ref="git_project_demo" />
</record>
<!-- Remotes-->
<record id="remote_demo_cetmix_tower_14_0_dev" model="cx.tower.git.remote">
<field name="source_id" ref="source_demo_cetmix_tower" />
<field name="repo_id" ref="repo_demo_cetmix_tower" />
<field name="head_type">branch</field>
<field name="head">14.0</field>
</record>
<record id="remote_demo_cetmix_tower_pr_176" model="cx.tower.git.remote">
<field name="source_id" ref="source_demo_cetmix_tower" />
<field name="repo_id" ref="repo_demo_cetmix_tower" />
<field name="head_type">pr</field>
<field name="head">176</field>
</record>
<!-- OCA Web -->
<record id="source_demo_oca_web" model="cx.tower.git.source">
<field name="name">OCA Web</field>
<field name="reference">oca_web</field>
<field name="git_project_id" ref="git_project_demo" />
</record>
<!-- Remotes -->
<record id="remote_demo_oca_web_14_0" model="cx.tower.git.remote">
<field name="source_id" ref="source_demo_oca_web" />
<field name="repo_id" ref="repo_demo_oca_web" />
<field name="head_type">branch</field>
<field name="head">14.0</field>
</record>
<!-- Odoo Enterprise -->
<record id="source_demo_odoo_enterprise" model="cx.tower.git.source">
<field name="name">Odoo Enterprise (Private)</field>
<field name="reference">odoo_enterprise</field>
<field name="git_project_id" ref="git_project_demo" />
</record>
<!-- Remotes -->
<record id="remote_demo_odoo_enterprise" model="cx.tower.git.remote">
<field name="source_id" ref="source_demo_odoo_enterprise" />
<field name="repo_id" ref="repo_demo_odoo_enterprise" />
<field name="head_type">branch</field>
<field name="head">19.0</field>
<field name="is_private" eval="True" />
</record>
<!-- Sample Private Gitlab -->
<record id="source_demo_gitlab_private" model="cx.tower.git.source">
<field name="name">Sample Semi Private Gitlab</field>
<field name="reference">gitlab_private</field>
<field name="git_project_id" ref="git_project_demo" />
</record>
<!-- Remotes -->
<record id="remote_demo_gitlab_private_main" model="cx.tower.git.remote">
<field name="source_id" ref="source_demo_gitlab_private" />
<field name="repo_id" ref="repo_demo_gitlab_private" />
<field name="head_type">branch</field>
<field name="head">main</field>
</record>
<record id="remote_demo_gitlab_private_mr_1234" model="cx.tower.git.remote">
<field name="source_id" ref="source_demo_gitlab_private" />
<field name="repo_id" ref="repo_demo_gitlab_private" />
<field name="head_type">pr</field>
<field name="head">1234</field>
</record>
<!-- Sample Private Bitbucket -->
<record id="source_demo_bitbucket_private" model="cx.tower.git.source">
<field name="name">Sample Private Bitbucket</field>
<field name="reference">bitbucket_private</field>
<field name="git_project_id" ref="git_project_demo" />
</record>
<!-- Remotes -->
<record id="remote_demo_bitbucket_private_main" model="cx.tower.git.remote">
<field name="source_id" ref="source_demo_bitbucket_private" />
<field name="repo_id" ref="repo_demo_bitbucket_private" />
<field name="head_type">branch</field>
<field name="head">dev</field>
</record>
<record id="remote_demo_bitbucket_private_feature" model="cx.tower.git.remote">
<field name="source_id" ref="source_demo_bitbucket_private" />
<field name="repo_id" ref="repo_demo_bitbucket_private" />
<field name="head_type">commit</field>
<field name="head">1234567890</field>
</record>
<!-- Files -->
<record id="file_demo_cetmix_tower_14_0_dev" model="cx.tower.file">
<field name="name">repos.yaml</field>
<field name="server_id" ref="cetmix_tower_server.server_demo_1" />
<field name="source">tower</field>
<field name="file_type">text</field>
<field name="server_dir">{{ instance_name }}/config</field>
</record>
<!-- Link file to git project -->
<record
id="git_project_rel_demo_cetmix_tower_14_0_dev"
model="cx.tower.git.project.rel"
>
<field name="git_project_id" ref="git_project_demo" />
<field name="server_id" ref="cetmix_tower_server.server_demo_1" />
<field name="file_id" ref="file_demo_cetmix_tower_14_0_dev" />
<field name="project_format">git_aggregator</field>
</record>
<!-- Demo variable for testing giturlparse -->
<record id="variable_demo_git_url" model="cx.tower.variable">
<field name="name">Demo Git URL</field>
<field name="reference">demo_git_url</field>
</record>
<!-- Demo command to test giturlparse -->
<record id="command_demo_git_url" model="cx.tower.command">
<field name="name">Parse Git URL</field>
<field name="action">python_code</field>
<field name="code">
if {{ demo_git_url }}:
parsed_url = giturlparse.parse({{ demo_git_url }})
repo = parsed_url.repo
owner = parsed_url.owner
host = parsed_url.host
platform = parsed_url.platform
message = "Repo: " + repo + ", Owner: " + owner + ", Host: " + host + ", Platform: " + platform
result={"exit_code": 0, "message": message}
else:
result={"exit_code": -100, "message": "Git URL is not defined!"}
</field>
<field name="access_level">1</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_custom')])]"
/>
<field name="note">Run Python Code: Check Branch</field>
</record>
</odoo>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,595 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * cetmix_tower_git
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
"Language: fi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0
#, python-format
msgid ""
"\n"
"# You need to set the following variables in your environment:\n"
"# %(vars)s \n"
"# and run git-aggregator with '--expand-env' parameter.\n"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0
#, python-format
msgid ""
"# This file is generated with Cetmix Tower https://cetmix.com/tower\n"
"# It's designed to be used with git-aggregator tool developed by Acsone.\n"
"# Documentation for git-aggregator: https://github.com/acsone/git-aggregator\n"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "* Sources where all remotes are private"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "* Sources where some remotes are private"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid ""
"<b>Managers.</b> All users who have \"Manager\" group and are set as \"Managers\" in <b><u>all</u></b> related servers.\n"
" This is done to avoid unpredictable consequences when some of the servers are not updated due to access restrictions when a project is updated."
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid ""
"<b>Users.</b> All users who have \"Manager\" group and are either set in "
"\"Users\" or in \"Managers\" in <b><u>all</u></b> related servers."
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "Access"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__active
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__active
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__active
msgid "Active"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "Archived"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__bitbucket
msgid "Bitbucket"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__branch
msgid "Branch"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form
msgid "Branch/PR/commit number or link"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__reference
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__reference
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__reference
msgid ""
"Can contain English letters, digits and '_'. Leave blank to autogenerate"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project
msgid "Cetmix Tower Git Configuration"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_remote
msgid "Cetmix Tower Git Remote"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_source
msgid "Cetmix Tower Git Source"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_server
msgid "Cetmix Tower Server"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0
#, python-format
msgid "Code generator function for '%(project_format)s' format not found."
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__commit
msgid "Commit"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_uid
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_uid
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_uid
msgid "Created by"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_date
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_date
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_date
msgid "Created on"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_file
msgid "Cx Tower File"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid "Disabled"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__display_name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__display_name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__display_name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__display_name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__display_name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__display_name
msgid "Display Name"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__enabled
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__enabled
msgid "Enable in configuration and exported to files"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__enabled
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__enabled
msgid "Enabled"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "Export YAML"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__file_id
msgid "File"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0
#, python-format
msgid "File '%(file)s' doesn't belong to server '%(server)s'"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_file.py:0
#, python-format
msgid ""
"File '%(file)s' is related to multiple projects: %(projects)s \n"
"Please select only one project."
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.constraint,message:cetmix_tower_git.constraint_cx_tower_git_project_rel_project_server_file_format_uniq
msgid "File is already related to the same project and format"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__file_ids
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "Files"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__project_format
msgid "Format"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir
msgid "Git Aggregator Root Dir"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid ""
"Git Aggregator: Bitbucket does not support fetching PRs. Please use branch instead.\n"
"\n"
"Source: %(src)s\n"
"URL: %(url)s\n"
"Head: %(head)s"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid "Git Aggregator: Head number is empty in %(head)s"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__git_project_id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__git_project_id
msgid "Git Configuration"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__git_project_id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_ids
msgid "Git Project"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_rel_ids
msgid "Git Project Rel"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_project_rel_ids
msgid "Git Project Server File Relations"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project_rel
msgid "Git Project relation to other model records"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.actions.act_window,name:cetmix_tower_git.cx_tower_git_project_action
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_ids
#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_project
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_server_view_form
msgid "Git Projects"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir
msgid ""
"Git aggregator root directory where sources will be cloned. Eg '/tmp/git-"
"aggregator' Will use '.' if not set"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid ""
"Git aggregator root directory where sources will be cloned. Leave blank to "
"use '.'"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__url
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form
msgid ""
"Git remote URL. Eg 'https://github.com/cetmix/cetmix-tower.git' or "
"'git@github.com:cetmix/cetmix-tower.git'"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__head
msgid ""
"Git remote head. Link to branch, PR, commit or commit hash. Leave blank to "
"auto-detect"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__github
msgid "GitHub"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__gitlab
msgid "GitLab"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__https
msgid "HTTPS"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes
msgid "Has Partially Private Remotes"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes
msgid "Has Private Remotes"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head
msgid "Head"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head_type
msgid "Head Type"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__id
msgid "ID"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes
msgid "Indicates if the project has any partially private remotes."
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes
msgid "Indicates if the project has any private remotes."
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__is_private
msgid "Is Private"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file____last_update
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project____last_update
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel____last_update
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote____last_update
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source____last_update
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server____last_update
msgid "Last Modified on"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_uid
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_uid
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_uid
msgid "Last Updated by"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_date
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_date
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_date
msgid "Last Updated on"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__manager_ids
msgid "Managers"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__manager_ids
msgid "Managers who can modify this record"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__name
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid "Name"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid "Not a valid URL. URL must end with '.git'"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid "Not a valid URL. URL must start with 'https://' or 'git@'"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid ""
"Not a valid URL: %(url_msg)s\n"
"URL must contain at least two parts separated by dot."
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__other
msgid "Other"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid "Private"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count_private
msgid "Private Remotes"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__pr
msgid "Pull/Merge Request"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__reference
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__reference
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__reference
msgid "Reference"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid ""
"Reference. Can contain English letters, digits and '_'. Leave blank to "
"autogenerate"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_ids
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid "Remotes"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__repo_provider
msgid "Repository Provider"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__is_private
msgid "Repository is private"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__ssh
msgid "SSH"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__sequence
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__sequence
msgid "Sequence"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__server_id
msgid "Server"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__server_ids
msgid "Servers"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__server_ids
msgid ""
"Servers are added automatically based on the files linked to the project.\n"
"IMPORTANT: This field may contain duplicates because of the relation nature!"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__source_id
msgid "Source"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__source_ids
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "Sources"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid ""
"The top one remote will be used as a merge target.\n"
" You can re-arrange remotes by dragging them or changing their sequence value."
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url
msgid "URL"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url_protocol
msgid "URL Protocol"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid "URL is required"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__user_ids
msgid "Users"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__user_ids
msgid "Users who can view this record"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__repo_provider
msgid ""
"Will be tried to be determined from the URL. Please select manually if auto-"
"detection fails."
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "YAML"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__yaml_code
msgid "Yaml Code"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid ""
"You can edit these fields at your own risk. However keep in mind that they "
"will be automatically updated each time related servers are added, removed "
"or updated."
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "You must be a member of the \"YAML/Export\" group to export data as YAML."
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "managers who can modify this record"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "users who can view this record"
msgstr ""

View File

@@ -0,0 +1,635 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * cetmix_tower_git
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-03-06 09:11+0000\n"
"Last-Translator: Bole <bole@dajmi5.com>\n"
"Language-Team: Croatian <https://hosted.weblate.org/projects/"
"tower-server-14-0-dev/cetmix_tower_git/hr/>\n"
"Language: hr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"X-Generator: Weblate 5.10.3-dev\n"
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0
#, python-format
msgid ""
"\n"
"# You need to set the following variables in your environment:\n"
"# %(vars)s \n"
"# and run git-aggregator with '--expand-env' parameter.\n"
msgstr ""
"\n"
"# Potrebno je postaviti sljedeće varijable u vaše okruženje:\n"
"# %(vars)s \n"
"# i pokrenuti git-aggregator sa ' --expand-env' parametrom\n"
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0
#, python-format
msgid ""
"# This file is generated with Cetmix Tower https://cetmix.com/tower\n"
"# It's designed to be used with git-aggregator tool developed by Acsone.\n"
"# Documentation for git-aggregator: https://github.com/acsone/git-aggregator\n"
msgstr ""
"# Ova datotek aje generiran pomoću Cetmix Tower sustava: https://cetmix.com/"
"tower\n"
"# Dizajniran je za korištenje sa git-aggregator alatom razvijenim od Ascone."
"\n"
"# Dokumentacija za git-aggregator : https://github.com/acsone/git-"
"aggregator\n"
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "* Sources where all remotes are private"
msgstr "* izvori sa svim udaljenim lokacijama koje su privatne"
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "* Sources where some remotes are private"
msgstr "* Izvori u kojima su neke udaljene lokacije privatne"
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid ""
"<b>Managers.</b> All users who have \"Manager\" group and are set as \"Managers\" in <b><u>all</u></b> related servers.\n"
" This is done to avoid unpredictable consequences when some of the servers are not updated due to access restrictions when a project is updated."
msgstr ""
"<b>Manageri.</b> Svi korisnici koji imaju \"Manager\" grupu i postavljeni su "
"kao \"Manageri\" u <b><u>svim</u></b> povezanim serverima.\n"
" "
"Ovo je napravljeno kako bi izbjegli nepredviđene posljedice kad neki od "
"servera nisu ažurirani zbog ograničenog pristupa prilikom ažuriranja "
"projekta."
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid ""
"<b>Users.</b> All users who have \"Manager\" group and are either set in "
"\"Users\" or in \"Managers\" in <b><u>all</u></b> related servers."
msgstr ""
"<b>Korisnici.</b> Svi korisnici koji imaju \"Manager\" grupu i postavljeni "
"su ili kao \"Korisnik\" ili kao \"Manager\" u <b><u>svim</u></b> povezanim "
"serverima."
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "Access"
msgstr "Pristup"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__active
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__active
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__active
msgid "Active"
msgstr "Aktivno"
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "Archived"
msgstr "Arhivirano"
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__bitbucket
msgid "Bitbucket"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__branch
msgid "Branch"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form
msgid "Branch/PR/commit number or link"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__reference
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__reference
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__reference
msgid ""
"Can contain English letters, digits and '_'. Leave blank to autogenerate"
msgstr ""
"Može sadržavati slova engleske abecede, brojke i ':'. Ostavite prazno za "
"automatsko generiranje"
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project
msgid "Cetmix Tower Git Configuration"
msgstr "Cetmix Tower Git postavke"
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_remote
msgid "Cetmix Tower Git Remote"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_source
msgid "Cetmix Tower Git Source"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_server
msgid "Cetmix Tower Server"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0
#, python-format
msgid "Code generator function for '%(project_format)s' format not found."
msgstr ""
"Funkcija generiranja koda za '%(project_format)s' format nije pronađena."
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__commit
msgid "Commit"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_uid
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_uid
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_uid
msgid "Created by"
msgstr "Kreirao"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_date
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_date
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_date
msgid "Created on"
msgstr "Kreirano"
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_file
msgid "Cx Tower File"
msgstr "Cx Tower datoteka"
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid "Disabled"
msgstr "Onemogućen"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__display_name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__display_name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__display_name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__display_name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__display_name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__display_name
msgid "Display Name"
msgstr "Naziv"
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__enabled
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__enabled
msgid "Enable in configuration and exported to files"
msgstr "Omogućen u postavkama i izvezen u datoteku"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__enabled
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__enabled
msgid "Enabled"
msgstr "Omogućen"
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "Export YAML"
msgstr "Izvoz YAML"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__file_id
msgid "File"
msgstr "Datoteka"
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0
#, python-format
msgid "File '%(file)s' doesn't belong to server '%(server)s'"
msgstr "Datoteka '%(file)s' ne pripada serveru '%(server)s'"
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_file.py:0
#, python-format
msgid ""
"File '%(file)s' is related to multiple projects: %(projects)s \n"
"Please select only one project."
msgstr ""
"Datoteka '%(file)s' je povezana sa višestrukim projektima: %(projects)s \n"
"Molim odaberite samo jedan projekt."
#. module: cetmix_tower_git
#: model:ir.model.constraint,message:cetmix_tower_git.constraint_cx_tower_git_project_rel_project_server_file_format_uniq
msgid "File is already related to the same project and format"
msgstr "Datoteka je već povezana sa ovim projektom i ovim formatom"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__file_ids
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "Files"
msgstr "Datoteke"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__project_format
msgid "Format"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir
msgid "Git Aggregator Root Dir"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid ""
"Git Aggregator: Bitbucket does not support fetching PRs. Please use branch instead.\n"
"\n"
"Source: %(src)s\n"
"URL: %(url)s\n"
"Head: %(head)s"
msgstr ""
"Git Aggregator: BitBucket ne podržava dohvaćanje PRova. Molim koristite "
"branch.\n"
"\n"
"Source: %(src)s\n"
"URL: %(url)s\n"
"Head: %(head)s"
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid "Git Aggregator: Head number is empty in %(head)s"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__git_project_id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__git_project_id
msgid "Git Configuration"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__git_project_id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_ids
msgid "Git Project"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_rel_ids
msgid "Git Project Rel"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_project_rel_ids
msgid "Git Project Server File Relations"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project_rel
msgid "Git Project relation to other model records"
msgstr "Git projekt povezan sa ostalim zapisima modela"
#. module: cetmix_tower_git
#: model:ir.actions.act_window,name:cetmix_tower_git.cx_tower_git_project_action
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_ids
#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_project
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_server_view_form
msgid "Git Projects"
msgstr "Git projekti"
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir
msgid ""
"Git aggregator root directory where sources will be cloned. Eg '/tmp/git-"
"aggregator' Will use '.' if not set"
msgstr ""
"GitAgregator izvorni direktorij u koji će izvori biti klonirani. Npr. '/tmp/"
"git-aggregator' Ako ništa nije postavljeno koristi se '.'"
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid ""
"Git aggregator root directory where sources will be cloned. Leave blank to "
"use '.'"
msgstr ""
"GitAgregtor izvorni dirketorij u koji će izvori biti klonirani. Ostavite "
"prazno za korištenje '.'"
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__url
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form
msgid ""
"Git remote URL. Eg 'https://github.com/cetmix/cetmix-tower.git' or "
"'git@github.com:cetmix/cetmix-tower.git'"
msgstr ""
"Git remote URL. Eg 'https://github.com/cetmix/cetmix-tower.git' ili "
"'git@github.com:cetmix/cetmix-tower.git'"
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__head
msgid ""
"Git remote head. Link to branch, PR, commit or commit hash. Leave blank to "
"auto-detect"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__github
msgid "GitHub"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__gitlab
msgid "GitLab"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__https
msgid "HTTPS"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes
msgid "Has Partially Private Remotes"
msgstr "Ima djelomično privatne udaljene izvore"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes
msgid "Has Private Remotes"
msgstr "Ima privatne udaljene izvore"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head
msgid "Head"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head_type
msgid "Head Type"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__id
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__id
msgid "ID"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes
msgid "Indicates if the project has any partially private remotes."
msgstr "Indicira ima li projekt djelomično privatnih udaljenih izvora."
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes
msgid "Indicates if the project has any private remotes."
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__is_private
msgid "Is Private"
msgstr "Je privatno"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file____last_update
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project____last_update
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel____last_update
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote____last_update
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source____last_update
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server____last_update
msgid "Last Modified on"
msgstr "Zadnje modificirano"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_uid
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_uid
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_uid
msgid "Last Updated by"
msgstr "Zadnji ažurirao"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_date
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_date
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_date
msgid "Last Updated on"
msgstr "Zadnje ažurirano"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__manager_ids
msgid "Managers"
msgstr "Manageri"
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__manager_ids
msgid "Managers who can modify this record"
msgstr "Manageri koji mogu urediti ovaj zapis"
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__name
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__name
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid "Name"
msgstr "Naziv"
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid "Not a valid URL. URL must end with '.git'"
msgstr "Nije valjani URL. URL mora završavati sa '.git'"
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid "Not a valid URL. URL must start with 'https://' or 'git@'"
msgstr "Nije valjani URL. URL mora počinjati sa 'https://' ili 'git@'"
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid ""
"Not a valid URL: %(url_msg)s\n"
"URL must contain at least two parts separated by dot."
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__other
msgid "Other"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid "Private"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count_private
msgid "Private Remotes"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__pr
msgid "Pull/Merge Request"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__reference
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__reference
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__reference
msgid "Reference"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid ""
"Reference. Can contain English letters, digits and '_'. Leave blank to "
"autogenerate"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_ids
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid "Remotes"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__repo_provider
msgid "Repository Provider"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__is_private
msgid "Repository is private"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__ssh
msgid "SSH"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__sequence
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__sequence
msgid "Sequence"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__server_id
msgid "Server"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__server_ids
msgid "Servers"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__server_ids
msgid ""
"Servers are added automatically based on the files linked to the project.\n"
"IMPORTANT: This field may contain duplicates because of the relation nature!"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__source_id
msgid "Source"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__source_ids
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "Sources"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form
msgid ""
"The top one remote will be used as a merge target.\n"
" You can re-arrange remotes by dragging them or changing their sequence value."
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url
msgid "URL"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url_protocol
msgid "URL Protocol"
msgstr ""
#. module: cetmix_tower_git
#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0
#, python-format
msgid "URL is required"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__user_ids
msgid "Users"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__user_ids
msgid "Users who can view this record"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__repo_provider
msgid ""
"Will be tried to be determined from the URL. Please select manually if auto-"
"detection fails."
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "YAML"
msgstr ""
#. module: cetmix_tower_git
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__yaml_code
msgid "Yaml Code"
msgstr "YAML kod"
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid ""
"You can edit these fields at your own risk. However keep in mind that they "
"will be automatically updated each time related servers are added, removed "
"or updated."
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "You must be a member of the \"YAML/Export\" group to export data as YAML."
msgstr "Morate bit član \"YAML/Izvoz\" grupe za izvoz podataka u YAML."
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "managers who can modify this record"
msgstr ""
#. module: cetmix_tower_git
#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form
msgid "users who can view this record"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
import logging
from odoo import SUPERUSER_ID, api
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Convert URLs in remotes to repositories.
Add repo_id to remotes.
"""
_logger.info(
"Converting URLs in remotes to repositories and adding repo_id to remotes."
)
env = api.Environment(cr, SUPERUSER_ID, {})
# Fetch all remotes using SQL query Group them {"url": [remote_id, remote_id, ...]}
cr.execute(
"""
SELECT url, array_agg(id) as remote_ids
FROM cx_tower_git_remote
GROUP BY url
"""
)
remote_urls = cr.fetchall()
remote_urls_dict = {url: remote_ids for url, remote_ids in remote_urls}
# Create repo for each url and add this repo to all remotes
url_count = 0
remote_obj = env["cx.tower.git.remote"]
repo_obj = env["cx.tower.git.repo"]
for url, remote_ids in remote_urls_dict.items():
repo_id = repo_obj.name_create(url)[0]
# Check if any of the remotes is private
remotes = remote_obj.browse(remote_ids)
is_private = bool(remotes.filtered(lambda r: r.is_private))
# Add repo to remotes
# We are using SQL to avoid post-write triggers
cr.execute(
"""
UPDATE cx_tower_git_remote
SET repo_id = %s
WHERE id = ANY(%s)
""",
(repo_id, remote_ids),
)
# Update repo.is_private
# We are using SQL to avoid post-write triggers
if is_private:
cr.execute(
"""
UPDATE cx_tower_git_repo
SET is_private = true
WHERE id = %s
""",
(repo_id,),
)
url_count += 1
# Compute project_ids for repositories
_logger.info("Computing project_ids for repositories.")
remote_obj.invalidate_model()
repo_obj.invalidate_model()
repo_obj.search([])._compute_git_project_ids()
# Sanitize all remote heads that contain a slash
# Use the SQL query to avoid post-write triggers
_logger.info("Sanitizing remote heads that contain a slash.")
cr.execute(
"""
UPDATE cx_tower_git_remote
SET head = (regexp_match(head, '[^/]+$'))[1]
WHERE head LIKE '%/%'
"""
)
_logger.info("Migration completed. %s unique urls processed", url_count)

View File

@@ -0,0 +1,15 @@
# cx_tower_git_project_rel must be the first one in the list
# in order to create the relation table properly
from . import cx_tower_git_project_rel
from . import cx_tower_git_project_file_template_rel
from . import cx_tower_file
from . import cx_tower_file_template
from . import cx_tower_git_project
from . import cx_tower_git_remote
from . import cx_tower_git_repo
from . import cx_tower_git_repo_owner
from . import cx_tower_git_source
from . import cx_tower_server
from . import cetmix_tower
from . import cx_tower_plan_line
from . import cx_tower_command

View File

@@ -0,0 +1,35 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, models
class CetmixTower(models.AbstractModel):
_inherit = "cetmix.tower"
@api.model
def servers_by_git_ref(self, repository_url, head=None, head_type=None):
"""
Return servers linked to a given Git repository reference.
This is a thin shortcut that delegates to
:meth:`cx.tower.server.get_servers_by_git_ref`.
Parameters
----------
repository_url : str
Pre-normalized canonical Git URL
(e.g. ``https://host/owner/repo.git``).
head : str, optional
Branch name, commit SHA, or PR identifier.
head_type : {'branch', 'commit', 'pr'}, optional
Type of the ``head`` argument.
Returns
-------
recordset of cx.tower.server
Matching servers. Empty recordset if no matches.
"""
return self.env["cx.tower.server"].get_servers_by_git_ref(
repository_url, head, head_type
)

View File

@@ -0,0 +1,37 @@
# Copyright 2024 Cetmix OÜ
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, models
from odoo.tools.safe_eval import wrap_module
# Wrap giturlparse safely
giturlparse = wrap_module(__import__("giturlparse"), ["parse", "validate"])
class CxTowerCommand(models.Model):
"""Extends cx.tower.command to add giturlparse functionality."""
_inherit = "cx.tower.command"
def _custom_python_libraries(self):
"""
Add the giturlparse library to the available libraries.
"""
custom_python_libraries = super()._custom_python_libraries()
custom_python_libraries.update(
{
"cetmix_tower_git": {
"giturlparse": {
"import": giturlparse,
"help": _(
"Python library for Git URL parsing. "
"Available methods: 'parse', 'validate'. "
" <a "
"href='https://github.com/nephila/giturlparse/'"
" target='_blank'>Documentation on GitHub</a>."
),
},
}
}
)
return custom_python_libraries

View File

@@ -0,0 +1,47 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class CxTowerFile(models.Model):
_inherit = "cx.tower.file"
git_project_id = fields.Many2one(
comodel_name="cx.tower.git.project",
compute="_compute_git_project_id",
store=True,
)
git_project_rel_ids = fields.One2many(
comodel_name="cx.tower.git.project.rel",
inverse_name="file_id",
string="Git Project Relations",
copy=False,
)
# Get server from the first related git project relation
# This is needed for YAML import
server_id = fields.Many2one(
comodel_name="cx.tower.server",
compute="_compute_git_project_id",
store=True,
readonly=False,
)
@api.depends("git_project_rel_ids.server_id", "git_project_rel_ids.git_project_id")
def _compute_git_project_id(self):
"""
Link to project using the proxy model.
"""
for record in self:
# File is related to project via proxy model.
# So there can be only one record in o2m field.
git_project_relation = (
record.git_project_rel_ids and record.git_project_rel_ids[0]
)
if git_project_relation:
record.update(
{
"git_project_id": git_project_relation.git_project_id,
"server_id": git_project_relation.server_id,
}
)

View File

@@ -0,0 +1,32 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class CxTowerFileTemplate(models.Model):
_inherit = "cx.tower.file.template"
git_project_ids = fields.Many2many(
comodel_name="cx.tower.git.project",
relation="cx_tower_git_project_file_template_rel",
column1="file_template_id",
column2="git_project_id",
string="Git Projects",
copy=False,
)
git_project_id = fields.Many2one(
comodel_name="cx.tower.git.project",
compute="_compute_git_project_id",
)
@api.depends("git_project_ids")
def _compute_git_project_id(self):
"""
Link to project using the proxy model.
"""
for record in self:
# File is related to project via proxy model.
# So there can be only one record in o2m field.
record.git_project_id = (
record.git_project_ids and record.git_project_ids[0].id
)

View File

@@ -0,0 +1,334 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import re
from odoo import _, api, fields, models
class CxTowerGitProject(models.Model):
"""
Git Project.
Implements pre-defined git configuration.
"""
_name = "cx.tower.git.project"
_description = "Cetmix Tower Git Project"
_order = "name"
_inherit = [
"cx.tower.reference.mixin",
"cx.tower.yaml.mixin",
"cx.tower.access.role.mixin",
]
def _get_post_create_fields(self):
res = super()._get_post_create_fields()
return res + [
"source_ids",
"git_project_rel_ids",
"git_project_file_template_rel_ids",
]
active = fields.Boolean(default=True)
# IMPORTANT: This field may contain duplicates because of the relation nature!
server_ids = fields.Many2many(
comodel_name="cx.tower.server",
relation="cx_tower_git_project_rel",
column1="git_project_id",
column2="server_id",
string="Servers",
readonly=True,
copy=False,
help="Servers are added automatically based on the files linked to the project."
"\nIMPORTANT: This field may contain duplicates"
" because of the relation nature!",
)
source_ids = fields.One2many(
comodel_name="cx.tower.git.source",
inverse_name="git_project_id",
string="Sources",
auto_join=True,
copy=True,
)
git_project_rel_ids = fields.One2many(
comodel_name="cx.tower.git.project.rel",
inverse_name="git_project_id",
string="Git Project Server File Relations",
copy=False,
)
# Helper field to get all files related to git project
file_ids = fields.Many2many(
comodel_name="cx.tower.file",
relation="cx_tower_git_project_rel",
column1="git_project_id",
column2="file_id",
string="Files",
readonly=True,
depends=["git_project_rel_ids"],
copy=False,
)
git_project_file_template_rel_ids = fields.One2many(
comodel_name="cx.tower.git.project.file.template.rel",
inverse_name="git_project_id",
string="Git Project File Template Relations",
copy=False,
)
# Helper field to get all file templates related to git project
file_template_ids = fields.Many2many(
comodel_name="cx.tower.file.template",
relation="cx_tower_git_project_file_template_rel",
column1="git_project_id",
column2="file_template_id",
string="File Templates",
readonly=True,
depends=["git_project_file_template_rel_ids"],
copy=False,
)
# Helper field to get all repositories used in this project
repo_ids = fields.Many2many(
comodel_name="cx.tower.git.repo",
relation="cx_tower_git_repo_project_rel",
column1="project_id",
column2="repo_id",
string="Repositories",
readonly=True,
copy=False,
help="Repositories used in this project through its sources and remotes",
)
note = fields.Text()
# ---- Access. Add relation for mixin fields
user_ids = fields.Many2many(
relation="cx_tower_git_project_user_rel",
compute="_compute_user_ids",
readonly=False,
store=True,
precompute=True,
domain=lambda self: [
("groups_id", "in", self.env.ref("cetmix_tower_server.group_manager").ids)
],
)
manager_ids = fields.Many2many(
relation="cx_tower_git_project_manager_rel",
compute="_compute_user_ids",
readonly=False,
store=True,
precompute=True,
)
# -- UI/UX fields
has_private_remotes = fields.Boolean(
compute="_compute_has_private_remotes",
help="Indicates if the project has any private remotes.",
)
has_partially_private_remotes = fields.Boolean(
compute="_compute_has_private_remotes",
help="Indicates if the project has any partially private remotes.",
)
# -- Git Aggregator related fields
git_aggregator_root_dir = fields.Char(
help="Git aggregator root directory where sources will be cloned."
" Eg '/tmp/git-aggregator'"
" Will use '.' if not set",
)
def _selection_project_format(self):
"""
Possible project formats.
Inherit and extend when adding new project formats.
Returns:
List of tuples: (code, name)
"""
return [
("git_aggregator", "Git Aggregator"),
]
def _default_project_format(self):
"""
Default project format.
"""
return "git_aggregator"
@api.depends(
"git_project_rel_ids.server_id",
"git_project_rel_ids.server_id.user_ids",
"git_project_rel_ids.server_id.manager_ids",
)
def _compute_user_ids(self):
"""
Users. All users who have "Manager" group and are either set in "Users"
or in "Managers" in all related servers.
Managers. All users who have "Manager" group and are set as "Managers"
in all related servers.
This is done to avoid unpredictable consequences when some of the servers
are not updated due to access restrictions when a project is updated.
"""
for project in self:
# Do not compute if no servers are related
server_ids = project.git_project_rel_ids.server_id
if not server_ids:
continue
# Get all user and manager ids from related servers
all_user_ids = server_ids.user_ids.filtered(
lambda u: u.has_group("cetmix_tower_server.group_manager")
).ids
all_manager_ids = server_ids.manager_ids.ids
# Create a final list of user and manager ids
user_ids = []
manager_ids = []
# Check if user is present in all servers
for user_id in all_user_ids:
if all(
user_id in server.user_ids.ids or user_id in server.manager_ids.ids
for server in server_ids
):
user_ids.append(user_id)
# Check if manager is present in all servers
for manager_id in all_manager_ids:
if all(manager_id in server.manager_ids.ids for server in server_ids):
manager_ids.append(manager_id)
# Set the final lists
project.update(
{
"user_ids": [(6, 0, user_ids)],
"manager_ids": [(6, 0, manager_ids)],
}
)
@api.depends(
"source_ids", "source_ids.remote_ids", "source_ids.remote_ids.is_private"
)
def _compute_has_private_remotes(self):
for project in self:
project.has_private_remotes = any(
source.remote_count > 0
and source.remote_count_private == source.remote_count
for source in project.source_ids
)
project.has_partially_private_remotes = any(
source.remote_count_private > 0
and source.remote_count_private != source.remote_count
for source in project.source_ids
)
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
# Update related files and templates on create
res._update_related_files_and_templates()
return res
def write(self, vals):
res = super().write(vals)
# Update related files and templates on update
self._update_related_files_and_templates()
return res
def _update_related_files_and_templates(self):
# Update related files and templates
if self.git_project_rel_ids:
self.git_project_rel_ids._save_to_file()
if self.git_project_file_template_rel_ids:
self.git_project_file_template_rel_ids._save_to_file_template()
def _extract_variables_from_text(self, text):
"""Extract environment variables from text.
Helper method for file content generation.
Returns:
List: List of variables
"""
variables = re.findall(r"\$([A-Z0-9_]+)", text)
return sorted(list(set(variables)))
# ------------------------------
# YAML mixin methods
# ------------------------------
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"note",
"source_ids",
"git_aggregator_root_dir",
]
return res
# -------------------------------
# Git Aggregator related methods
# -------------------------------
def _git_aggregator_prepare_record(self):
"""Prepare json structure for git aggregator.
Returns:
Dict: Json structure for git aggregator
"""
self.ensure_one()
values = {}
for source in self.source_ids:
if source.enabled and source.remote_count:
root_dir = self.git_aggregator_root_dir or "."
values.update(
{
f"/{source.reference}"
if root_dir == "/"
else f"{root_dir}/{source.reference}": source._git_aggregator_prepare_record() # noqa: E501
}
)
return values
def _git_aggregator_prepare_yaml_comment(self, yaml_code):
"""Generate commentary for yaml file.
It includes brief instructions for git aggregator
and lists environment variables that are required.
Args:
yaml_code (str): Yaml code
Returns:
Char: comment text or None
"""
comment_text = _(
"# This file is generated with Cetmix Tower https://cetmix.com/tower\n"
"# It's designed to be used with git-aggregator tool developed by Acsone.\n"
"# Documentation for git-aggregator: https://github.com/acsone/git-aggregator\n"
)
variable_list = self._extract_variables_from_text(yaml_code)
if variable_list:
comment_text += _(
"\n# You need to set the following variables in your environment:\n# %(vars)s\n" # noqa: E501
"# and run git-aggregator with '--expand-env' parameter.\n", # noqa: E501
vars=(", ".join(variable_list)),
)
return comment_text
def _generate_code_git_aggregator(self, record):
"""Generate code in git-aggregator format.
Args:
record (recordset()): Model record to generate code for.
must be a single record and have git_project_id field.
Returns:
Text: Yaml code
"""
yaml_mixin = self.env["cx.tower.yaml.mixin"]
# Do not generate code if record values are empty
record_values = record.git_project_id._git_aggregator_prepare_record()
if record_values:
yaml_code = yaml_mixin._convert_dict_to_yaml(record_values)
# Prepend comment to yaml code
comment = record.git_project_id._git_aggregator_prepare_yaml_comment(
yaml_code
)
return f"{comment}\n{yaml_code}"
return ""

View File

@@ -0,0 +1,113 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class CxTowerGitProjectFileTemplateRel(models.Model):
"""
Relation between git projects and file templates.
"""
_name = "cx.tower.git.project.file.template.rel"
_table = "cx_tower_git_project_file_template_rel"
_description = "Cetmix Tower Git Project relation to File Templates"
_log_access = False
name = fields.Char(related="git_project_id.name", readonly=True)
git_project_id = fields.Many2one(
comodel_name="cx.tower.git.project",
index=True,
required=True,
ondelete="cascade",
)
file_template_id = fields.Many2one(
comodel_name="cx.tower.file.template",
required=True,
ondelete="cascade",
)
project_format = fields.Selection(
selection=lambda self: self.env[
"cx.tower.git.project"
]._selection_project_format(),
default=lambda self: self.env["cx.tower.git.project"]._default_project_format(),
required=True,
string="Format",
)
_sql_constraints = [
(
"project_server_file_format_uniq",
"unique(git_project_id, file_template_id, project_format)",
"File template is already related to the same project and format",
),
]
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
# Export project to file
res._save_to_file_template()
return res
def write(self, vals):
res = super().write(vals)
# Export project to file
self._save_to_file_template()
return res
def action_open_file_template(self):
"""
Open file template record in current window
"""
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": self.file_template_id.name,
"res_model": "cx.tower.file.template",
"res_id": self.file_template_id.id, # pylint: disable=no-member
"view_mode": "form",
"view_type": "form",
"target": "current",
}
# ----------------------------------------------------
# Save project to linked file based on selected format
# ----------------------------------------------------
def _save_to_file_template(self):
"""Save project to linked file using format-specific function."""
# Get required function based on project format
# Following the pattern: _generate_code__<format> where format
# is one of the values in _selection_project_format
# Function gets a single record as an argument.
# Save resolved functions to dict for faster access
code_generator_functions = {}
for record in self:
code_generator_function = code_generator_functions.get(
record.project_format
)
if not code_generator_function:
code_generator_function = getattr(
self.git_project_id, f"_generate_code_{record.project_format}", None
)
if not code_generator_function:
raise ValidationError(
_(
"Code generator function for '%(project_format)s'"
" format not found.",
project_format=record.project_format,
)
)
code_generator_functions[
record.project_format
] = code_generator_function
# Generate code for current record
code = code_generator_function(record)
if record.file_template_id.code != code:
record.file_template_id.write({"code": code})

View File

@@ -0,0 +1,177 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class CxTowerGitProjectRel(models.Model):
"""
Relation between git projects and other model records.
"""
_name = "cx.tower.git.project.rel"
_inherit = [
"cx.tower.reference.mixin",
"cx.tower.yaml.mixin",
]
_table = "cx_tower_git_project_rel"
_description = "Cetmix Tower Git Project relation to Files and Servers"
_log_access = False
name = fields.Char(related="git_project_id.name", readonly=True)
git_project_id = fields.Many2one(
comodel_name="cx.tower.git.project",
index=True,
required=True,
ondelete="cascade",
)
server_id = fields.Many2one(
comodel_name="cx.tower.server",
index=True,
required=True,
ondelete="cascade",
)
file_id = fields.Many2one(
comodel_name="cx.tower.file",
domain="[('server_id', '=', server_id),"
"('source', '=', 'tower'),"
"('file_type', '=', 'text')]",
required=True,
ondelete="cascade",
)
project_format = fields.Selection(
selection=lambda self: self.env[
"cx.tower.git.project"
]._selection_project_format(),
default=lambda self: self.env["cx.tower.git.project"]._default_project_format(),
required=True,
string="Format",
)
auto_sync = fields.Boolean(related="file_id.auto_sync", readonly=False)
_sql_constraints = [
(
"project_server_file_format_uniq",
"unique(git_project_id, file_id, project_format)",
"File is already related to the same project and format",
),
]
@api.constrains("server_id", "file_id")
def _check_server_file_relation(self):
"""
Check if server and file are related.
"""
for record in self:
if (
record.file_id.server_id
and record.server_id != record.file_id.server_id
):
raise ValidationError(
_(
"File '%(file)s' doesn't belong to server '%(server)s'",
file=record.file_id.name,
server=record.server_id.name,
)
)
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
# Export project to file
res._save_to_file()
return res
def write(self, vals):
res = super().write(vals)
# Export project to file
self._save_to_file()
return res
def action_open_project(self):
"""
Open project record in current window
"""
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": self.name,
"res_model": "cx.tower.git.project",
"res_id": self.git_project_id.id, # pylint: disable=no-member
"view_mode": "form",
"view_type": "form",
"target": "current",
}
def action_open_server(self):
"""
Open server record in current window
"""
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": self.server_id.name,
"res_model": "cx.tower.server",
"res_id": self.server_id.id, # pylint: disable=no-member
"view_mode": "form",
"view_type": "form",
"target": "current",
}
# ----------------------------------------------------
# Save project to linked file based on selected format
# ----------------------------------------------------
def _save_to_file(self):
"""Save project to linked file using format-specific function."""
# Get required function based on project format
# Following the pattern: _generate_code_<format> where format
# is one of the values in _selection_project_format
# Function gets a single record as an argument.
# Save resolved functions to dict for faster access
code_generator_functions = {}
for record in self:
# Disconnect file from file template if it is connected
if record.file_id.template_id:
record.file_id.action_unlink_from_template()
code_generator_function = code_generator_functions.get(
record.project_format
)
if not code_generator_function:
code_generator_function = getattr(
self.git_project_id, f"_generate_code_{record.project_format}", None
)
if not code_generator_function:
raise ValidationError(
_(
"Code generator function for '%(project_format)s'"
" format not found.",
project_format=record.project_format,
)
)
code_generator_functions[
record.project_format
] = code_generator_function
# Generate code for current record
code = code_generator_function(record)
if record.file_id.code != code:
record.file_id.write({"code": code})
# ------------------------------
# YAML mixin methods
# ------------------------------
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"file_id",
"git_project_id",
"project_format",
"auto_sync",
]
return res

View File

@@ -0,0 +1,415 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import giturlparse
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class CxTowerGitRemote(models.Model):
"""
Git Remote.
Implements single git remote.
Eg a branch or a pull request.
"""
_name = "cx.tower.git.remote"
_inherit = [
"cx.tower.reference.mixin",
"cx.tower.yaml.mixin",
]
_description = "Cetmix Tower Git Remote"
_order = "sequence, name"
# Used to detect git ssh urls
GIT_SSH_URL_PATTERN = r"^[\w\.-]+@[\w\.-]+:.*\.git$"
GIT_HTTPS_URL_PATTERN = r"^https://.*\.git$"
GIT_GIT_URL_PATTERN = r"^git://.*\.git$"
active = fields.Boolean(related="source_id.active", store=True, readonly=True)
enabled = fields.Boolean(
default=True, help="Enable in configuration and exported to files"
)
sequence = fields.Integer(default=10)
name = fields.Char(compute="_compute_name", store=True, default="remote")
source_id = fields.Many2one(
comodel_name="cx.tower.git.source",
required=True,
ondelete="cascade",
auto_join=True,
)
git_project_id = fields.Many2one(
comodel_name="cx.tower.git.project",
related="source_id.git_project_id",
store=True,
readonly=True,
)
repo_id = fields.Many2one(
comodel_name="cx.tower.git.repo",
string="Repository",
required=True,
ondelete="restrict",
help="If selected, the remote URL will be filled from the"
" repo settings based on the remote protocol",
)
repo_provider = fields.Selection(
related="repo_id.provider",
readonly=True,
)
# -- Repo related fields
url_protocol = fields.Selection(
string="Protocol",
selection=[
("ssh", "SSH"),
("https", "HTTPS"),
("git", "GIT"),
],
required=True,
default=lambda self: self._get_default_url_protocol(),
)
is_private = fields.Boolean(
string="Private",
help="Repository is private",
related="repo_id.is_private",
store=True,
readonly=True,
)
head_type = fields.Selection(
selection=[
("branch", "Branch"),
("pr", "Pull/Merge Request"),
("commit", "Commit"),
],
required=True,
)
head = fields.Char(
help="Git remote head. Link to branch, PR, commit or commit hash.",
required=True,
index=True,
)
def _get_default_url_protocol(self):
"""Default URL protocol for new remote.
Returns:
Char: Default URL protocol.
"""
return "https"
@api.depends("source_id", "sequence")
def _compute_name(self):
"""
Compute remote name.
By default all remotes are named `remote_<position>`
where position is the position of the remote in the source.
Eg first remote is `remote_1`, second is `remote_2`, etc.
"""
for remote in self:
if remote.source_id:
for index, source_remote in enumerate(remote.source_id.remote_ids):
source_remote.name = f"remote_{index + 1}"
@api.onchange("head")
def onchange_head(self):
"""
Extract head number from head url
and set it as head.
"""
for remote in self:
if remote.head and "/" in remote.head:
remote.head = self._sanitize_head(remote.head)
@api.model_create_multi
def create(self, vals_list):
# Sanitize head
for vals in vals_list:
head = vals.get("head")
if head and "/" in head:
vals["head"] = self._sanitize_head(head)
res = super().create(vals_list)
# Export project to related files and templates
res._update_related_files_and_templates()
return res
def write(self, vals):
# Sanitize head
if "head" in vals:
head = vals["head"]
if head and "/" in head:
vals["head"] = self._sanitize_head(head)
res = super().write(vals)
# Update related files and templates on update
self._update_related_files_and_templates()
return res
def unlink(self):
"""
Override to update related files and templates on unlink
"""
related_files = self.mapped("git_project_id").mapped("git_project_rel_ids")
related_templates = self.mapped("git_project_id").mapped(
"git_project_file_template_rel_ids"
)
res = super().unlink()
# Update related files and templates on unlink
if related_files:
related_files._save_to_file()
if related_templates:
related_templates._save_to_file_template()
return res
def _sanitize_head(self, head):
"""Sanitize head.
Extract head number from head url
and set it as head.
Args:
head (Char): Head to sanitize
Returns:
Char: Sanitized head
"""
if head and "/" in head:
return head.split("/")[-1].strip()
return head
@api.model
def get_head_data(self):
"""
This method is used to get values for the dropdown dynamic widget.
It is designed for integrations with repo providers using APIs.
Returns:
List: List of tuples(selection, name)
eg [('18.0', '18.0'), ('main', 'main'), ('develop', 'develop')]
"""
values = [
("18.0", "18.0"),
("main", "Main"),
("develop", "Develop"),
("17.0", "17.0"),
]
return values
def _update_related_files_and_templates(self):
# Update related files on update
related_files = self.mapped("git_project_id").mapped("git_project_rel_ids")
if related_files:
related_files._save_to_file()
related_templates = self.mapped("git_project_id").mapped(
"git_project_file_template_rel_ids"
)
if related_templates:
related_templates._save_to_file_template()
# ------------------------------
# Reference mixin methods
# ------------------------------
def _get_pre_populated_model_data(self):
res = super()._get_pre_populated_model_data()
res.update({"cx.tower.git.remote": ["cx.tower.git.source", "source_id"]})
return res
# ------------------------------
# YAML mixin methods
# ------------------------------
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"enabled",
"sequence",
"repo_id",
"head",
"head_type",
]
return res
# ------------------------------
# Git Aggregator related methods
# ------------------------------
def _git_aggregator_prepare_url(self):
"""Prepare url for git aggregator
Returns:
Char: Prepared url for git aggregator
"""
self.ensure_one()
if not self.repo_id:
raise ValidationError(_("Repository is required"))
if not self.repo_id.url:
raise ValidationError(_("Repository URL is not set"))
url = self.repo_id.url
prepared_url = giturlparse.parse(url).urls.get(self.url_protocol, url)
# If repo is public or is not using HTTPS protocol return URL as is
if not self.is_private or self.url_protocol != "https":
return prepared_url
if self.repo_provider == "github":
prepared_url = self._git_aggregator_prepare_url_github(prepared_url)
elif self.repo_provider == "gitlab":
prepared_url = self._git_aggregator_prepare_url_gitlab(prepared_url)
elif self.repo_provider == "bitbucket":
prepared_url = self._git_aggregator_prepare_url_bitbucket(prepared_url)
return prepared_url
def _git_aggregator_prepare_url_github(self, url):
"""Prepare url for git aggregator
for private Github repo using https protocol.
Args:
url (Char): URL to prepare
Returns:
Char: Prepared url for git aggregator
"""
self.ensure_one()
# This is how final url will look like
# https://$GITHUB_TOKEN:x-oauth-basic@github.com/soem_org/some_private_repo.git
url_without_protocol = url.replace("https://", "")
url = f"https://$GITHUB_TOKEN:x-oauth-basic@{url_without_protocol}"
return url
def _git_aggregator_prepare_url_gitlab(self, url):
"""Prepare url for git aggregator
for private GitLab repo using https protocol.
Args:
url (Char): URL to prepare
Returns:
Char: Prepared url for git aggregator
"""
self.ensure_one()
# This is how final url will look like
# https://<token-name>:<token-value>@<gitlaburl-repository>.git
url_without_protocol = url.replace("https://", "")
url = f"https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@{url_without_protocol}"
return url
def _git_aggregator_prepare_url_bitbucket(self, url):
"""Prepare url for git aggregator
for private Github repo using https protocol.
Args:
url (Char): URL to prepare
Returns:
Char: Prepared url for git aggregator
"""
self.ensure_one()
# This is how final url will look like
# https://x-token-auth:{access_token}@bitbucket.org/user/repo.git
# From https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/
url_without_protocol = url.replace("https://", "")
url = f"https://x-token-auth:$BITBUCKET_TOKEN@{url_without_protocol}"
return url
def _git_aggregator_prepare_head(self):
"""Prepare head for git aggregator
Returns:
Char: Prepared head for git aggregator
"""
self.ensure_one()
if self.repo_provider == "github":
return self._git_aggregator_prepare_head_github()
if self.repo_provider == "gitlab":
return self._git_aggregator_prepare_head_gitlab()
if self.repo_provider == "bitbucket":
return self._git_aggregator_prepare_head_bitbucket()
return self.head
def _git_aggregator_prepare_head_github(self):
"""Prepare head for git aggregator for Github.
Returns:
Char: Prepared head for git aggregator
"""
# Extract branch name, PR/MR or commit number from head
head_number = self.head.split("/")[-1]
if not head_number:
raise ValidationError(
_("Git Aggregator: " "Head number is empty in %(head)s", head=self.head)
)
# PR/MR
if self.head_type == "pr":
return f"refs/pull/{head_number}/head"
# Commit
if self.head_type in ["commit", "branch"]:
return f"{head_number}"
# Fallback to original head
return self.head
def _git_aggregator_prepare_head_gitlab(self):
"""Prepare head for git aggregator for GitLab.
Returns:
Char: Prepared head for git aggregator
"""
# Extract branch name, PR/MR or commit number from head
head_number = self.head.split("/")[-1]
if not head_number:
raise ValidationError(
_("Git Aggregator: " "Head number is empty in %(head)s", head=self.head)
)
# PR/MR
if self.head_type == "pr":
return f"merge-requests/{head_number}/head"
# Commit
# https://gitlab.com/cetmix/test/-/tree/17.0-test-branch?ref_type=heads
if self.head_type in ["commit", "branch"]:
head_parts = head_number.split("?")
return f"{head_parts[0]}"
# Fallback to original head
return self.head
def _git_aggregator_prepare_head_bitbucket(self):
"""Prepare head for git aggregator for Bitbucket.
Returns:
Char: Prepared head for git aggregator
"""
# Extract branch name, PR/MR or commit number from head
head_number = self.head.split("/")[-1]
if not head_number:
raise ValidationError(
_("Git Aggregator: " "Head number is empty in %(head)s", head=self.head)
)
# PR/MR
if self.head_type == "pr":
raise ValidationError(
_(
"Git Aggregator: "
"Bitbucket does not support"
" fetching PRs. Please use branch instead.\n\n"
"Source: %(src)s\n"
"URL: %(url)s\n"
"Head: %(head)s",
src=self.source_id.name,
url=self.repo_id.url,
head=self.head,
)
)
# Commit
if self.head_type in ["commit", "branch"]:
return f"{head_number}"
# Fallback to original head
return self.head

View File

@@ -0,0 +1,409 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import giturlparse
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools import ormcache
class CxTowerGitRepo(models.Model):
"""
Git Repository.
Represents a git repository with its metadata and configuration.
"""
_name = "cx.tower.git.repo"
_inherit = [
"cx.tower.reference.mixin",
"cx.tower.yaml.mixin",
]
_description = "Cetmix Tower Git Repository"
_order = "name"
_rec_names_search = ["repo", "host", "owner_id"]
active = fields.Boolean(default=True, help="Indicates if the repository is active")
name = fields.Char(
compute="_compute_name", store=True, required=False, index="trigram"
)
reference = fields.Char(
index=True,
compute="_compute_name",
required=False,
store=True,
)
repo = fields.Char(
string="Repository Name",
readonly=True,
help="Repository name (e.g., 'cetmix-tower', 'odoo')",
)
url = fields.Char(
string="Generic URL",
help="Displayed in 'https' format, but can be entered in any format",
compute="_compute_url",
inverse="_inverse_url",
required=True,
compute_sudo=True,
)
url_ssh = fields.Char(
string="SSH URL",
help="SSH URL of the repository",
compute="_compute_url",
compute_sudo=True,
)
url_git = fields.Char(
string="GIT URL",
help="GIT URL of the repository",
compute="_compute_url",
compute_sudo=True,
)
is_private = fields.Boolean(
string="Private", default=False, help="Indicates if the repository is private"
)
provider = fields.Selection(
selection="_selection_provider",
required=True,
default="other",
help="Repository provider to determine provider-based behaviour",
)
host = fields.Char(
readonly=True,
index=True,
help="Repository host (e.g., 'github.com', 'gitlab.com')",
)
owner_id = fields.Many2one(
comodel_name="cx.tower.git.repo.owner",
readonly=True,
help="Repository owner (e.g., 'cetmix' or 'OCA')",
)
secret_id = fields.Many2one(
comodel_name="cx.tower.key",
string="Secret",
domain="[('key_type', '=', 's')]",
help="Custom secret used for this repository",
)
remote_ids = fields.One2many(
comodel_name="cx.tower.git.remote",
inverse_name="repo_id",
help="Remotes that use this repository",
)
git_project_ids = fields.Many2many(
comodel_name="cx.tower.git.project",
relation="cx_tower_git_repo_project_rel",
column1="repo_id",
column2="project_id",
compute="_compute_git_project_ids",
store=True,
help="Projects this repository is used in",
)
remote_count = fields.Integer(
compute="_compute_remote_count",
help="Number of remotes this repository is used in",
)
git_project_count = fields.Integer(
compute="_compute_git_project_count",
help="Number of projects this repository is used in",
)
_sql_constraints = [
(
"unique_repo_host_owner",
"unique(repo, host, owner_id)",
"A repository with the same name, host, and owner already exists.",
),
]
# -- Selection
def _selection_provider(self):
"""Available repository providers.
Returns:
List of tuples: available options.
"""
return [
("github", "GitHub"),
("gitlab", "GitLab"),
("bitbucket", "Bitbucket"),
("assembla", "Assembla"),
("other", "Other"),
]
# -- Computes
@api.depends("host", "owner_id", "repo")
def _compute_name(self):
"""
Compute name in format: host/owner/name.
Compute reference based on name.
"""
for repo in self:
if repo.host and repo.owner_id and repo.repo:
name = f"{repo.host}/{repo.owner_id.name}/{repo.repo}"
reference = repo._generate_or_fix_reference(name)
repo.update(
{
"name": name,
"reference": reference,
}
)
else:
repo.update(
{
"name": False,
"reference": False,
}
)
@api.depends("remote_ids", "remote_ids.git_project_id")
def _compute_git_project_ids(self):
"""Compute projects this repository is used in."""
for repo in self:
projects = repo.remote_ids.mapped("git_project_id")
repo.git_project_ids = [(6, 0, projects.ids)]
@api.depends("remote_ids")
def _compute_remote_count(self):
"""Compute remote count field."""
for repo in self:
repo.remote_count = len(repo.remote_ids)
@api.depends("git_project_ids")
def _compute_git_project_count(self):
"""Compute project count field."""
for repo in self:
repo.git_project_count = len(repo.git_project_ids)
@api.depends("repo", "host", "owner_id")
def _compute_url(self):
"""Compute URL from repository properties."""
for repo in self:
if repo.repo and repo.host and repo.owner_id:
https_url = f"https://{repo.host}/{repo.owner_id.name}/{repo.repo}.git"
elif repo.repo and repo.host:
https_url = f"https://{repo.host}/{repo.repo}.git"
else:
https_url = ""
if https_url:
try:
parsed_urls = giturlparse.parse(https_url).urls
urls = {
"url": https_url,
"url_ssh": parsed_urls["ssh"],
"url_git": parsed_urls["git"],
}
except Exception as e: # noqa: F841 catch all errors
urls = {
"url": "",
"url_ssh": "",
"url_git": "",
}
else:
urls = {
"url": "",
"url_ssh": "",
"url_git": "",
}
repo.update(urls)
def _inverse_url(self):
"""Parse URL to update repository properties."""
for repo in self:
if not repo.url:
continue
# Parse URL
parsed_url_dict = self._parse_url(repo.url)
# Update repository properties
repo.update(parsed_url_dict)
def action_view_remotes(self):
"""Open remotes list view."""
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id(
"cetmix_tower_git.action_cx_tower_git_remote"
)
action.update(
{
"domain": [("repo_id", "=", self.id)],
"context": {"default_repo_id": self.id},
}
)
return action
def action_view_projects(self):
"""Open projects list view."""
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id(
"cetmix_tower_git.cx_tower_git_project_action"
)
action.update(
{
"domain": [("repo_ids", "in", self.id)],
"context": {"default_repo_ids": [(4, self.id)]},
}
)
return action
@api.model_create_multi
def create(self, vals_list):
"""Create multiple repositories."""
# Check if any of the repositories already exist
# This is needed to allow creating repositories using just an URL.
# Eg when importing repositories from a YAML file.
res = self.browse()
existing_repo_ids = []
vals_list_to_create = []
for vals in vals_list:
url = vals.get("url")
if url:
# Try to get repository by URL
repo_id = self._get_repo_id_by_url(
url=url, create=False, raise_if_invalid=False
)
if repo_id:
existing_repo_ids.append(repo_id)
continue
# Parse URL and update vals
parsed_url_dict = self._parse_url(url=url, raise_if_invalid=True)
vals.update(parsed_url_dict)
# Otherwise, add to create list
vals_list_to_create.append(vals)
# Compose the result
if vals_list_to_create:
res |= super().create(vals_list_to_create)
if existing_repo_ids:
res |= self.browse(existing_repo_ids)
self.clear_caches()
return res
def write(self, vals):
"""Write repositories."""
res = super().write(vals)
self.clear_caches()
return res
def unlink(self):
"""Unlink repositories."""
res = super().unlink()
self.clear_caches()
return res
@api.model
def name_create(self, name):
"""
Create a new repository from a URL.
"""
repo_id = self._get_repo_id_by_url(url=name, create=True, raise_if_invalid=True)
repo = self.browse(repo_id)
return repo_id, repo.display_name
@ormcache("self.env.uid", "self.env.su", "url")
def _get_repo_id_by_url(self, url, create=False, raise_if_invalid=False):
"""Get repository id by URL.
Args:
url (Char): URL to get repository id
create (Bool, optional): Create repository if not found.
Default is False.
raise_if_invalid (Bool, optional): Raise ValidationError
if the URL is not valid. Default is False.
Returns:
int: Repository ID
or False if the URL is not valid and raise_if_invalid is False
Raises:
ValidationError: If the URL is not valid and raise_if_invalid is True
"""
# Parse URL
parsed_url_dict = self._parse_url(url, raise_if_invalid=raise_if_invalid)
if not parsed_url_dict:
return False
# Check if repository already exists and use it
repo = self.search(
[
("repo", "=", parsed_url_dict["repo"]),
("host", "=", parsed_url_dict["host"]),
("owner_id", "=", parsed_url_dict["owner_id"]),
],
limit=1,
)
# Otherwise, create a new one
if not repo and create:
repo = self.create(parsed_url_dict)
return repo.id if repo else False
def _parse_url(self, url, raise_if_invalid=True):
"""Parse URL to get name, host and owner.
Args:
url (Char): URL to parse
Raises:
ValidationError: If the URL is not valid
Returns:
Dict: Dictionary with name, host and owner
or empty dict if the URL is not valid and raise_if_invalid is False
"""
# Validate URL
if not giturlparse.validate(url):
if raise_if_invalid:
raise ValidationError(_("Not a valid repository URL!"))
return {}
# Parse URL
parsed_url = giturlparse.parse(url)
# Get or create owner
owner_id = self.env["cx.tower.git.repo.owner"]._get_owner_id_by_name(
name=parsed_url.owner,
create=True,
)
# Get provider based on host
provider = self._get_provider(parsed_url)
return {
"repo": parsed_url.repo,
"host": parsed_url.host,
"owner_id": owner_id,
"provider": provider,
}
def _get_provider(self, parsed_url):
"""Get provider.
Args:
parsed_url (GitUrlParsed): Parsed URL object
Returns:
str: Provider name
"""
provider = "other"
if parsed_url.assembla:
provider = "assembla"
elif parsed_url.bitbucket or "bitbucket" in parsed_url.host:
provider = "bitbucket"
elif parsed_url.gitlab:
provider = "gitlab"
elif parsed_url.github:
provider = "github"
return provider
# ------------------------------
# YAML mixin methods
# ------------------------------
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"url",
"is_private",
"secret_id",
]
return res

View File

@@ -0,0 +1,107 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
from odoo.tools import ormcache
class CxTowerGitRepoOwner(models.Model):
"""
Git Repository Owner.
Represents an organization or user that owns repositories.
Examples: "cetmix", "OCA", etc.
"""
_name = "cx.tower.git.repo.owner"
_inherit = ["cx.tower.reference.mixin", "cx.tower.yaml.mixin"]
_description = "Cetmix Tower Git Repository Owner"
_order = "name"
display_name = fields.Char(
readonly=False, compute="_compute_display_name", store=True
)
name = fields.Char(
help="Name of the repository owner (e.g., 'cetmix', 'OCA')",
)
reference = fields.Char(
index=True,
compute="_compute_display_name",
required=False,
store=True,
)
repo_ids = fields.One2many(
comodel_name="cx.tower.git.repo",
inverse_name="owner_id",
string="Repositories",
copy=False,
help="Repositories owned by this organization/user",
)
secret_id = fields.Many2one(
comodel_name="cx.tower.key",
string="Secret",
domain="[('key_type', '=', 's')]",
help="Custom secret used for this repository owner",
)
@api.depends("name")
def _compute_display_name(self):
"""Compute display name."""
for owner in self:
# By default, display name is the same as name
name = owner.name
owner.update(
{
"display_name": name or False,
"reference": owner._generate_or_fix_reference(name)
if name
else False,
}
)
@ormcache("self.env.uid", "self.env.su", "name")
def _get_owner_id_by_name(self, name, create=False):
"""Get owner id by name.
Args:
name (str): Owner name
create (bool): Create owner if not found
Returns:
int: Owner ID or None if not found
"""
owner = self.search([("name", "=ilike", name)], limit=1) if name else None
if not owner and create and name:
owner = self.create({"name": name})
return owner.id if owner else None
@api.model_create_multi
def create(self, vals_list):
"""Clear cache on create."""
res = super().create(vals_list)
self.clear_caches()
return res
def write(self, vals):
"""Clear cache on write."""
res = super().write(vals)
if "name" in vals:
self.clear_caches()
return res
def unlink(self):
"""Clear cache on unlink."""
res = super().unlink()
self.clear_caches()
return res
# ------------------------------
# YAML mixin methods
# ------------------------------
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"display_name",
"name",
"secret_id",
]
return res

View File

@@ -0,0 +1,189 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
class CxTowerGitSource(models.Model):
"""
Git Source.
Implements single git source.
Each source can include multiple remotes which can be
branches or pull requests of different repositories.
"""
_name = "cx.tower.git.source"
_description = "Cetmix Tower Git Source"
_inherit = [
"cx.tower.reference.mixin",
"cx.tower.yaml.mixin",
]
_order = "sequence, name"
active = fields.Boolean(related="git_project_id.active", store=True, readonly=True)
enabled = fields.Boolean(
default=True, help="Enable in configuration and exported to files"
)
name = fields.Char(required=False)
sequence = fields.Integer(default=10)
git_project_id = fields.Many2one(
comodel_name="cx.tower.git.project",
string="Git Configuration",
required=True,
ondelete="cascade",
auto_join=True,
)
remote_ids = fields.One2many(
comodel_name="cx.tower.git.remote",
inverse_name="source_id",
auto_join=True,
copy=True,
)
remote_count = fields.Integer(
compute="_compute_remote_count",
string="Remotes",
)
remote_count_private = fields.Integer(
compute="_compute_remote_count",
string="Private Remotes",
)
@api.depends("remote_ids", "remote_ids.enabled", "remote_ids.is_private")
def _compute_remote_count(self):
for record in self:
remote_count = private_remote_count = 0
for remote in record.remote_ids:
if not remote.enabled:
continue
if remote.is_private:
private_remote_count += 1
remote_count += 1
record.update(
{
"remote_count": remote_count,
"remote_count_private": private_remote_count,
}
)
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
# Update name
res._compose_name()
# Update related files and templates on create
res._update_related_files_and_templates()
return res
def write(self, vals):
res = super().write(vals)
# Compose name
if "name" in vals and not vals.get("name"):
self._compose_name()
# Update related files and templates on update
self._update_related_files_and_templates()
return res
def unlink(self):
"""
Override to update related files and templates on unlink
"""
related_files = self.mapped("git_project_id").mapped("git_project_rel_ids")
related_templates = self.mapped("git_project_id").mapped(
"git_project_file_template_rel_ids"
)
res = super().unlink()
# Update related files and templates on unlink
if related_files:
related_files._save_to_file()
if related_templates:
related_templates._save_to_file_template()
return res
def _compose_name(self):
"""Compose name if not provided explicitly"""
for source in self:
if source.name:
continue
remote = fields.first(source.remote_ids)
if not remote:
source.name = _("Empty Source")
continue
remote_repo = remote.repo_id
source.name = f"{remote_repo.owner_id.name}/{remote_repo.repo}"
def _update_related_files_and_templates(self):
# Update related files and templates on update
related_files = self.mapped("git_project_id").mapped("git_project_rel_ids")
if related_files:
related_files._save_to_file()
related_templates = self.mapped("git_project_id").mapped(
"git_project_file_template_rel_ids"
)
if related_templates:
related_templates._save_to_file_template()
# ------------------------------
# Reference mixin methods
# ------------------------------
def _get_pre_populated_model_data(self):
res = super()._get_pre_populated_model_data()
res.update({"cx.tower.git.source": ["cx.tower.git.project", "git_project_id"]})
return res
# ------------------------------
# YAML mixin methods
# ------------------------------
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"enabled",
"sequence",
"remote_ids",
]
return res
# ------------------------------
# Git Aggregator related methods
# ------------------------------
def _git_aggregator_prepare_record(self):
"""Prepare json structure for git aggregator.
Returns:
Dict: Json structure for git aggregator
"""
self.ensure_one()
# Prepare remotes, merges and target
remotes = {}
merges = []
target = None
for remote in self.remote_ids:
if remote.enabled:
remotes.update({remote.name: remote._git_aggregator_prepare_url()})
merges.append(
{
"remote": remote.name,
"ref": remote._git_aggregator_prepare_head(),
}
)
# Set target to first remote name
if not target:
target = remote.name
# If no remotes, return empty dict
if not remotes:
return {}
vals = {
"remotes": remotes,
"merges": merges,
"target": target,
}
# Fetch only first commit if there is only one remote
if len(remotes) == 1:
vals.update({"defaults": {"depth": 1}})
return vals

View File

@@ -0,0 +1,32 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class CxTowerPlanLine(models.Model):
"""Flight Plan Line"""
_inherit = "cx.tower.plan.line"
git_project_id = fields.Many2one(
comodel_name="cx.tower.git.project",
string="Git Project",
help="Select a git project to be linked to the file and server.",
)
is_make_copy = fields.Boolean(
string="Make a Copy",
help="Create a copy of the Git Project instead of linking "
"the file to the existing one.",
)
# ------------------------------
# YAML mixin methods
# ------------------------------
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"git_project_id",
"is_make_copy",
]
return res

View File

@@ -0,0 +1,156 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class CxTowerServer(models.Model):
_inherit = "cx.tower.server"
git_project_rel_ids = fields.One2many(
comodel_name="cx.tower.git.project.rel",
inverse_name="server_id",
copy=False,
depends=["git_project_ids"],
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root",
)
# Helper field to get all git projects related to server
# IMPORTANT: This field may contain duplicates because of the relation nature!
git_project_ids = fields.Many2many(
comodel_name="cx.tower.git.project",
relation="cx_tower_git_project_rel",
column1="server_id",
column2="git_project_id",
readonly=True,
copy=False,
depends=["git_project_rel_ids"],
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root",
)
# ------------------------------
# YAML mixin methods
# ------------------------------
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"git_project_rel_ids",
]
return res
def _get_force_x2m_resolve_models(self):
res = super()._get_force_x2m_resolve_models()
# Add File in order to always try to use existing one
res += ["cx.tower.file"]
return res
def _update_or_create_related_record(
self, model, reference, values, create_immediately=False
):
# Files must be created immediately because they are related
# to both server and git project.
# So if a file is not created immediately when it is created
# for the server, the same file will be created for the git project.
# This will lead to creation of two files with the same content
# for the same server.
if model._name == "cx.tower.file":
create_immediately = True
return super()._update_or_create_related_record(
model, reference, values, create_immediately=create_immediately
)
@api.model
def get_servers_by_git_ref(self, repository_url, head=None, head_type=None):
"""
Return servers linked to a given Git repository reference.
Parameters
----------
repository_url : str
Pre-normalized canonical Git URL
(e.g. ``https://host/owner/repo.git``).
head : str, optional
Branch name, commit SHA, or PR identifier.
head_type : {'branch', 'commit', 'pr'}, optional
Type of the ``head`` argument.
If only ``head`` is provided, it will match across all head types.
If only ``head_type`` is provided, it will filter by type regardless of head
Returns
-------
recordset of cx.tower.server
Matching servers. Empty recordset if no matches.
"""
server_obj = self.env["cx.tower.server"]
# URL MUST be already canonical.
if not repository_url:
return server_obj
# Get repository id by URL
repo_id = self.env["cx.tower.git.repo"]._get_repo_id_by_url(
repository_url, raise_if_invalid=False
)
if not repo_id:
return server_obj
repo = self.env["cx.tower.git.repo"].browse(repo_id)
# Compose domain for remotes
remote_domain = [
("source_id.enabled", "=", True),
("enabled", "=", True),
]
if head:
head = self.env["cx.tower.git.remote"]._sanitize_head(head)
remote_domain.append(("head", "=", head))
if head_type:
remote_domain.append(("head_type", "=", head_type))
# Get remotes
remotes = repo.remote_ids.filtered_domain(remote_domain)
if not remotes:
return server_obj
# Get servers from remotes
servers = remotes.mapped("git_project_id.git_project_rel_ids.server_id")
return servers
def _command_runner_file_using_template_create_file(
self,
file_template_id,
server_dir,
plan_line,
if_file_exists,
**kwargs,
):
"""Override to create git project relation
when creating a file using a template.
"""
file = super()._command_runner_file_using_template_create_file(
file_template_id, server_dir, plan_line, if_file_exists, **kwargs
)
if file and plan_line:
git_project = plan_line.git_project_id
if not git_project:
return file
if plan_line.is_make_copy:
# Remove default_server_ids from context, because this relation
# will be created through git_project_rel_ids.
# default_server_ids will interfere at the moment when
# pairs of values are created through SQL query
# in the method write_real and it does not take into account
# that in this case we are creating a copy of the git project
git_project = git_project.with_context(default_server_ids=False).copy()
self.env["cx.tower.git.project.rel"].create(
{
"git_project_id": git_project.id,
"server_id": self.id,
"file_id": file.id,
"project_format": git_project._default_project_format(),
}
)
return file

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 Git Management functionality 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,43 @@
## 16.0.2.0.1 (2025-12-11)
- Features: Improve search views, implement the search panel for selected views. (5139)
## 16.0.2.0.0 (2025-10-27)
- Features: Major refactoring: implement Git repository entity. (4914)
## 16.0.1.0.6 (2025-08-18)
- Features: Link or copy a git project when uploading the linked file using command (4759)
## 16.0.1.0.5 (2025-08-17)
- Features: Search servers by git reference (4838)
## 16.0.1.0.4 (2025-07-29)
- Features: Export related commands and flight plans together with server (4849)
## 16.0.1.0.3 (2025-05-23)
- Bugfixes: Duplicated file is created when importing a YAML file with a git project. (4715)
## 16.0.1.0.2 (2025-05-16)
- Features: Record references for git relations. (4670)
## 16.0.1.0.1 (2025-05-09)
- Bugfixes: Non-critical issues and performance improvements. (4663)
## 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,49 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Manager Read Rule -->
<record id="rule_git_project_file_template_rel_manager_read" model="ir.rule">
<field
name="name"
>Git Project File Template Relation: Manager Read Access</field>
<field name="model_id" ref="model_cx_tower_git_project_file_template_rel" />
<field name="domain_force">['&amp;',
'|',
('git_project_id.user_ids', 'in', [user.id]),
('git_project_id.manager_ids', 'in', [user.id]),
'|',
('file_template_id.user_ids', 'in', [user.id]),
('file_template_id.manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Manager Write/Create/Delete Rule -->
<record id="rule_git_project_file_template_rel_manager_write" model="ir.rule">
<field
name="name"
>Git Project File Template Relation: Manager Write Access</field>
<field name="model_id" ref="model_cx_tower_git_project_file_template_rel" />
<field name="domain_force">[
('git_project_id.manager_ids', 'in', [user.id]),
('file_template_id.manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="0" />
<field name="perm_write" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_unlink" eval="1" />
</record>
<!-- Root Access Rule -->
<record id="rule_git_project_file_template_rel_root" model="ir.rule">
<field name="name">Git Project File Template Relation: Root Full Access</field>
<field name="model_id" ref="model_cx_tower_git_project_file_template_rel" />
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
</record>
</odoo>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Manager Read Rule -->
<record id="rule_git_project_rel_manager_read" model="ir.rule">
<field name="name">Git Project Relation: Manager Read Access</field>
<field name="model_id" ref="model_cx_tower_git_project_rel" />
<field name="domain_force">['&amp;',
'|',
('git_project_id.user_ids', 'in', [user.id]),
('git_project_id.manager_ids', 'in', [user.id]),
'|',
('server_id.user_ids', 'in', [user.id]),
('server_id.manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Manager Write/Create/Delete Rule -->
<record id="rule_git_project_rel_manager_create_write_unlink" model="ir.rule">
<field
name="name"
>Git Project Relation: Manager Create/Write/Delete Access</field>
<field name="model_id" ref="model_cx_tower_git_project_rel" />
<field name="domain_force">[('git_project_id.manager_ids', 'in', [user.id]),
('server_id.manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="0" />
<field name="perm_write" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_unlink" eval="1" />
</record>
<!-- Root Access Rule -->
<record id="rule_git_project_rel_root" model="ir.rule">
<field name="name">Git Project Relation: Root Full Access</field>
<field name="model_id" ref="model_cx_tower_git_project_rel" />
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
</record>
</odoo>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Manager Read Rule -->
<record id="rule_git_project_manager_read" model="ir.rule">
<field name="name">Git Project: Manager Read Access</field>
<field name="model_id" ref="model_cx_tower_git_project" />
<field
name="domain_force"
>['|', ('user_ids', 'in', [user.id]), ('manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Additional Manager Read Rule with Server Access -->
<record id="rule_git_project_manager_read_server" model="ir.rule">
<field name="name">Git Project: Manager Read Access via Server</field>
<field name="model_id" ref="model_cx_tower_git_project" />
<field name="domain_force">['|',
('server_ids.user_ids', 'in', [user.id]),
('server_ids.manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Manager Write/Create Rule -->
<record id="rule_git_project_manager_write" model="ir.rule">
<field name="name">Git Project: Manager Write Access</field>
<field name="model_id" ref="model_cx_tower_git_project" />
<field name="domain_force">[('manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="0" />
<field name="perm_write" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Manager Delete Rule -->
<record id="rule_git_project_manager_unlink" model="ir.rule">
<field name="name">Git Project: Manager Delete Access</field>
<field name="model_id" ref="model_cx_tower_git_project" />
<field
name="domain_force"
>[('manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_unlink" eval="1" />
</record>
<!-- Root Access Rule -->
<record id="rule_git_project_root" model="ir.rule">
<field name="name">Git Project: Root Full Access</field>
<field name="model_id" ref="model_cx_tower_git_project" />
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
</record>
</odoo>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Manager Read Rule -->
<record id="rule_git_remote_manager_read" model="ir.rule">
<field name="name">Git Remote: Manager Read Access</field>
<field name="model_id" ref="model_cx_tower_git_remote" />
<field
name="domain_force"
>['|', ('git_project_id.user_ids', 'in', [user.id]), ('git_project_id.manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Additional Manager Read Rule with Server Access -->
<record id="rule_git_remote_manager_read_server" model="ir.rule">
<field name="name">Git Remote: Manager Read Access via Server</field>
<field name="model_id" ref="model_cx_tower_git_remote" />
<field name="domain_force">['|',
('git_project_id.server_ids.user_ids', 'in', [user.id]),
('git_project_id.server_ids.manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Manager Write/Create Rule -->
<record id="rule_git_remote_manager_write" model="ir.rule">
<field name="name">Git Remote: Manager Write/Create Access</field>
<field name="model_id" ref="model_cx_tower_git_remote" />
<field
name="domain_force"
>[('git_project_id.manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="0" />
<field name="perm_write" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Manager Delete Rule -->
<record id="rule_git_remote_manager_unlink" model="ir.rule">
<field name="name">Git Remote: Manager Delete Access</field>
<field name="model_id" ref="model_cx_tower_git_remote" />
<field
name="domain_force"
>[('git_project_id.manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_unlink" eval="1" />
</record>
<!-- Root Access Rule -->
<record id="rule_git_remote_root" model="ir.rule">
<field name="name">Git Remote: Root Full Access</field>
<field name="model_id" ref="model_cx_tower_git_remote" />
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_unlink" eval="1" />
</record>
</odoo>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Manager Read Rule - View All Records - nothing to add as this is default -->
<!-- Manager Write/Create Rule -->
<record id="rule_git_repo_owner_manager_write" model="ir.rule">
<field name="name">Git Repository Owner: Manager Write/Create Access</field>
<field name="model_id" ref="model_cx_tower_git_repo_owner" />
<field name="domain_force">[('create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="0" />
<field name="perm_write" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Root Access Rule -->
<record id="rule_git_repo_owner_root" model="ir.rule">
<field name="name">Git Repository Owner: Root Full Access</field>
<field name="model_id" ref="model_cx_tower_git_repo_owner" />
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
</record>
</odoo>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Manager Read Rule - View All Records - nothing to add as this is default -->
<!-- Manager Write/Create Rule -->
<record id="rule_git_repo_manager_write" model="ir.rule">
<field name="name">Git Repository: Manager Write/Create Access</field>
<field name="model_id" ref="model_cx_tower_git_repo" />
<field name="domain_force">[('create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="0" />
<field name="perm_write" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Root Access Rule -->
<record id="rule_git_repo_root" model="ir.rule">
<field name="name">Git Repository: Root Full Access</field>
<field name="model_id" ref="model_cx_tower_git_repo" />
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
</record>
</odoo>

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Git Source Record Rules -->
<!-- Manager Read Rule -->
<record id="rule_git_source_manager_read" model="ir.rule">
<field name="name">Git Source: Manager Read Access</field>
<field name="model_id" ref="model_cx_tower_git_source" />
<field
name="domain_force"
>['|', ('git_project_id.user_ids', 'in', [user.id]), ('git_project_id.manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Additional Manager Read Rule with Server Access -->
<record id="rule_git_source_manager_read_server" model="ir.rule">
<field name="name">Git Source: Manager Read Access via Server</field>
<field name="model_id" ref="model_cx_tower_git_source" />
<field name="domain_force">['|',
('git_project_id.server_ids.user_ids', 'in', [user.id]),
('git_project_id.server_ids.manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Manager Write/Create Rule -->
<record id="rule_git_source_manager_write" model="ir.rule">
<field name="name">Git Source: Manager Write/Create Access</field>
<field name="model_id" ref="model_cx_tower_git_source" />
<field
name="domain_force"
>[('git_project_id.manager_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="0" />
<field name="perm_write" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_unlink" eval="0" />
</record>
<!-- Manager Delete Rule -->
<record id="rule_git_source_manager_unlink" model="ir.rule">
<field name="name">Git Source: Manager Delete Access</field>
<field name="model_id" ref="model_cx_tower_git_source" />
<field
name="domain_force"
>[('git_project_id.manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
<field name="perm_read" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_create" eval="0" />
<field name="perm_unlink" eval="1" />
</record>
<!-- Root Access Rule -->
<record id="rule_git_source_root" model="ir.rule">
<field name="name">Git Source: Root Full Access</field>
<field name="model_id" ref="model_cx_tower_git_source" />
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
<field name="perm_read" eval="1" />
<field name="perm_write" eval="1" />
<field name="perm_create" eval="1" />
<field name="perm_unlink" eval="1" />
</record>
</odoo>

View File

@@ -0,0 +1,15 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_git_config_manager,Git Config Manager,model_cx_tower_git_project,cetmix_tower_server.group_manager,1,1,1,1
access_git_config_root,Git Config Root,model_cx_tower_git_project,cetmix_tower_server.group_root,1,1,1,1
access_git_source_manager,Git Source Manager,model_cx_tower_git_source,cetmix_tower_server.group_manager,1,1,1,1
access_git_source_root,Git Source Root,model_cx_tower_git_source,cetmix_tower_server.group_root,1,1,1,1
access_git_remote_manager,Git Remote Manager,model_cx_tower_git_remote,cetmix_tower_server.group_manager,1,1,1,1
access_git_remote_root,Git Remote Root,model_cx_tower_git_remote,cetmix_tower_server.group_root,1,1,1,1
access_git_repo_manager,Git Repository Manager,model_cx_tower_git_repo,cetmix_tower_server.group_manager,1,1,1,1
access_git_repo_root,Git Repository Root,model_cx_tower_git_repo,cetmix_tower_server.group_root,1,1,1,1
access_git_repo_owner_manager,Git Repository Owner Manager,model_cx_tower_git_repo_owner,cetmix_tower_server.group_manager,1,1,1,0
access_git_repo_owner_root,Git Repository Owner Root,model_cx_tower_git_repo_owner,cetmix_tower_server.group_root,1,1,1,1
access_git_project_server_file_rel,Git Project Server File Rel Manager,model_cx_tower_git_project_rel,cetmix_tower_server.group_manager,1,1,1,1
access_git_project_server_file_rel_root,Git Project Server File Rel Root,model_cx_tower_git_project_rel,cetmix_tower_server.group_root,1,1,1,1
access_git_project_file_template_rel,Git Project File Template Rel Manager,model_cx_tower_git_project_file_template_rel,cetmix_tower_server.group_manager,1,1,1,1
access_git_project_file_template_rel_root,Git Project File Template Rel Root,model_cx_tower_git_project_file_template_rel,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_git_config_manager Git Config Manager model_cx_tower_git_project cetmix_tower_server.group_manager 1 1 1 1
3 access_git_config_root Git Config Root model_cx_tower_git_project cetmix_tower_server.group_root 1 1 1 1
4 access_git_source_manager Git Source Manager model_cx_tower_git_source cetmix_tower_server.group_manager 1 1 1 1
5 access_git_source_root Git Source Root model_cx_tower_git_source cetmix_tower_server.group_root 1 1 1 1
6 access_git_remote_manager Git Remote Manager model_cx_tower_git_remote cetmix_tower_server.group_manager 1 1 1 1
7 access_git_remote_root Git Remote Root model_cx_tower_git_remote cetmix_tower_server.group_root 1 1 1 1
8 access_git_repo_manager Git Repository Manager model_cx_tower_git_repo cetmix_tower_server.group_manager 1 1 1 1
9 access_git_repo_root Git Repository Root model_cx_tower_git_repo cetmix_tower_server.group_root 1 1 1 1
10 access_git_repo_owner_manager Git Repository Owner Manager model_cx_tower_git_repo_owner cetmix_tower_server.group_manager 1 1 1 0
11 access_git_repo_owner_root Git Repository Owner Root model_cx_tower_git_repo_owner cetmix_tower_server.group_root 1 1 1 1
12 access_git_project_server_file_rel Git Project Server File Rel Manager model_cx_tower_git_project_rel cetmix_tower_server.group_manager 1 1 1 1
13 access_git_project_server_file_rel_root Git Project Server File Rel Root model_cx_tower_git_project_rel cetmix_tower_server.group_root 1 1 1 1
14 access_git_project_file_template_rel Git Project File Template Rel Manager model_cx_tower_git_project_file_template_rel cetmix_tower_server.group_manager 1 1 1 1
15 access_git_project_file_template_rel_root Git Project File Template Rel Root model_cx_tower_git_project_file_template_rel cetmix_tower_server.group_root 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,497 @@
<!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 Git</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-git">
<h1 class="title">Cetmix Tower Git</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:583744e8956f294682a551fc082f086b174b8d2b72652c21b1dd68f3933e7211
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<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_git"><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 Git Management functionality 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-12-11)</a></li>
<li><a class="reference internal" href="#section-2" id="toc-entry-5">16.0.2.0.0 (2025-10-27)</a></li>
<li><a class="reference internal" href="#section-3" id="toc-entry-6">16.0.1.0.6 (2025-08-18)</a></li>
<li><a class="reference internal" href="#section-4" id="toc-entry-7">16.0.1.0.5 (2025-08-17)</a></li>
<li><a class="reference internal" href="#section-5" id="toc-entry-8">16.0.1.0.4 (2025-07-29)</a></li>
<li><a class="reference internal" href="#section-6" id="toc-entry-9">16.0.1.0.3 (2025-05-23)</a></li>
<li><a class="reference internal" href="#section-7" id="toc-entry-10">16.0.1.0.2 (2025-05-16)</a></li>
<li><a class="reference internal" href="#section-8" id="toc-entry-11">16.0.1.0.1 (2025-05-09)</a></li>
<li><a class="reference internal" href="#section-9" id="toc-entry-12">16.0.1.0.0</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-13">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-14">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-15">Authors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-16">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-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-5">16.0.2.0.0 (2025-10-27)</a></h2>
<ul class="simple">
<li>Features: Major refactoring: implement Git repository entity. (4914)</li>
</ul>
</div>
<div class="section" id="section-3">
<h2><a class="toc-backref" href="#toc-entry-6">16.0.1.0.6 (2025-08-18)</a></h2>
<ul class="simple">
<li>Features: Link or copy a git project when uploading the linked file
using command (4759)</li>
</ul>
</div>
<div class="section" id="section-4">
<h2><a class="toc-backref" href="#toc-entry-7">16.0.1.0.5 (2025-08-17)</a></h2>
<ul class="simple">
<li>Features: Search servers by git reference (4838)</li>
</ul>
</div>
<div class="section" id="section-5">
<h2><a class="toc-backref" href="#toc-entry-8">16.0.1.0.4 (2025-07-29)</a></h2>
<ul class="simple">
<li>Features: Export related commands and flight plans together with
server (4849)</li>
</ul>
</div>
<div class="section" id="section-6">
<h2><a class="toc-backref" href="#toc-entry-9">16.0.1.0.3 (2025-05-23)</a></h2>
<ul class="simple">
<li>Bugfixes: Duplicated file is created when importing a YAML file with a
git project. (4715)</li>
</ul>
</div>
<div class="section" id="section-7">
<h2><a class="toc-backref" href="#toc-entry-10">16.0.1.0.2 (2025-05-16)</a></h2>
<ul class="simple">
<li>Features: Record references for git relations. (4670)</li>
</ul>
</div>
<div class="section" id="section-8">
<h2><a class="toc-backref" href="#toc-entry-11">16.0.1.0.1 (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-9">
<h2><a class="toc-backref" href="#toc-entry-12">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-13">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_git%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-14">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-15">Authors</a></h2>
<ul class="simple">
<li>Cetmix</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-16">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_git">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 @@
from . import test_remote
from . import test_source
from . import test_project
from . import test_file_rel
from . import test_file_template_rel
from . import test_server
from . import test_repo

View File

@@ -0,0 +1,136 @@
from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon
class CommonTest(TestTowerCommon):
"""Common test class for all tests."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Models
cls.GitProject = cls.env["cx.tower.git.project"]
cls.GitProjectRel = cls.env["cx.tower.git.project.rel"]
cls.GitProjectFileTemplateRel = cls.env[
"cx.tower.git.project.file.template.rel"
]
cls.GitSource = cls.env["cx.tower.git.source"]
cls.GitRemote = cls.env["cx.tower.git.remote"]
# Data
# Project
cls.git_project_1 = cls.GitProject.create({"name": "Git Project 1"})
# Sources
cls.git_source_1 = cls.GitSource.create(
{"name": "Git Source 1", "git_project_id": cls.git_project_1.id}
)
cls.git_source_2 = cls.GitSource.create(
{"name": "Git Source 2", "git_project_id": cls.git_project_1.id}
)
# Repositories
cls.Repo = cls.env["cx.tower.git.repo"]
cls.RepoOwner = cls.env["cx.tower.git.repo.owner"]
cls.repo_cetmix_tower = cls.Repo.create(
{
"name": "Cetmix Tower",
"url": "https://github.com/cetmix-test/cetmix-tower-test.git",
}
)
cls.repo_oca_web = cls.Repo.create(
{
"name": "OCA Web",
"url": "https://github.com/oca-test/web-test.git",
}
)
cls.repo_odoo_enterprise = cls.Repo.create(
{
"name": "Odoo Enterprise",
"url": "https://github.com/odoo-test/enterprise-test.git",
"is_private": True,
}
)
cls.repo_gitlab_private = cls.Repo.create(
{
"name": "GitLab Private",
"url": "git@my.gitlab.com:cetmix-test/cetmix-tower-test.git",
"is_private": True,
}
)
cls.repo_bitbucket_private = cls.Repo.create(
{
"name": "Bitbucket Private",
"url": "https://bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git",
"is_private": True,
}
)
# Same urls, different protocols (intentionally aliased)
cls.repo_other_ssh = cls.Repo.create(
{"url": "git@memegit.com:cetmix-test/cetmix-tower-test.git"}
)
cls.repo_other_https = cls.repo_other_ssh
# Remotes
cls.remote_github_https = cls.GitRemote.create(
{
"repo_id": cls.repo_cetmix_tower.id,
"source_id": cls.git_source_1.id,
"head_type": "pr",
"head": "https://github.com/cetmix-test/cetmix-tower-test/pull/123",
"sequence": 1,
}
)
cls.remote_gitlab_https = cls.GitRemote.create(
{
"repo_id": cls.repo_gitlab_private.id,
"source_id": cls.git_source_1.id,
"head_type": "branch",
"head": "main",
"sequence": 2,
}
)
cls.remote_gitlab_ssh = cls.GitRemote.create(
{
"repo_id": cls.repo_gitlab_private.id,
"source_id": cls.git_source_1.id,
"head_type": "commit",
"url_protocol": "ssh",
"head": "10000000",
"sequence": 3,
}
)
cls.remote_bitbucket_https = cls.GitRemote.create(
{
"repo_id": cls.repo_bitbucket_private.id,
"source_id": cls.git_source_2.id,
"head_type": "branch",
"head": "dev",
"sequence": 4,
}
)
cls.remote_other_ssh = cls.GitRemote.create(
{
"repo_id": cls.repo_other_ssh.id,
"source_id": cls.git_source_2.id,
"head_type": "branch",
"url_protocol": "ssh",
"head": "old",
"sequence": 5,
}
)
# File
cls.server_1_file_1 = cls.File.create(
{
"name": "File 1",
"server_id": cls.server_test_1.id,
"source": "tower",
}
)
cls.file_template_1 = cls.FileTemplate.create(
{
"name": "File Template 1",
}
)

View File

@@ -0,0 +1,390 @@
from odoo.exceptions import AccessError
from .common import CommonTest
class TestFileRel(CommonTest):
"""Test class for git file relation."""
def setUp(self):
super().setUp()
self.file_1_rel = self.GitProjectRel.create(
{
"server_id": self.server_test_1.id,
"file_id": self.server_1_file_1.id,
"git_project_id": self.git_project_1.id,
"project_format": "git_aggregator",
}
)
def test_file_rel_create(self):
"""Test if file relation is created correctly"""
# -- 1 --
# Check if file content is updated
# Get code from project
yaml_code_from_project = (
self.file_1_rel.git_project_id._generate_code_git_aggregator(
self.file_1_rel
)
)
self.assertEqual(
self.server_1_file_1.code,
yaml_code_from_project,
"File content is not updated correctly",
)
# Check specific if remote is present in file
self.assertIn(
self.remote_other_ssh.repo_id.url_ssh,
self.server_1_file_1.code,
"Remote is not present in file",
)
# -- 2 --
# Modify remove and check if file content is updated
self.remote_other_ssh.repo_id = self.Repo.create(
{
"url": "https://github.com/cetmix/cetmix-memes.git",
}
)
self.remote_other_ssh.url_protocol = "https"
# Must be different from previous project code
self.assertNotEqual(
self.server_1_file_1.code,
yaml_code_from_project,
"File content is not updated correctly",
)
# New remote must be present in file
self.assertIn(
"https://github.com/cetmix/cetmix-memes.git",
self.server_1_file_1.code,
"Remote is not present in file",
)
# -- 3 --
# Disable source and check if file content is updated
self.git_source_2.active = False
self.assertNotIn(
"https://github.com/cetmix/cetmix-memes.git",
self.server_1_file_1.code,
"Remote is present in file",
)
def test_format_git_aggregator(self):
"""Test if format git aggregator works correctly"""
# -- 1 --
# Check if YAML code is generated correctly
yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower
# It's designed to be used with git-aggregator tool developed by Acsone.
# Documentation for git-aggregator: https://github.com/acsone/git-aggregator
# You need to set the following variables in your environment:
# BITBUCKET_TOKEN, GITLAB_TOKEN, GITLAB_TOKEN_NAME
# and run git-aggregator with '--expand-env' parameter.
./git_project_1_git_source_1:
remotes:
remote_1: https://github.com/cetmix-test/cetmix-tower-test.git
remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git
remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git
merges:
- remote: remote_1
ref: refs/pull/123/head
- remote: remote_2
ref: main
- remote: remote_3
ref: '10000000'
target: remote_1
./git_project_1_git_source_1_2:
remotes:
remote_1: https://x-token-auth:$BITBUCKET_TOKEN@bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git
remote_2: git@memegit.com:cetmix-test/cetmix-tower-test.git
merges:
- remote: remote_1
ref: dev
- remote: remote_2
ref: old
target: remote_1
""" # noqa: E501
# Get code from project
yaml_code_from_project = (
self.file_1_rel.git_project_id._generate_code_git_aggregator(
self.file_1_rel
)
)
self.assertEqual(
yaml_code_from_project,
yaml_code,
"YAML code is not generated correctly",
)
# -- 2 --
# Unlink remote and check if file content is updated
self.remote_github_https.unlink()
yaml_code_from_project = (
self.file_1_rel.git_project_id._generate_code_git_aggregator(
self.file_1_rel
)
)
yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower
# It's designed to be used with git-aggregator tool developed by Acsone.
# Documentation for git-aggregator: https://github.com/acsone/git-aggregator
# You need to set the following variables in your environment:
# BITBUCKET_TOKEN, GITLAB_TOKEN, GITLAB_TOKEN_NAME
# and run git-aggregator with '--expand-env' parameter.
./git_project_1_git_source_1:
remotes:
remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git
remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git
merges:
- remote: remote_2
ref: main
- remote: remote_3
ref: '10000000'
target: remote_2
./git_project_1_git_source_1_2:
remotes:
remote_1: https://x-token-auth:$BITBUCKET_TOKEN@bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git
remote_2: git@memegit.com:cetmix-test/cetmix-tower-test.git
merges:
- remote: remote_1
ref: dev
- remote: remote_2
ref: old
target: remote_1
""" # noqa: E501
self.assertEqual(
yaml_code_from_project,
yaml_code,
"YAML code is not generated correctly",
)
# -- 3 --
# Unlink source and check if file content is updated
self.git_source_2.unlink()
yaml_code_from_project = (
self.file_1_rel.git_project_id._generate_code_git_aggregator(
self.file_1_rel
)
)
yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower
# It's designed to be used with git-aggregator tool developed by Acsone.
# Documentation for git-aggregator: https://github.com/acsone/git-aggregator
# You need to set the following variables in your environment:
# GITLAB_TOKEN, GITLAB_TOKEN_NAME
# and run git-aggregator with '--expand-env' parameter.
./git_project_1_git_source_1:
remotes:
remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git
remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git
merges:
- remote: remote_2
ref: main
- remote: remote_3
ref: '10000000'
target: remote_2
""" # noqa: E501
self.assertEqual(
yaml_code_from_project,
yaml_code,
"YAML code is not generated correctly",
)
def test_user_access(self):
"""Test that regular users have no access to git project relations"""
user_rel = self.GitProjectRel.with_user(self.user)
# Try create - should fail
with self.assertRaises(AccessError):
user_rel.create(
{
"server_id": self.server_test_1.id,
"file_id": self.server_1_file_1.id,
"git_project_id": self.git_project_1.id,
"project_format": "git_aggregator",
}
)
# Try read - should fail
with self.assertRaises(AccessError):
user_rel.browse(self.file_1_rel.id).read(["name"])
# Try write - should fail
with self.assertRaises(AccessError):
user_rel.browse(self.file_1_rel.id).write(
{"project_format": "git_aggregator"}
)
# Try unlink - should fail
with self.assertRaises(AccessError):
user_rel.browse(self.file_1_rel.id).unlink()
def test_manager_read_access(self):
"""Test manager read access rules"""
manager_rel = self.GitProjectRel.with_user(self.manager)
# Initially manager should not have access
with self.assertRaises(AccessError):
manager_rel.browse(self.file_1_rel.id).read(["name"])
# Add manager as project user - should have read access
self.git_project_1.write({"user_ids": [(4, self.manager.id)]})
self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1")
# Remove from project, add as server user - should have read access
self.git_project_1.write({"user_ids": [(3, self.manager.id)]})
self.server_test_1.write({"user_ids": [(4, self.manager.id)]})
self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1")
# Remove from server users, add as project manager - should have read access
self.server_test_1.write({"user_ids": [(3, self.manager.id)]})
self.git_project_1.write({"manager_ids": [(4, self.manager.id)]})
self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1")
# Remove from project, add as server manager - should have read access
self.git_project_1.write({"manager_ids": [(3, self.manager.id)]})
self.server_test_1.write({"manager_ids": [(4, self.manager.id)]})
self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1")
def test_manager_write_access(self):
"""Test manager write/create access rules"""
manager_rel = self.GitProjectRel.with_user(self.manager)
# Create new file to avoid unique constraint violation
file_2 = self.File.create(
{
"name": "test_file_2",
"server_id": self.server_test_1.id,
"source": "tower",
"file_type": "text",
}
)
# Try create without being project and server manager - should fail
with self.assertRaises(AccessError):
manager_rel.create(
{
"server_id": self.server_test_1.id,
"file_id": file_2.id,
"git_project_id": self.git_project_1.id,
"project_format": "git_aggregator",
}
)
# Add as project manager only - should still fail
file_3 = self.File.create(
{
"name": "test_file_3",
"server_id": self.server_test_1.id,
"source": "tower",
"file_type": "text",
}
)
self.git_project_1.write({"manager_ids": [(4, self.manager.id)]})
with self.assertRaises(AccessError):
manager_rel.create(
{
"server_id": self.server_test_1.id,
"file_id": file_3.id,
"git_project_id": self.git_project_1.id,
"project_format": "git_aggregator",
}
)
# Add as server manager - should succeed
file_4 = self.File.create(
{
"name": "test_file_4",
"server_id": self.server_test_1.id,
"source": "tower",
"file_type": "text",
}
)
self.server_test_1.write({"manager_ids": [(4, self.manager.id)]})
rel = manager_rel.create(
{
"server_id": self.server_test_1.id,
"file_id": file_4.id,
"git_project_id": self.git_project_1.id,
"project_format": "git_aggregator",
}
)
self.assertTrue(rel.exists())
# Test write access
rel.write({"project_format": "git_aggregator"})
# Remove server manager access - should fail to write
self.server_test_1.write({"manager_ids": [(3, self.manager.id)]})
with self.assertRaises(AccessError):
rel.write({"project_format": "git_aggregator"})
# Remove project manager access - should fail to write
self.git_project_1.write({"manager_ids": [(3, self.manager.id)]})
with self.assertRaises(AccessError):
rel.write({"project_format": "git_aggregator"})
def test_manager_unlink_access(self):
"""Test manager unlink access rules"""
manager_rel = self.GitProjectRel.with_user(self.manager)
# Try delete without being project and server manager - should fail
with self.assertRaises(AccessError):
manager_rel.browse(self.file_1_rel.id).unlink()
# Add as project manager only - should fail
self.git_project_1.write({"manager_ids": [(4, self.manager.id)]})
with self.assertRaises(AccessError):
manager_rel.browse(self.file_1_rel.id).unlink()
# Add as server manager - should succeed
self.server_test_1.write({"manager_ids": [(4, self.manager.id)]})
self.file_1_rel.unlink()
self.assertFalse(self.file_1_rel.exists())
def test_root_access(self):
"""Test root access rules"""
root_rel = self.GitProjectRel.with_user(self.root)
# Create new file to avoid unique constraint violation
file_3 = self.File.create(
{
"name": "test_file_3",
"server_id": self.server_test_1.id,
"source": "tower",
"file_type": "text",
}
)
# Create - should succeed
rel = root_rel.create(
{
"server_id": self.server_test_1.id,
"file_id": file_3.id,
"git_project_id": self.git_project_1.id,
"project_format": "git_aggregator",
}
)
self.assertTrue(rel.exists())
# Read - should succeed
self.assertEqual(root_rel.browse(rel.id).name, "Git Project 1")
# Write - should succeed
root_rel.browse(rel.id).write({"project_format": "git_aggregator"})
# Delete - should succeed
rel.unlink()
self.assertFalse(rel.exists())

View File

@@ -0,0 +1,308 @@
from odoo.exceptions import AccessError
from .common import CommonTest
class TestFileTemplateRel(CommonTest):
"""Test class for git file template relation."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.file_template_1_rel = cls.GitProjectFileTemplateRel.create(
{
"git_project_id": cls.git_project_1.id,
"file_template_id": cls.file_template_1.id,
"project_format": "git_aggregator",
}
)
def test_file_template_rel_create(self):
"""Test if file template relation is created correctly"""
# -- 1 --
# Check if file content is updated
# Get code from project
yaml_code_from_project = (
self.file_template_1_rel.git_project_id._generate_code_git_aggregator(
self.file_template_1_rel
)
)
self.assertEqual(
self.file_template_1.code,
yaml_code_from_project,
"File template content is not updated correctly",
)
# Check specific if remote is present in file
self.assertIn(
self.remote_other_ssh.repo_id.url_ssh,
self.file_template_1.code,
"Remote is not present in file template",
)
# -- 2 --
# Modify remove and check if file template content is updated
self.remote_other_ssh.repo_id = self.Repo.create(
{
"url": "https://github.com/cetmix/cetmix-memes.git",
}
)
self.remote_other_ssh.url_protocol = "https"
# Must be different from previous project code
self.assertNotEqual(
self.file_template_1.code,
yaml_code_from_project,
"File template content is not updated correctly",
)
# New remote must be present in file
self.assertIn(
"https://github.com/cetmix/cetmix-memes.git",
self.file_template_1.code,
"Remote is not present in file template",
)
# -- 3 --
# Disable source and check if file content is updated
self.git_source_2.active = False
self.assertNotIn(
"https://github.com/cetmix/cetmix-memes.git",
self.file_template_1.code,
"Remote is present in file template",
)
def test_format_git_aggregator(self):
"""Test if format git aggregator works correctly"""
# -- 1 --
# Check if YAML code is generated correctly
yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower
# It's designed to be used with git-aggregator tool developed by Acsone.
# Documentation for git-aggregator: https://github.com/acsone/git-aggregator
# You need to set the following variables in your environment:
# BITBUCKET_TOKEN, GITLAB_TOKEN, GITLAB_TOKEN_NAME
# and run git-aggregator with '--expand-env' parameter.
./git_project_1_git_source_1:
remotes:
remote_1: https://github.com/cetmix-test/cetmix-tower-test.git
remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git
remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git
merges:
- remote: remote_1
ref: refs/pull/123/head
- remote: remote_2
ref: main
- remote: remote_3
ref: '10000000'
target: remote_1
./git_project_1_git_source_1_2:
remotes:
remote_1: https://x-token-auth:$BITBUCKET_TOKEN@bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git
remote_2: git@memegit.com:cetmix-test/cetmix-tower-test.git
merges:
- remote: remote_1
ref: dev
- remote: remote_2
ref: old
target: remote_1
""" # noqa: E501
# Get code from project
yaml_code_from_project = (
self.file_template_1_rel.git_project_id._generate_code_git_aggregator(
self.file_template_1_rel
)
)
self.assertEqual(
yaml_code_from_project,
yaml_code,
"YAML code is not generated correctly",
)
def test_user_access(self):
"""Test that regular users have no access to git project relations"""
user_rel = self.GitProjectFileTemplateRel.with_user(self.user)
# Try create - should fail
with self.assertRaises(AccessError):
user_rel.create(
{
"git_project_id": self.git_project_1.id,
"file_template_id": self.file_template_1.id,
"project_format": "git_aggregator",
}
)
# Try read - should fail
with self.assertRaises(AccessError):
user_rel.browse(self.file_template_1_rel.id).read(["name"])
# Try write - should fail
with self.assertRaises(AccessError):
user_rel.browse(self.file_template_1_rel.id).write(
{"project_format": "git_aggregator"}
)
# Try unlink - should fail
with self.assertRaises(AccessError):
user_rel.browse(self.file_template_1_rel.id).unlink()
def test_manager_read_access(self):
"""Test manager read access rules"""
manager_rel = self.GitProjectFileTemplateRel.with_user(self.manager)
# Initially manager should not have access
with self.assertRaises(AccessError):
manager_rel.browse(self.file_template_1_rel.id).read(["name"])
# Add manager as project user - should have read access
self.git_project_1.write({"user_ids": [(4, self.manager.id)]})
self.assertEqual(
manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1"
)
# Remove from project, add as file template user
# should have read access
self.git_project_1.write({"user_ids": [(3, self.manager.id)]})
self.file_template_1.write({"user_ids": [(4, self.manager.id)]})
self.assertEqual(
manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1"
)
# Remove from file template users, add as project manager
# should have read access
self.file_template_1.write({"user_ids": [(3, self.manager.id)]})
self.git_project_1.write({"manager_ids": [(4, self.manager.id)]})
self.assertEqual(
manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1"
)
# Remove from project, add as file template manager
# should have read access
self.git_project_1.write({"manager_ids": [(3, self.manager.id)]})
self.file_template_1.write({"manager_ids": [(4, self.manager.id)]})
self.assertEqual(
manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1"
)
def test_manager_write_access(self):
"""Test manager write/create access rules"""
manager_rel = self.GitProjectFileTemplateRel.with_user(self.manager)
# Create new file template to avoid unique constraint violation
file_template_2 = self.FileTemplate.create(
{
"name": "test_file_template_2",
}
)
# Try create without being project and file template manager - should fail
with self.assertRaises(AccessError):
manager_rel.create(
{
"git_project_id": self.git_project_1.id,
"file_template_id": file_template_2.id,
"project_format": "git_aggregator",
}
)
# Add as project manager only - should still fail
file_template_3 = self.FileTemplate.create(
{
"name": "test_file_template_3",
}
)
self.git_project_1.write({"manager_ids": [(4, self.manager.id)]})
with self.assertRaises(AccessError):
manager_rel.create(
{
"git_project_id": self.git_project_1.id,
"file_template_id": file_template_3.id,
"project_format": "git_aggregator",
}
)
# Add as file template manager - should succeed
file_template_4 = self.FileTemplate.create(
{
"name": "test_file_template_4",
}
)
file_template_4.write({"manager_ids": [(4, self.manager.id)]})
rel = manager_rel.create(
{
"git_project_id": self.git_project_1.id,
"file_template_id": file_template_4.id,
"project_format": "git_aggregator",
}
)
self.assertTrue(rel.exists())
# Test write access
rel.write({"project_format": "git_aggregator"})
# Remove file template manager access - should fail to write
file_template_4.write({"manager_ids": [(3, self.manager.id)]})
with self.assertRaises(AccessError):
rel.write({"project_format": "git_aggregator"})
# Remove project manager access - should fail to write
self.git_project_1.write({"manager_ids": [(3, self.manager.id)]})
file_template_4.write({"manager_ids": [(4, self.manager.id)]})
with self.assertRaises(AccessError):
rel.write({"project_format": "git_aggregator"})
def test_manager_unlink_access(self):
"""Test manager unlink access rules"""
manager_rel = self.GitProjectFileTemplateRel.with_user(self.manager)
# Try delete without being project and server manager - should fail
with self.assertRaises(AccessError):
manager_rel.browse(self.file_template_1_rel.id).unlink()
# Add as project manager only - should fail
self.git_project_1.write({"manager_ids": [(4, self.manager.id)]})
with self.assertRaises(AccessError):
manager_rel.browse(self.file_template_1_rel.id).unlink()
# Add as file template manager - should succeed
self.file_template_1.write({"manager_ids": [(4, self.manager.id)]})
self.file_template_1_rel.unlink()
self.assertFalse(self.file_template_1_rel.exists())
def test_root_access(self):
"""Test root access rules"""
root_rel = self.GitProjectFileTemplateRel.with_user(self.root)
# Create new file to avoid unique constraint violation
file_template_3 = self.FileTemplate.create(
{
"name": "test_file_template_3",
}
)
# Create - should succeed
rel = root_rel.create(
{
"git_project_id": self.git_project_1.id,
"file_template_id": file_template_3.id,
"project_format": "git_aggregator",
}
)
self.assertTrue(rel.exists())
# Read - should succeed
self.assertEqual(root_rel.browse(rel.id).name, "Git Project 1")
# Write - should succeed
root_rel.browse(rel.id).write({"project_format": "git_aggregator"})
# Delete - should succeed
rel.unlink()
self.assertFalse(rel.exists())

View File

@@ -0,0 +1,315 @@
from odoo.exceptions import AccessError
from .common import CommonTest
class TestProject(CommonTest):
"""Test class for git project."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Remove user bob from all groups
cls.remove_from_group(
cls.user_bob,
[
"cetmix_tower_server.group_user",
"cetmix_tower_server.group_manager",
"cetmix_tower_server.group_root",
],
)
# Create another manager for testing
cls.manager_2 = cls.Users.create(
{
"name": "Second Manager",
"login": "manager2",
"email": "manager2@test.com",
"groups_id": [(4, cls.env.ref("cetmix_tower_server.group_manager").id)],
}
)
# Create test project as root
cls.project = cls.GitProject.create(
{
"name": "Test Project",
}
)
def test_user_access(self):
"""Test that regular users have no access to git projects"""
user_project = self.GitProject.with_user(self.user)
# Test CRUD operations
with self.assertRaises(AccessError):
user_project.create({"name": "New Project"})
with self.assertRaises(AccessError):
user_project.browse(self.project.id).read(["name"])
with self.assertRaises(AccessError):
user_project.browse(self.project.id).write({"name": "Updated Name"})
with self.assertRaises(AccessError):
user_project.browse(self.project.id).unlink()
def test_manager_read_access(self):
"""Test manager read access rules"""
manager_project = self.GitProject.with_user(self.manager)
# Manager not in user_ids or manager_ids - should not read
with self.assertRaises(AccessError):
manager_project.browse(self.project.id).read(["name"])
# Add manager to user_ids - should read
self.project.write({"user_ids": [(4, self.manager.id)]})
self.assertEqual(manager_project.browse(self.project.id).name, "Test Project")
# Remove from user_ids, add to manager_ids - should read
self.project.write(
{"user_ids": [(3, self.manager.id)], "manager_ids": [(4, self.manager.id)]}
)
self.assertEqual(manager_project.browse(self.project.id).name, "Test Project")
def test_manager_write_access(self):
"""Test manager write/create access rules"""
manager_project = self.GitProject.with_user(self.manager)
# Create - should succeed as manager is added by default
new_project = manager_project.create({"name": "New Project"})
self.assertTrue(new_project.exists())
self.assertIn(self.manager, new_project.manager_ids)
# Write - not in manager_ids, should fail
with self.assertRaises(AccessError):
manager_project.browse(self.project.id).write({"name": "Updated Name"})
# Add to manager_ids - should write
self.project.write({"manager_ids": [(4, self.manager.id)]})
manager_project.browse(self.project.id).write({"name": "Updated Name"})
self.assertEqual(self.project.name, "Updated Name")
def test_manager_unlink_access(self):
"""Test manager unlink access rules"""
# Create project as manager_2
project = self.GitProject.with_user(self.manager_2).create(
{"name": "Project to Delete"}
)
manager_project = self.GitProject.with_user(self.manager)
# Try delete as different manager - should fail
with self.assertRaises(AccessError):
manager_project.browse(project.id).unlink()
# Add to manager_ids but not creator - should fail
project.write({"manager_ids": [(4, self.manager.id)]})
with self.assertRaises(AccessError):
manager_project.browse(project.id).unlink()
# Create as manager and try delete - should succeed
own_project = manager_project.create({"name": "Own Project"})
self.assertTrue(own_project.exists())
own_project.unlink()
self.assertFalse(own_project.exists())
def test_root_access(self):
"""Test root access rules"""
root_project = self.GitProject.with_user(self.root)
# Create
new_project = root_project.create({"name": "Root Project"})
self.assertTrue(new_project.exists())
# Read
self.assertEqual(root_project.browse(self.project.id).name, "Test Project")
# Write
root_project.browse(self.project.id).write({"name": "Updated by Root"})
self.assertEqual(self.project.name, "Updated by Root")
# Delete
new_project.unlink()
self.assertFalse(new_project.exists())
def test_compute_user_ids(self):
"""Test computation of user_ids and manager_ids for git projects"""
# Add users "Bob" and "user" to the group "cetmix_tower_server.group_manager"
self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager")
self.add_to_group(self.user, "cetmix_tower_server.group_manager")
# -- 1 --
# Create project as manager
project_as_manager = self.GitProject.with_user(self.manager).create(
{
"name": "Project As Manager",
}
)
# Check that manager is added to both user_ids and manager_ids by default
self.assertEqual(len(project_as_manager.user_ids), 1)
self.assertIn(self.manager, project_as_manager.user_ids)
self.assertEqual(len(project_as_manager.manager_ids), 1)
self.assertIn(self.manager, project_as_manager.manager_ids)
# -- 2 --
# Create servers with multiple users and managers
server_1 = self.Server.create(
{
"name": "Test Server 1",
"ip_v4_address": "localhost",
"ssh_username": "admin",
"ssh_password": "password",
"os_id": self.os_debian_10.id,
"user_ids": [(6, 0, [self.user_bob.id, self.user.id])], # Two users
"manager_ids": [
(6, 0, [self.manager.id, self.manager_2.id])
], # Two managers
}
)
server_2 = self.Server.create(
{
"name": "Test Server 2",
"ip_v4_address": "localhost",
"ssh_username": "admin",
"ssh_password": "password",
"os_id": self.os_debian_10.id,
"user_ids": [
(6, 0, [self.user_bob.id, self.user.id])
], # Same two users
"manager_ids": [
(6, 0, [self.manager.id, self.manager_2.id])
], # Same two managers
}
)
# Create project and link servers
project = self.GitProject.create(
{
"name": "Test Project",
}
)
# Create files and link them to the project
for server in [server_1, server_2]:
file = self.File.create(
{
"name": f"test_file_{server.name}",
"server_id": server.id,
}
)
self.GitProjectRel.create(
{
"server_id": server.id,
"file_id": file.id,
"git_project_id": project.id,
"project_format": "git_aggregator",
}
)
# Invalidate cache to ensure computed fields are updated
project.invalidate_recordset(["server_ids", "user_ids", "manager_ids"])
# -- 3 --
# Test computed values with linked servers
# Each user/manager should be counted only once even if present in both servers
self.assertEqual(len(project.server_ids), 2)
self.assertEqual(len(project.user_ids), 2) # Two unique users
self.assertIn(self.user_bob, project.user_ids)
self.assertIn(self.user, project.user_ids)
self.assertEqual(len(project.manager_ids), 2) # Two unique managers
self.assertIn(self.manager, project.manager_ids)
self.assertIn(self.manager_2, project.manager_ids)
# -- 4 --
# Add server with different users/managers
server_3 = self.Server.create(
{
"name": "Test Server 3",
"ip_v4_address": "localhost",
"ssh_username": "admin",
"ssh_password": "password",
"os_id": self.os_debian_10.id,
"user_ids": [(6, 0, [self.user_bob.id])], # Only one user
"manager_ids": [(6, 0, [self.manager_2.id])], # Only second manager
}
)
file_3 = self.File.create(
{
"name": "test_file_3",
"server_id": server_3.id,
}
)
self.GitProjectRel.create(
{
"server_id": server_3.id,
"file_id": file_3.id,
"git_project_id": project.id,
"project_format": "git_aggregator",
}
)
# Invalidate cache to ensure computed fields are updated
project.invalidate_recordset(["server_ids", "user_ids", "manager_ids"])
# Test that computed values are updated correctly
# Only users/managers present in all servers should remain
self.assertEqual(len(project.server_ids), 3)
self.assertEqual(len(project.user_ids), 1) # Only bob is in all servers
self.assertIn(self.user_bob, project.user_ids)
self.assertEqual(
len(project.manager_ids), 1
) # Only manager_2 is in all servers
self.assertIn(self.manager_2, project.manager_ids)
# -- 5 --
# Verify that first manager can still access the project
project_as_manager_1 = self.GitProject.with_user(self.manager).browse(
project.id
)
self.assertTrue(project_as_manager_1.exists())
self.assertEqual(project_as_manager_1.name, "Test Project")
def test_manager_server_based_access(self):
"""Test manager access through server relationships"""
manager_project = self.GitProject.with_user(self.manager)
# Create a server where manager is a user
server = self.Server.create(
{
"name": "Test Server",
"ip_v4_address": "localhost",
"ssh_username": "admin",
"ssh_password": "password",
"os_id": self.os_debian_10.id,
"user_ids": [(4, self.manager.id)],
}
)
# Create a file and link project to server
file = self.File.create(
{
"name": "test_file",
"server_id": server.id,
}
)
self.GitProjectRel.create(
{
"server_id": server.id,
"file_id": file.id,
"git_project_id": self.project.id,
"project_format": "git_aggregator",
}
)
# Manager should be able to read project through server relationship
self.assertEqual(manager_project.browse(self.project.id).name, "Test Project")
# Remove manager from server users
server.write({"user_ids": [(3, self.manager.id)]})
# Manager should not be able to read project anymore
with self.assertRaises(AccessError):
manager_project.browse(self.project.id).read(["name"])
# Add manager to server managers
server.write({"manager_ids": [(4, self.manager.id)]})
# Manager should be able to read project again
self.assertEqual(manager_project.browse(self.project.id).name, "Test Project")

View File

@@ -0,0 +1,462 @@
from odoo.exceptions import AccessError
from .common import CommonTest
class TestRemote(CommonTest):
"""Test class for git remote."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create another manager for testing
cls.manager_2 = cls.Users.create(
{
"name": "Second Manager",
"login": "manager2",
"email": "manager2@test.com",
"groups_id": [(4, cls.env.ref("cetmix_tower_server.group_manager").id)],
}
)
# Create test project and source as root
cls.project = cls.GitProject.create(
{
"name": "Test Project",
}
)
cls.source = cls.GitSource.create(
{
"name": "Test Source",
"git_project_id": cls.project.id,
}
)
cls.repo_cetmix_tower = cls.Repo.create(
{
"name": "Cetmix Tower",
"url": "https://github.com/cetmix-test/cetmix-tower.git",
}
)
cls.remote = cls.GitRemote.create(
{
"repo_id": cls.repo_cetmix_tower.id,
"source_id": cls.source.id,
"head_type": "branch",
"head": "main",
}
)
cls.repo_test = cls.Repo.create(
{
"name": "Test Repository",
"url": "https://github.com/cetmix-test/test.git",
}
)
def test_user_access(self):
"""Test that regular users have no access to git remotes"""
user_remote = self.GitRemote.with_user(self.user)
# Test CRUD operations
with self.assertRaises(AccessError):
user_remote.create(
{
"repo_id": self.repo_test.id,
"url_protocol": "https",
"source_id": self.source.id,
"head": "main",
}
)
with self.assertRaises(AccessError):
user_remote.search([("id", "=", self.remote.id)])
with self.assertRaises(AccessError):
self.remote.with_user(self.user).write({"head": "dev"})
with self.assertRaises(AccessError):
self.remote.with_user(self.user).unlink()
def test_manager_read_access(self):
"""Test manager read access rules"""
manager_remote = self.GitRemote.with_user(self.manager)
# Manager not in project user_ids or manager_ids - should not read
self.assertFalse(manager_remote.search([("id", "=", self.remote.id)]))
# Add manager to project user_ids - should read
self.project.write({"user_ids": [(4, self.manager.id)]})
remote = manager_remote.search([("id", "=", self.remote.id)])
self.assertTrue(remote)
self.assertEqual(remote.head, "main")
# Remove from user_ids, add to manager_ids - should read
self.project.write(
{"user_ids": [(3, self.manager.id)], "manager_ids": [(4, self.manager.id)]}
)
remote = manager_remote.search([("id", "=", self.remote.id)])
self.assertTrue(remote.exists())
def test_manager_write_access(self):
"""Test manager write/create access rules"""
manager_remote = self.GitRemote.with_user(self.manager)
# Create project as manager - should be added to manager_ids automatically
project = self.GitProject.with_user(self.manager).create(
{
"name": "Manager Project",
}
)
source = self.GitSource.create(
{
"name": "Manager Source",
"git_project_id": project.id,
}
)
# Create remote in own project - should succeed
new_remote = manager_remote.create(
{
"repo_id": self.repo_test.id,
"url_protocol": "https",
"source_id": source.id,
"head_type": "branch",
"head": "main",
}
)
self.assertTrue(new_remote.exists())
# Write to own remote - should succeed
new_remote.write({"head": "dev"})
self.assertEqual(new_remote.head, "dev")
# Write to other's remote - should fail
with self.assertRaises(AccessError):
self.remote.with_user(self.manager).write({"head": "dev"})
def test_manager_unlink_access(self):
"""Test manager unlink access rules"""
# Create project and remote as manager_2
project = self.GitProject.with_user(self.manager_2).create(
{
"name": "Manager 2 Project",
}
)
source = self.GitSource.create(
{
"name": "Manager 2 Source",
"git_project_id": project.id,
}
)
remote = self.GitRemote.with_user(self.manager_2).create(
{
"repo_id": self.repo_test.id,
"url_protocol": "https",
"source_id": source.id,
"head_type": "branch",
"head": "main",
}
)
# Try delete as different manager - should fail even if added to manager_ids
project.write({"manager_ids": [(4, self.manager.id)]})
with self.assertRaises(AccessError):
remote.with_user(self.manager).unlink()
# Create remote as manager and try delete - should succeed
own_remote = self.GitRemote.with_user(self.manager).create(
{
"repo_id": self.repo_test.id,
"url_protocol": "https",
"source_id": source.id,
"head_type": "branch",
"head": "main",
}
)
self.assertTrue(own_remote.exists())
own_remote.with_user(self.manager).unlink()
self.assertFalse(own_remote.exists())
def test_root_access(self):
"""Test root access rules"""
root_remote = self.GitRemote.with_user(self.root)
# Create
new_remote = root_remote.create(
{
"repo_id": self.repo_test.id,
"url_protocol": "https",
"source_id": self.source.id,
"head_type": "branch",
"head": "main",
}
)
self.assertTrue(new_remote.exists())
# Read
remote = root_remote.search([("id", "=", self.remote.id)])
self.assertTrue(remote)
self.assertEqual(remote.head, "main")
# Write
self.remote.with_user(self.root).write({"head": "dev"})
self.assertEqual(self.remote.head, "dev")
# Delete
new_remote.with_user(self.root).unlink()
self.assertFalse(new_remote.exists())
def test_remote_provider_protocol_and_name(self):
"""Test if remote provider is detected correctly"""
# -- 1--
# GitHub + https
# Check if remote provider is detected correctly
self.assertEqual(
self.remote_github_https.repo_provider,
"github",
"Provider is not detected correctly",
)
self.assertEqual(
self.remote_github_https.url_protocol,
"https",
"Protocol is not detected correctly",
)
self.assertEqual(
self.remote_github_https.name,
"remote_1",
"Name is not prepared correctly",
)
# -- 2 --
# GitLab + ssh
# Check if remote provider is detected correctly
self.assertEqual(
self.remote_gitlab_ssh.repo_provider,
"gitlab",
"Provider is not detected correctly",
)
self.assertEqual(
self.remote_gitlab_ssh.url_protocol,
"ssh",
"Protocol is not detected correctly",
)
self.assertEqual(
self.remote_gitlab_ssh.name,
"remote_3",
"Name is not prepared correctly",
)
# -- 3 --
# Bitbucket + https
# Check if remote provider is detected correctly
self.assertEqual(
self.remote_bitbucket_https.repo_provider,
"bitbucket",
"Provider is not detected correctly",
)
self.assertEqual(
self.remote_bitbucket_https.url_protocol,
"https",
"Protocol is not detected correctly",
)
self.assertEqual(
self.remote_bitbucket_https.name,
"remote_1",
"Name is not prepared correctly",
)
# -- 4 --
# Other + ssh
# Check if remote provider is detected correctly
self.assertEqual(
self.remote_other_ssh.repo_provider,
"gitlab", # this is how giturlparse detects the provider
"Provider is not detected correctly",
)
self.assertEqual(
self.remote_other_ssh.url_protocol,
"ssh",
"Protocol is not detected correctly",
)
self.assertEqual(
self.remote_other_ssh.name,
"remote_2",
"Name is not prepared correctly",
)
def test_git_aggregator_prepare_url(self):
"""Test if url is prepared correctly"""
# -- 1 --
# GitHub + https
self.remote_github_https.repo_id.is_private = False
self.assertEqual(
self.remote_github_https._git_aggregator_prepare_url(),
self.remote_github_https.repo_id.url,
"URL is not prepared correctly",
)
# -- 2 --
# GitHub + https -> private
self.remote_github_https.repo_id.is_private = True
self.assertEqual(
self.remote_github_https._git_aggregator_prepare_url(),
"https://$GITHUB_TOKEN:x-oauth-basic@github.com/cetmix-test/cetmix-tower-test.git",
"URL is not prepared correctly",
)
# -- 3 --
# Gitlab + https
self.remote_gitlab_https.repo_id.is_private = False
self.assertEqual(
self.remote_gitlab_https._git_aggregator_prepare_url(),
self.remote_gitlab_https.repo_id.url,
"URL is not prepared correctly",
)
# -- 4 --
# Gitlab + https -> private
self.remote_gitlab_https.repo_id.is_private = True
self.assertEqual(
self.remote_gitlab_https._git_aggregator_prepare_url(),
"https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git",
"URL is not prepared correctly",
)
# -- 5 --
# Bitbucket + https
self.remote_bitbucket_https.repo_id.is_private = False
self.assertEqual(
self.remote_bitbucket_https._git_aggregator_prepare_url(),
self.remote_bitbucket_https.repo_id.url,
"URL is not prepared correctly",
)
# -- 6 --
# Bitbucket + https -> private
self.remote_bitbucket_https.repo_id.is_private = True
self.assertEqual(
self.remote_bitbucket_https._git_aggregator_prepare_url(),
"https://x-token-auth:$BITBUCKET_TOKEN@bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git",
"URL is not prepared correctly",
)
# -- 7 --
# Other + ssh
self.remote_other_ssh.repo_id.is_private = False
self.assertEqual(
self.remote_other_ssh._git_aggregator_prepare_url(),
self.remote_other_ssh.repo_id.url_ssh,
"URL is not prepared correctly",
)
def test_git_aggregator_prepare_head(self):
"""Test if head is prepared correctly"""
# -- 1 --
# GitHub + PR/MR as link
self.assertEqual(
self.remote_github_https._git_aggregator_prepare_head(),
"refs/pull/123/head",
"Head is not prepared correctly",
)
# -- 2 --
# GitHub + PR/MR as number
self.remote_github_https.write({"head": "123", "head_type": "pr"})
self.assertEqual(
self.remote_github_https._git_aggregator_prepare_head(),
"refs/pull/123/head",
"Head is not prepared correctly",
)
# -- 3 --
# GitHub + branch as name
self.remote_github_https.write({"head": "main", "head_type": "branch"})
self.assertEqual(
self.remote_github_https._git_aggregator_prepare_head(),
self.remote_github_https.head,
"Head is not prepared correctly",
)
# -- 4 --
# GitHub + branch as link
self.remote_github_https.write(
{
"head": "https://github.com/cetmix-test/cetmix-tower/tree/14.0-demo-branch",
"head_type": "branch",
}
)
self.assertEqual(
self.remote_github_https._git_aggregator_prepare_head(),
"14.0-demo-branch",
"Head is not prepared correctly",
)
# -- 5 --
# GitHub + commit as number
self.remote_github_https.write({"head": "1234567890", "head_type": "commit"})
self.assertEqual(
self.remote_github_https._git_aggregator_prepare_head(),
"1234567890",
"Head is not prepared correctly",
)
# -- 6 --
# GitHub + commit as link
self.remote_github_https.head = (
"https://github.com/cetmix-test/cetmix-tower/commit/1234567890"
)
self.assertEqual(
self.remote_github_https._git_aggregator_prepare_head(),
"1234567890",
"Head is not prepared correctly",
)
def test_manager_server_based_access(self):
"""Test manager access to remotes through server relationships"""
manager_remote = self.GitRemote.with_user(self.manager)
# Create a server where manager is a user
server = self.Server.create(
{
"name": "Test Server",
"ip_v4_address": "localhost",
"ssh_username": "admin",
"ssh_password": "password",
"os_id": self.os_debian_10.id,
"user_ids": [(4, self.manager.id)],
}
)
# Link project to server
file = self.File.create(
{
"name": "test_file",
"server_id": server.id,
}
)
self.GitProjectRel.create(
{
"server_id": server.id,
"file_id": file.id,
"git_project_id": self.project.id,
"project_format": "git_aggregator",
}
)
# Manager should be able to read remote through server relationship
remote = manager_remote.search([("id", "=", self.remote.id)])
self.assertTrue(remote)
self.assertEqual(remote.head, "main")
# Remove manager from server users
server.write({"user_ids": [(3, self.manager.id)]})
# Manager should not be able to read remote anymore
self.assertFalse(manager_remote.search([("id", "=", self.remote.id)]))
# Add manager to server managers
server.write({"manager_ids": [(4, self.manager.id)]})
# Manager should be able to read remote again
remote = manager_remote.search([("id", "=", self.remote.id)])
self.assertTrue(remote)
self.assertEqual(remote.head, "main")

View File

@@ -0,0 +1,84 @@
from odoo.exceptions import ValidationError
from .common import CommonTest
class TestRepo(CommonTest):
"""Test class for git repository."""
@classmethod
def setUpClass(cls):
super().setUpClass()
def test_repo_create_from_url_https_success(self):
"""Test if repository is created correctly"""
# -- 1 --
# Valid HTTPS URL
repo = self.Repo.create(
{
"url": "https://github.com/memes-demo/doge-memes.git",
}
)
repo.invalidate_recordset()
self.assertEqual(repo.name, "github.com/memes-demo/doge-memes")
self.assertEqual(repo.host, "github.com")
self.assertEqual(repo.owner_id.name, "memes-demo")
self.assertEqual(repo.provider, "github")
self.assertEqual(repo.is_private, False)
self.assertEqual(repo.url_ssh, "git@github.com:memes-demo/doge-memes.git")
self.assertEqual(repo.url_git, "git://github.com/memes-demo/doge-memes.git")
def test_repo_create_from_url_ssh_success(self):
"""Test if repository is created correctly"""
# -- 1 --
# Valid SSH URL
repo = self.Repo.create(
{
"url": "git@gitlab.com:chad-guy/chad-guy.git",
}
)
repo.invalidate_recordset()
self.assertEqual(repo.name, "gitlab.com/chad-guy/chad-guy")
self.assertEqual(repo.host, "gitlab.com")
self.assertEqual(repo.owner_id.name, "chad-guy")
self.assertEqual(repo.provider, "gitlab")
self.assertEqual(repo.is_private, False)
self.assertEqual(repo.url, "https://gitlab.com/chad-guy/chad-guy.git")
self.assertEqual(repo.url_git, "git://gitlab.com/chad-guy/chad-guy.git")
def test_repo_create_from_url_git_success(self):
"""Test if repository is created correctly"""
# -- 1 --
# Valid GIT URL
repo = self.Repo.create(
{
"url": "git://bitbucket.com/much-pepe/pepe-memes.git",
}
)
self.assertEqual(repo.name, "bitbucket.com/much-pepe/pepe-memes")
self.assertEqual(repo.host, "bitbucket.com")
self.assertEqual(repo.owner_id.name, "much-pepe")
self.assertEqual(repo.provider, "bitbucket")
self.assertEqual(repo.is_private, False)
self.assertEqual(repo.url_ssh, "git@bitbucket.com:much-pepe/pepe-memes.git")
self.assertEqual(repo.url, "https://bitbucket.com/much-pepe/pepe-memes.git")
def test_repo_create_from_url_fails(self):
"""Test if repository creation fails with invalid URLs"""
# Invalid URL 1
with self.assertRaises(ValidationError):
self.Repo.create(
{
"url": "something.com",
}
)
# Invalid URL 2
with self.assertRaises(ValidationError):
self.Repo.create(
{
"url": "random string",
}
)

View File

@@ -0,0 +1,198 @@
try:
from odoo.addons.queue_job.tests.common import trap_jobs
except ImportError:
trap_jobs = None
from .common import CommonTest
class TestServer(CommonTest):
"""Test setting git project to server from plan line."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.GitProjectRel.create(
{
"git_project_id": cls.git_project_1.id,
"server_id": cls.server_test_1.id,
"file_id": cls.server_1_file_1.id,
}
)
def test_server_creation_running_flight_plan(self):
"""Test that server is created with git project from plan line."""
git_project = self.GitProject.create(
{
"name": "Test Git Project",
"manager_ids": [(4, self.manager.id)],
}
)
file_template = self.FileTemplate.create(
{
"name": "Git Config Template",
"file_name": "repos.yaml",
"server_dir": "/var/test",
"code": "repositories:\n test_repo:\n "
"url: https://github.com/test/repo.git\n target: main",
}
)
command = self.Command.create(
{
"name": "Create Git Config File",
"action": "file_using_template",
"file_template_id": file_template.id,
}
)
flight_plan = self.Plan.create(
{
"name": "Git Project Setup Plan",
"note": "Sets up a git project on the server",
}
)
self.plan_line.create(
{
"plan_id": flight_plan.id,
"command_id": command.id,
"sequence": 10,
"git_project_id": git_project.id,
}
)
server_template = self.ServerTemplate.create(
{
"name": "Git Server Template",
"ssh_port": 22,
"ssh_username": "admin",
"ssh_password": "password",
"ssh_auth_mode": "p",
"os_id": self.os_debian_10.id,
"flight_plan_id": flight_plan.id,
"manager_ids": [(4, self.manager.id)],
}
)
action = server_template.action_create_server()
# Open the wizard and fill in the data
wizard = (
self.env["cx.tower.server.template.create.wizard"]
.with_context(**action["context"])
.create(
{
"name": "Git Server",
"ip_v4_address": "192.168.1.10",
"server_template_id": server_template.id,
}
)
)
# If cetmix_tower_server_queue module is installed, test async processing
if self.env["ir.module.module"].search_count(
[("name", "=", "cetmix_tower_server_queue"), ("state", "=", "installed")]
):
with trap_jobs() as trap:
wizard.action_confirm()
# Verify that jobs were created
self.assertGreater(
len(trap.enqueued_jobs), 0, "Jobs should have been enqueued"
)
# Execute all trapped jobs to simulate async processing
trap.perform_enqueued_jobs()
else:
wizard.action_confirm()
# Now search for the created records after jobs have been executed
server = self.Server.search(
[
("name", "=", "Git Server"),
("server_template_id", "=", server_template.id),
]
)
self.assertEqual(len(server), 1, "Exactly one server should have been created")
# Verify the file was created
file = self.File.search(
[("server_id", "=", server.id), ("name", "=", "repos.yaml")]
)
self.assertEqual(
len(file), 1, "Exactly one git config file should have been created"
)
# Verify the git project relation exists
git_project_rel = self.GitProjectRel.search(
[
("server_id", "=", server.id),
("git_project_id", "=", git_project.id),
("file_id", "=", file.id),
]
)
self.assertEqual(
len(git_project_rel), 1, "Exactly one git project relation should exist"
)
self.assertEqual(
git_project_rel.file_id,
file,
"The related file should be the git config file",
)
self.assertEqual(
git_project_rel.git_project_id,
git_project,
"The related git project should match the one in the flight plan",
)
self.assertEqual(
git_project_rel.project_format,
git_project._default_project_format(),
"Project format should match the default format",
)
def test_server_get_servers_by_git_ref_success(self):
"""Check the success case of server.get_servers_by_git_ref"""
# 1. URL only
servers = self.Server.get_servers_by_git_ref(
self.remote_github_https.repo_id.url
)
self.assertEqual(servers, self.server_test_1)
# 2. Specific URL with specific head
servers = self.Server.get_servers_by_git_ref(
self.remote_github_https.repo_id.url, "123"
)
self.assertEqual(servers, self.server_test_1)
# 2. Specific URL with specific head and head type
servers = self.Server.get_servers_by_git_ref(
self.remote_github_https.repo_id.url, "123", "pr"
)
self.assertEqual(servers, self.server_test_1)
def test_server_get_servers_by_git_ref_no_match(self):
"""Check the no match case of server.get_servers_by_git_ref"""
# 1. Repo link does not exist
servers = self.Server.get_servers_by_git_ref(
"https://github.com/other-org/other-repo.git", "main", "branch"
)
self.assertFalse(servers)
# 2. Repo link exists, but remote does not exist
servers = self.Server.get_servers_by_git_ref(
self.repo_cetmix_tower.url, "3311", "pr"
)
self.assertFalse(servers)
# 3. Repo link exists, but remote type does not exist
servers = self.Server.get_servers_by_git_ref(
self.repo_cetmix_tower.url, "main", "commit"
)
self.assertFalse(servers)

View File

@@ -0,0 +1,226 @@
from odoo.exceptions import AccessError
from .common import CommonTest
class TestSource(CommonTest):
"""Test class for git source."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create another manager for testing
cls.manager_2 = cls.Users.create(
{
"name": "Second Manager",
"login": "manager2",
"email": "manager2@test.com",
"groups_id": [(4, cls.env.ref("cetmix_tower_server.group_manager").id)],
}
)
# Create test project and source as root
cls.project = cls.GitProject.create(
{
"name": "Test Project",
}
)
cls.source = cls.GitSource.create(
{
"name": "Test Source",
"git_project_id": cls.project.id,
}
)
def test_user_access(self):
"""Test that regular users have no access to git sources"""
user_source = self.GitSource.with_user(self.user)
# Test CRUD operations
with self.assertRaises(AccessError):
user_source.create(
{
"name": "New Source",
"git_project_id": self.project.id,
}
)
with self.assertRaises(AccessError):
user_source.browse(self.source.id).read(["name"])
with self.assertRaises(AccessError):
user_source.browse(self.source.id).write({"name": "Updated Name"})
with self.assertRaises(AccessError):
user_source.browse(self.source.id).unlink()
def test_manager_read_access(self):
"""Test manager read access rules"""
manager_source = self.GitSource.with_user(self.manager)
# Manager not in project user_ids or manager_ids - should not read
with self.assertRaises(AccessError):
manager_source.browse(self.source.id).read(["name"])
# Add manager to project user_ids - should read
self.project.write({"user_ids": [(4, self.manager.id)]})
self.assertEqual(manager_source.browse(self.source.id).name, "Test Source")
# Remove from user_ids, add to manager_ids - should read
self.project.write(
{"user_ids": [(3, self.manager.id)], "manager_ids": [(4, self.manager.id)]}
)
self.assertEqual(manager_source.browse(self.source.id).name, "Test Source")
def test_manager_write_access(self):
"""Test manager write/create access rules"""
manager_source = self.GitSource.with_user(self.manager)
# Create project as manager - should be added to manager_ids automatically
project = self.GitProject.with_user(self.manager).create(
{
"name": "Manager Project",
}
)
self.assertIn(self.manager, project.manager_ids)
# Create source in own project - should succeed
new_source = manager_source.create(
{
"name": "New Source",
"git_project_id": project.id,
}
)
self.assertTrue(new_source.exists())
# Write to own source - should succeed
new_source.write({"name": "Updated Name"})
self.assertEqual(new_source.name, "Updated Name")
# Write to other's source - should fail
with self.assertRaises(AccessError):
manager_source.browse(self.source.id).write({"name": "Updated Name"})
def test_manager_unlink_access(self):
"""Test manager unlink access rules"""
# Create project and source as manager_2
project = self.GitProject.with_user(self.manager_2).create(
{
"name": "Manager 2 Project",
}
)
source = self.GitSource.with_user(self.manager_2).create(
{
"name": "Source to Delete",
"git_project_id": project.id,
}
)
manager_source = self.GitSource.with_user(self.manager)
# Try delete as different manager - should fail even if added to manager_ids
project.write({"manager_ids": [(4, self.manager.id)]})
with self.assertRaises(AccessError):
manager_source.browse(source.id).unlink()
# Create source as manager and try delete - should succeed
own_source = manager_source.create(
{
"name": "Own Source",
"git_project_id": project.id,
}
)
self.assertTrue(own_source.exists())
own_source.unlink()
self.assertFalse(own_source.exists())
def test_root_access(self):
"""Test root access rules"""
root_source = self.GitSource.with_user(self.root)
# Create
new_source = root_source.create(
{
"name": "Root Source",
"git_project_id": self.project.id,
}
)
self.assertTrue(new_source.exists())
# Read
self.assertEqual(root_source.browse(self.source.id).name, "Test Source")
# Write
root_source.browse(self.source.id).write({"name": "Updated by Root"})
self.assertEqual(self.source.name, "Updated by Root")
# Delete
new_source.unlink()
self.assertFalse(new_source.exists())
def test_source_git_aggregator_prepare_record(self):
"""Test if source prepare record method works correctly."""
# -- 1 --
# Source 1
expected_result = {
"remotes": {
"remote_1": "https://github.com/cetmix-test/cetmix-tower-test.git",
"remote_2": "https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git",
"remote_3": "git@my.gitlab.com:cetmix-test/cetmix-tower-test.git",
},
"merges": [
{"remote": "remote_1", "ref": "refs/pull/123/head"},
{"remote": "remote_2", "ref": "main"},
{"remote": "remote_3", "ref": "10000000"},
],
"target": "remote_1",
}
prepared_result = self.git_source_1._git_aggregator_prepare_record()
self.assertEqual(
prepared_result, expected_result, "Prepared result is not correct"
)
def test_manager_server_based_access(self):
"""Test manager access to sources through server relationships"""
manager_source = self.GitSource.with_user(self.manager)
# Create a server where manager is a user
server = self.Server.create(
{
"name": "Test Server",
"ip_v4_address": "localhost",
"ssh_username": "admin",
"ssh_password": "password",
"os_id": self.os_debian_10.id,
"user_ids": [(4, self.manager.id)],
}
)
# Link project to server
file = self.File.create(
{
"name": "test_file",
"server_id": server.id,
}
)
self.GitProjectRel.create(
{
"server_id": server.id,
"file_id": file.id,
"git_project_id": self.project.id,
"project_format": "git_aggregator",
}
)
# Manager should be able to read source through server relationship
self.assertEqual(manager_source.browse(self.source.id).name, "Test Source")
# Remove manager from server users
server.write({"user_ids": [(3, self.manager.id)]})
# Manager should not be able to read source anymore
with self.assertRaises(AccessError):
manager_source.browse(self.source.id).read(["name"])
# Add manager to server managers
server.write({"manager_ids": [(4, self.manager.id)]})
# Manager should be able to read source again
self.assertEqual(manager_source.browse(self.source.id).name, "Test Source")

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_file_template_view_form" model="ir.ui.view">
<field name="name">cx.tower.file.template.view.form</field>
<field name="model">cx.tower.file.template</field>
<field
name="inherit_id"
ref="cetmix_tower_server.cx_tower_file_template_view_form"
/>
<field name="arch" type="xml">
<field name="source" position="after">
<field
name="git_project_id"
attrs="{'invisible': [('git_project_id', '=', False)]}"
/>
</field>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,18 @@
<?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">
<field name="auto_sync" position="before">
<field
name="git_project_id"
attrs="{'invisible': [('git_project_id', '=', False)]}"
/>
</field>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,236 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Tree View -->
<record id="cx_tower_git_project_view_tree" model="ir.ui.view">
<field name="name">cx.tower.git.project.tree</field>
<field name="model">cx.tower.git.project</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="server_ids" widget="many2many_tags" />
<field name="active" widget="boolean_toggle" />
</tree>
</field>
</record>
<!-- Form View -->
<record id="cx_tower_git_project_view_form" model="ir.ui.view">
<field name="name">cx.tower.git.project.form</field>
<field name="model">cx.tower.git.project</field>
<field name="arch" type="xml">
<form>
<sheet>
<widget
name="web_ribbon"
title="Archived"
bg_color="bg-danger"
attrs="{'invisible': [('active', '=', True)]}"
/>
<div class="oe_title">
<h1>
<field name="name" placeholder="Name" />
</h1>
<h3>
<field
name="reference"
placeholder="Reference. Can contain English letters, digits and '_'. Leave blank to autogenerate"
/>
</h3>
</div>
<group>
<group string="General">
<field name="active" invisible="1" />
<field
name="server_ids"
widget="many2many_tags"
attrs="{'invisible': [('server_ids', '=', [])]}"
/>
<field
name="file_template_ids"
widget="many2many_tags"
attrs="{'invisible': [('file_template_ids', '=', [])]}"
/>
</group>
<group string="Git Aggregator">
<field
name="git_aggregator_root_dir"
string="Root Directory"
placeholder="Git aggregator root directory where sources will be cloned. Leave blank to use '.'"
/>
</group>
<field name="note" placeholder="Put your notes here..." />
</group>
<notebook>
<page name="sources" string="Sources">
<field name="source_ids">
<tree
decoration-muted="not enabled"
decoration-info="remote_count_private == remote_count and remote_count &gt; 0"
decoration-warning="remote_count_private != remote_count and remote_count &gt; 0 and remote_count_private &gt; 0"
>
<field name="sequence" widget="handle" />
<field
name="name"
placeholder="..to be autogenerated"
/>
<field name="remote_count" optional="show" />
<field
name="remote_count_private"
optional="hide"
/>
<field name="enabled" widget="boolean_toggle" />
<field name="reference" optional="hide" />
<field name="create_uid" optional="hide" />
</tree>
</field>
<field name="has_private_remotes" invisible="1" />
<field name="has_partially_private_remotes" invisible="1" />
<div
class="text-info"
attrs="{'invisible': [('has_private_remotes', '=', False)]}"
>
<p>
* Sources where all remotes are private
</p>
</div>
<div
class="text-warning"
attrs="{'invisible': [('has_partially_private_remotes', '=', False)]}"
>
<p>
* Sources where some remotes are private
</p>
</div>
</page>
<page name="files" string="Files">
<field name="git_project_rel_ids">
<tree editable="bottom">
<field
name="server_id"
options="{'no_create': True, 'no_create_edit': True}"
/>
<field
name="file_id"
options="{'no_create': True, 'no_create_edit': True}"
/>
<field name="project_format" />
<field name="auto_sync" />
<button
type="object"
name="action_open_server"
string="Open Server"
title="Open Server"
class="btn-secondary"
/>
</tree>
<form>
<group>
<field name="server_id" />
<field name="file_id" />
<field name="project_format" />
<field name="auto_sync" />
</group>
</form>
</field>
</page>
<page name="file_templates" string="File Templates">
<field name="git_project_file_template_rel_ids">
<tree editable="bottom">
<field
name="file_template_id"
options="{'no_create': True, 'no_create_edit': True}"
/>
<field name="project_format" />
<button
type="object"
string="Open file template"
name="action_open_file_template"
title="Open File Template"
class="btn-secondary"
/>
</tree>
<form>
<group>
<field name="file_template_id" />
<field name="project_format" />
</group>
</form>
</field>
</page>
<page
name="repos"
string="Repos"
groups="base.group_no_one"
attrs="{'invisible': [('repo_ids', '=', [])]}"
>
<field name="repo_ids" />
</page>
<page
name="access"
string="Access"
groups="cetmix_tower_server.group_manager"
>
<group name="access">
<field
name="user_ids"
widget="many2many_tags"
placeholder="users who can view this record"
options="{'no_create': True}"
/>
<field
name="manager_ids"
widget="many2many_tags"
placeholder="managers who can modify this record"
options="{'no_create': True}"
/>
</group>
<div
class="alert alert-warning"
role="alert"
style="margin-bottom:0px;"
>
<ul>
<li>
<b
>Users.</b> All users who have "Manager" group and are either set in "Users" or in "Managers" in <b
><u>all</u></b> related servers.
</li>
<li>
<b
>Managers.</b> All users who have "Manager" group and are set as "Managers" in <b
><u>all</u></b> related servers.
This is done to avoid unpredictable consequences when some of the servers are not updated due to access restrictions when a project is updated.
</li>
</ul>
You can edit these fields at your own risk. However keep in mind that they will be automatically updated each time related servers are added, removed or updated.
</div>
</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>
<!-- Action -->
<record id="cx_tower_git_project_action" model="ir.actions.act_window">
<field name="name">Git Projects</field>
<field name="res_model">cx.tower.git.project</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Tree View -->
<record id="cx_tower_git_remote_view_tree" model="ir.ui.view">
<field name="name">cx.tower.git.remote.tree</field>
<field name="model">cx.tower.git.remote</field>
<field name="arch" type="xml">
<tree decoration-info="is_private == True">
<field
name="repo_id"
placeholder="select or enter a link"
options="{'no_create_edit': True}"
/>
<field name="is_private" optional="hide" />
<field name="head_type" />
<field name="head" />
<field name="enabled" widget="boolean_toggle" />
<field name="create_uid" optional="hide" />
<field name="reference" optional="hide" />
</tree>
</field>
</record>
<!-- Form View -->
<record id="cx_tower_git_remote_view_form" model="ir.ui.view">
<field name="name">cx.tower.git.remote.form</field>
<field name="model">cx.tower.git.remote</field>
<field name="arch" type="xml">
<form>
<sheet>
<widget
name="web_ribbon"
title="Disabled"
bg_color="bg-danger"
attrs="{'invisible': [('enabled', '=', True)]}"
/>
<group>
<field name="sequence" />
<field name="enabled" />
<field name="active" invisible="1" />
<field
name="repo_id"
placeholder="select or enter a link"
options="{'no_create_edit': True}"
/>
<field name="is_private" />
<field
name="url_protocol"
widget="radio"
options="{'horizontal': true}"
/>
<field
name="head_type"
widget="radio"
options="{'horizontal': true}"
/>
<field
name="head"
placeholder="Branch/PR/commit number or link"
/>
<field name="create_uid" />
</group>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_cx_tower_git_remote" model="ir.actions.act_window">
<field name="name">Git Remotes</field>
<field name="res_model">cx.tower.git.remote</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first git remote!
</p>
<p>
Git remotes represent branches, pull requests, or commits from git repositories.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Tree View -->
<record id="cx_tower_git_repo_owner_view_tree" model="ir.ui.view">
<field name="name">cx.tower.git.repo.owner.tree</field>
<field name="model">cx.tower.git.repo.owner</field>
<field name="arch" type="xml">
<tree>
<field name="display_name" />
<field name="name" />
</tree>
</field>
</record>
<!-- Form View -->
<record id="cx_tower_git_repo_owner_view_form" model="ir.ui.view">
<field name="name">cx.tower.git.repo.owner.form</field>
<field name="model">cx.tower.git.repo.owner</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="display_name" placeholder="e.g., Cetmix, OCA" />
<field name="name" placeholder="e.g., cetmix, oca" />
<field
name="reference"
placeholder="Can contain English letters, digits and '_'. Leave blank to autogenerate"
/>
<field name="secret_id" />
</group>
<notebook>
<page string="Repositories" name="repositories">
<field name="repo_ids" readonly="1">
<tree>
<field name="name" />
<field name="reference" optional="hide" />
<field name="provider" />
<field name="is_private" />
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_cx_tower_git_repo_owner" model="ir.actions.act_window">
<field name="name">Repository Owners</field>
<field name="res_model">cx.tower.git.repo.owner</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first repository owner!
</p>
<p>
Repository owners represent organizations or users that own git repositories.
Examples include "cetmix", "OCA", etc.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Tree View -->
<record id="cx_tower_git_repo_view_tree" model="ir.ui.view">
<field name="name">cx.tower.git.repo.tree</field>
<field name="model">cx.tower.git.repo</field>
<field name="arch" type="xml">
<tree decoration-info="is_private == True">
<field name="name" />
<field name="reference" optional="hide" />
<field name="provider" optional="show" />
<field name="owner_id" optional="hide" />
<field name="is_private" optional="hide" />
<field name="remote_count" optional="hide" />
<field name="git_project_count" optional="hide" />
</tree>
</field>
</record>
<!-- Form View -->
<record id="cx_tower_git_repo_view_form" model="ir.ui.view">
<field name="name">cx.tower.git.repo.form</field>
<field name="model">cx.tower.git.repo</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button
name="action_view_remotes"
type="object"
string="Remotes"
class="oe_stat_button"
icon="fa-external-link"
attrs="{'invisible': [('remote_count', '=', 0)]}"
>
<field
name="remote_count"
widget="statinfo"
string="Remotes"
/>
</button>
<button
name="action_view_projects"
type="object"
string="GitProjects"
class="oe_stat_button"
icon="fa-folder"
attrs="{'invisible': [('git_project_count', '=', 0)]}"
>
<field
name="git_project_count"
widget="statinfo"
string="Projects"
/>
</button>
</div>
<group>
<group name="info">
<field
name="url"
placeholder="https, ssh or git formats are accepted"
/>
<field name="url_ssh" />
<field name="url_git" />
<field name="repo" placeholder="e.g., cetmix-tower, odoo" />
<field name="host" />
</group>
<group name="details">
<field
name="reference"
placeholder="Can contain English letters, digits and '_'. Leave blank to autogenerate"
/>
<field name="active" />
<field name="owner_id" />
<field name="provider" />
<field name="is_private" />
<field name="secret_id" />
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="cx_tower_git_repo_view_search" model="ir.ui.view">
<field name="name">cx.tower.git.repo.search</field>
<field name="model">cx.tower.git.repo</field>
<field name="arch" type="xml">
<search>
<field
name="repo"
string="Name/Reference"
filter_domain="['|', ('repo', 'ilike', self), ('reference', 'ilike', self)]"
/>
<field name="owner_id" string="Org" />
<field name="provider" />
<filter
string="Public"
name="public"
domain="[('is_private', '=', False)]"
/>
<filter
string="Private"
name="private"
domain="[('is_private', '=', True)]"
/>
<separator />
<filter
string="Provider: Other"
name="no_provider"
domain="[('provider', '=', 'other')]"
/>
<group expand="0" string="Group By">
<filter
string="Provider"
name="group_provider"
context="{'group_by': 'provider'}"
/>
<filter
string="Org"
name="group_owner"
context="{'group_by': 'owner_id'}"
/>
</group>
<searchpanel>
<field
name="provider"
string="Provider"
icon="fa-globe"
enable_counters="1"
/>
<field
name="owner_id"
string="Org"
icon="fa-building"
enable_counters="1"
/>
</searchpanel>
</search>
</field>
</record>
<!-- Action -->
<record id="action_cx_tower_git_repo" model="ir.actions.act_window">
<field name="name">Repositories</field>
<field name="res_model">cx.tower.git.repo</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first repository!
</p>
<p>
Repositories represent git repositories with their metadata and configuration.
They can be linked to remotes to automatically populate URL information.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Tree View -->
<record id="cx_tower_git_source_view_tree" model="ir.ui.view">
<field name="name">cx.tower.git.source.tree</field>
<field name="model">cx.tower.git.source</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="git_project_id" />
</tree>
</field>
</record>
<!-- Form View -->
<record id="cx_tower_git_source_view_form" model="ir.ui.view">
<field name="name">cx.tower.git.source.form</field>
<field name="model">cx.tower.git.source</field>
<field name="arch" type="xml">
<form>
<sheet>
<widget
name="web_ribbon"
title="Disabled"
bg_color="bg-danger"
attrs="{'invisible': [('enabled', '=', True)]}"
/>
<div class="oe_title">
<h1>
<field
name="name"
placeholder="Name. Leave blank to autogenerate"
/>
</h1>
<h3>
<field
name="reference"
placeholder="Reference. Can contain English letters, digits and '_'. Leave blank to autogenerate"
attrs="{'invisible': [('reference', '=', False)]}"
/>
</h3>
</div>
<group>
<field name="sequence" />
<field name="enabled" />
<field name="active" invisible="1" />
</group>
<notebook>
<page name="remotes" string="Remotes">
<div
class="alert alert-warning"
role="alert"
style="margin-bottom:0px;"
attrs="{'invisible': [('remote_count', '&lt;', 2)]}"
>
<p>
The top one remote will be used as a merge target.
You can re-arrange remotes by dragging them or changing their sequence value.
</p>
</div>
<field name="remote_count" invisible="1" />
<field name="remote_ids">
<tree
decoration-muted="not enabled"
decoration-info="is_private == True"
>
<field name="sequence" widget="handle" />
<field name="repo_id" />
<field name="head_type" />
<field name="head" />
<field name="url_protocol" />
<field name="enabled" widget="boolean_toggle" />
<field name="is_private" optional="hide" />
<field name="reference" optional="hide" />
<field name="create_uid" optional="hide" />
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_plan_line_view_form" model="ir.ui.view">
<field name="name">cx.tower.plan.line.view.form</field>
<field name="model">cx.tower.plan.line</field>
<field
name="inherit_id"
ref="cetmix_tower_server.cx_tower_plan_line_view_form"
/>
<field name="arch" type="xml">
<xpath expr="//field[@name='condition']" position="before">
<group attrs="{'invisible': [('action', '!=', 'file_using_template')]}">
<field name="git_project_id" />
<field name="is_make_copy" />
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_server_view_form" model="ir.ui.view">
<field name="name">cx.tower.server.view.form.shortcuts</field>
<field name="model">cx.tower.server</field>
<field name="inherit_id" ref="cetmix_tower_server.cx_tower_server_view_form" />
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page
name="git_projects"
string="Git Projects"
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
>
<field name="git_project_rel_ids">
<tree editable="bottom">
<field name="server_id" invisible="1" />
<field name="git_project_id" />
<field name="file_id" />
<field name="project_format" />
<field name="auto_sync" />
<button
type="object"
string="Configure"
name="action_open_project"
title="Open Git Project"
class="btn-secondary"
/>
</tree>
<form>
<group>
<field name="server_id" invisible="1" />
<field name="git_project_id" />
<field name="file_id" />
<field name="project_format" />
<field name="auto_sync" />
</group>
</form>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,32 @@
<odoo>
<!-- Git Projects -> Tools -->
<menuitem
id="menu_cx_tower_git_project"
name="Git Projects"
action="cx_tower_git_project_action"
sequence="30"
parent="cetmix_tower_server.menu_tools"
/>
<!-- Git Projects Settings -> Settings -->
<menuitem
id="menu_cx_tower_git_project_settings"
name="Git Projects"
parent="cetmix_tower_server.menu_settings"
sequence="60"
/>
<menuitem
id="menu_cx_tower_git_repositories"
name="Repositories"
parent="menu_cx_tower_git_project_settings"
action="action_cx_tower_git_repo"
sequence="10"
/>
<menuitem
id="menu_cx_tower_git_repository_owners"
name="Repository Owners"
parent="menu_cx_tower_git_project_settings"
action="action_cx_tower_git_repo_owner"
sequence="20"
/>
</odoo>

View File

@@ -0,0 +1,318 @@
===================
Cetmix Tower Server
===================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:426fb7e35a73ec875cf2b484e24211df3f52fc22d9d637f4f3a86bc23ac2e05f
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |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
:alt: cetmix/cetmix-tower
|badge1| |badge2| |badge3|
`Cetmix Tower <https://cetmix.com/tower>`__ offers a streamlined
solution for managing remote servers and applications via SSH or API
calls directly from `Odoo <https://odoo.com>`__. It is designed for
versatility across different operating systems and software
environments, providing a practical option for those looking to manage
servers without getting tied down by vendor or technology constraints.
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.2.2.8 (2025-12-22)
-----------------------
- Bugfixes: Handle malformed expressions in flight plan line conditions.
(5154)
16.0.2.2.7 (2025-12-16)
-----------------------
- Features: Support for ANSI formatting in server logs. (5141)
- Bugfixes: UI/UX fixed and improvements. (5141)
16.0.2.2.6 (2025-12-11)
-----------------------
- Features: Improve search views, implement the search panel for
selected views. (5139)
16.0.2.2.5 (2025-12-10)
-----------------------
- Bugfixes: Custom values in flight plan are lost in a skipped command
and are not available after it. (5129)
16.0.2.2.4 (2025-12-10)
-----------------------
- Features: Parse empty or missing key values as 'None' instead of
leaving key reference as is. (5134)
16.0.2.2.3 (2025-12-03)
-----------------------
- Bugfixes: Save correct error message in log when SSH connection fails.
(5109)
16.0.2.2.2 (2025-12-03)
-----------------------
- Bugfixes: Make variables selectable in scheduled tasks (5105)
16.0.2.2.0 (2025-11-12)
-----------------------
- Features: Integrate user notifications into the main module, drop the
'cetmix_tower_notify_backend' module. (5074)
16.0.2.0.6 (2025-10-27)
-----------------------
- Features: Tag mixin and helper commands. (5039)
16.0.2.0.5 (2025-10-16)
-----------------------
- Bugfixes: Flight plan command exception handling (4930)
16.0.2.0.4 (2025-10-13)
-----------------------
- Features: Auto update references for related records (5005)
16.0.2.0.3 (2025-10-13)
-----------------------
- Features: Terminate running flight plan manually (3410)
16.0.2.0.2 (2025-10-08)
-----------------------
- Features: UI/UX improvements (4996)
- Bugfixes: Handle secret values when a record is duplicated using
copy() (4996)
16.0.2.0.1 (2025-10-08)
-----------------------
- Bugfixes: Improve variable value references uniqueness (4961)
16.0.2.0.0 (2025-10-07)
-----------------------
- Features: 'Cetmix Tower Vault' - new way of centralized password/key
management (4824)
16.0.1.7.2 (2025-09-18)
-----------------------
- Features: Set 'Auto Sync' in files from file templates (4949)
16.0.1.7.1 (2025-09-10)
-----------------------
- Bugfixes: Check custom values in flight plan line condition (4922)
16.0.1.6.4 (2025-08-18)
-----------------------
- Features: Improve the extendability of the file upload command. (4759)
16.0.1.6.3 (2025-08-13)
-----------------------
- Features: Improve access settings for logs (4866)
16.0.1.6.2 (2025-08-05)
-----------------------
- Bugfixes: Pin paramiko version to "<4" to maintain compatibility with
legacy installations (4891)
16.0.1.6.0 (2025-07-30)
-----------------------
- Features: Optional behaviour when file uploaded by command already
exists on the server. (4740)
16.0.1.5.3 (2025-07-29)
-----------------------
- Features: Make file references server dependent to be more unique
(4870)
16.0.1.5.1 (2025-07-25)
-----------------------
- Features: Select secrets from dropdown list in the code fields (4853)
16.0.1.5.0 (2025-07-22)
-----------------------
- Features: Select variables from dropdown list in the code fields
(4827)
16.0.1.3.0 (2025-07-17)
-----------------------
- Features: Add the tldextract and dnspython libraries. (4737)
16.0.1.1.4 (2025-07-07)
-----------------------
- Bugfixes: Command log sorting (4816)
16.0.1.1.2 (2025-06-25)
-----------------------
- Features: Required variables in servers (4779)
16.0.1.1.1 (2025-06-21)
-----------------------
- Features: Command view improvements (4753)
16.0.1.1.0 (2025-06-20)
-----------------------
- Features: Run commands and flight plans using scheduled tasks. (4650)
16.0.1.0.12 (2025-06-06)
------------------------
- Features: Improve command and flight plan log management. (4749)
16.0.1.0.11 (2025-06-06)
------------------------
- Bugfixes: Host key cannot be retrieved from the UI. (4747)
16.0.1.0.10 (2025-05-24)
------------------------
- Features: Improve command log and flight plan form views (4697)
16.0.1.0.9 (2025-05-23)
-----------------------
- Bugfixes: Error when rendering a file not attached to a server. (4715)
16.0.1.0.8 (2025-05-21)
-----------------------
- Features: References for secret values. (4696)
- Features: Make the "Host key" field non-required in the form view to
improve the UX. (4699)
16.0.1.0.7 (2025-05-16)
-----------------------
- Features: Option to preserve command splitting when using sudo. (4641)
- Features: Record references for files. (4670)
- Features: Use ``sudo`` parameter to pass sudo mode to command runner
instead of using context. (4678)
- Bugfixes: Incorrect sudo usage in commands run in wizard. Pass 'No
split for sudo' property to commands run in wizard. (4679)
16.0.1.0.6 (2025-05-16)
-----------------------
- Features: Improve the key storage functionality. (4686)
16.0.1.0.5 (2025-05-09)
-----------------------
- Bugfixes: Non-critical issues and performance improvements. (4663)
16.0.1.0.4 (2025-04-30)
-----------------------
- Features: UI/UX improvements. (4642)
16.0.1.0.3 (2025-04-22)
-----------------------
- Features: Allow to pass custom variable values to commands (4524)
- Features: Cetmix Tower Odoo Automation model: pass custom variable
values to the ``server_run_command`` method. (4547)
- Bugfixes: Random id generation, sudo command parsing, record rule
names, spelling errors in descriptions. (4612)
16.0.1.0.2 (2025-04-22)
-----------------------
- Bugfixes: Refactor secret value handling, fix the new server template
creation wizard. (4601)
16.0.1.0.1
----------
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%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>`_ project on GitHub.
You are welcome to contribute.

View File

@@ -0,0 +1,4 @@
# pylint: disable=E8103
from . import models
from . import wizards

View File

@@ -0,0 +1,86 @@
# Copyright Cetmix OÜ 2022
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Cetmix Tower Server",
"summary": "Manage servers and applications from Odoo",
"version": "16.0.2.2.9",
"category": "Productivity",
"website": "https://tower.cetmix.com",
"live_test_url": "https://cetmix.com/tower",
"images": ["static/description/banner.png"],
"author": "Cetmix",
"license": "AGPL-3",
"application": False,
"installable": True,
"external_dependencies": {
"python": ["paramiko<4", "tldextract", "dnspython", "ansi2html"],
},
"depends": [
"mail",
"rpc_helper",
"web_notify",
],
"data": [
"security/cetmix_tower_server_groups.xml",
"security/ir.model.access.csv",
"security/cx_tower_server_security.xml",
"security/cx_tower_command_security.xml",
"security/cx_tower_plan_security.xml",
"security/cx_tower_plan_line_security.xml",
"security/cx_tower_plan_line_action_security.xml",
"security/cx_tower_plan_log_security.xml",
"security/cx_tower_server_log_security.xml",
"security/cx_tower_command_log_security.xml",
"security/cx_tower_server_template_security.xml",
"security/cx_tower_file_security.xml",
"security/cx_tower_file_template_security.xml",
"security/cx_tower_variable_security.xml",
"security/cx_tower_variable_value_security.xml",
"security/cx_tower_variable_option_security.xml",
"security/cx_tower_scheduled_task_security.xml",
"security/cx_tower_scheduled_task_cv_security.xml",
"security/cx_tower_key_security.xml",
"security/cx_tower_key_value_security.xml",
"security/cx_tower_tag_security.xml",
"security/cx_tower_shortcut_security.xml",
"security/cx_tower_server_wizard_access_rules.xml",
"data/ir_actions_server.xml",
"data/ir_cron.xml",
"data/ir_config_parameter.xml",
"wizards/cx_tower_command_run_wizard_view.xml",
"wizards/cx_tower_plan_run_wizard_view.xml",
"wizards/cx_tower_server_template_create_wizard_view.xml",
"wizards/cx_tower_server_host_key_wizard_view.xml",
"views/cx_tower_server_view.xml",
"views/cx_tower_os_view.xml",
"views/cx_tower_tag_view.xml",
"views/cx_tower_variable_view.xml",
"views/cx_tower_variable_value_view.xml",
"views/cx_tower_command_view.xml",
"views/cx_tower_plan_view.xml",
"views/cx_tower_plan_line_view.xml",
"views/cx_tower_plan_line_view_action_view.xml",
"views/cx_tower_command_log_view.xml",
"views/cx_tower_plan_log_view.xml",
"views/cx_tower_key_view.xml",
"views/cx_tower_file_view.xml",
"views/cx_tower_file_template_view.xml",
"views/cx_tower_server_log_view.xml",
"views/cx_tower_server_template_view.xml",
"views/cx_tower_shortcut_view.xml",
"views/cx_tower_scheduled_task_view.xml",
"views/res_partner_view.xml",
"views/res_config_settings.xml",
"views/menuitems.xml",
],
"demo": [
"demo/demo_data.xml",
],
"assets": {
"web.assets_backend": [
"cetmix_tower_server/static/src/**/*.xml",
"cetmix_tower_server/static/src/**/*.js",
"cetmix_tower_server/static/src/**/*.scss",
],
},
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="action_execute_cx_tower_command" model="ir.actions.server">
<field name="name">Command</field>
<field name="type">ir.actions.server</field>
<field name="model_id" ref="model_cx_tower_server" />
<field name="binding_model_id" ref="model_cx_tower_server" />
<field name="sequence">10</field>
<field name="state">code</field>
<field name="code">action = records.action_run_command()</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
</record>
<record id="action_execute_cx_tower_plan" model="ir.actions.server">
<field name="name">Flight Plan</field>
<field name="type">ir.actions.server</field>
<field name="model_id" ref="model_cx_tower_server" />
<field name="binding_model_id" ref="model_cx_tower_server" />
<field name="sequence">12</field>
<field name="state">code</field>
<field name="code">action = records.action_run_flight_plan()</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
</record>
<record id="action_stop_cx_tower_plan_log" model="ir.actions.server">
<field name="name">Stop Flight Plan</field>
<field name="type">ir.actions.server</field>
<field name="model_id" ref="model_cx_tower_plan_log" />
<field name="binding_model_id" ref="model_cx_tower_plan_log" />
<field name="sequence">14</field>
<field name="state">code</field>
<field name="code">action = records.action_stop()</field>
<field
name="groups_id"
eval="[(4, ref('cetmix_tower_server.group_manager'))]"
/>
</record>
</odoo>

View File

@@ -0,0 +1,20 @@
<odoo noupdate="1">
<record model="ir.config_parameter" id="cetmix_tower_server_command_timeout">
<field name="key">cetmix_tower_server.command_timeout</field>
<field name="value">900</field>
</record>
<record
model="ir.config_parameter"
id="cetmix_tower_server_notification_type_success"
>
<field name="key">cetmix_tower_server.notification_type_success</field>
<field name="value">sticky</field>
</record>
<record
model="ir.config_parameter"
id="cetmix_tower_server_notification_type_error"
>
<field name="key">cetmix_tower_server.notification_type_error</field>
<field name="value">sticky</field>
</record>
</odoo>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<!-- Check zombie commands -->
<record forcecreate="True" id="ir_cron_check_zombie_commands" model="ir.cron">
<field name="name">Cetmix Tower: Check zombie commands</field>
<field name="model_id" ref="model_cx_tower_server" />
<field name="state">code</field>
<field name="code">model._check_zombie_commands()</field>
<field name="user_id" ref="base.user_root" />
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall" />
</record>
<!-- Auto pull files from server -->
<record forcecreate="True" id="ir_cron_auto_pull_files_from_server" model="ir.cron">
<field name="name">Cetmix Tower: Auto pull files from server</field>
<field name="model_id" ref="model_cx_tower_file" />
<field name="state">code</field>
<field name="code">model._run_auto_pull_files()</field>
<field name="user_id" ref="base.user_root" />
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall" />
</record>
<!-- Run scheduled tasks -->
<record forcecreate="True" id="ir_cron_run_scheduled_tasks" model="ir.cron">
<field name="name">Cetmix Tower: Run scheduled tasks</field>
<field name="model_id" ref="model_cx_tower_scheduled_task" />
<field name="state">code</field>
<field name="code">model._run_scheduled_tasks()</field>
<field name="user_id" ref="base.user_root" />
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall" />
</record>
</odoo>

View File

@@ -0,0 +1,3 @@
-- deactivate scheduled tasks
UPDATE cx_tower_scheduled_task
SET active = false;

View File

@@ -0,0 +1,973 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<!-- Add demo users to groups -->
<record id="base.user_admin" model="res.users">
<field name="groups_id" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
</record>
<record id="base.user_demo" model="res.users">
<field name="groups_id" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
</record>
<!-- ===============================================
OSs
================================================ -->
<record id="os_debian_10" model="cx.tower.os">
<field name="name">Debian 10</field>
<field name="color">1</field>
</record>
<record id="os_debian_11" model="cx.tower.os">
<field name="parent_id" ref="os_debian_10" />
<field name="name">Debian 11</field>
<field name="color">1</field>
</record>
<record id="os_debian_12" model="cx.tower.os">
<field name="parent_id" ref="os_debian_11" />
<field name="name">Debian 12</field>
<field name="color">1</field>
</record>
<record id="os_ubuntu_20_04" model="cx.tower.os">
<field name="name">Ubuntu 20.04</field>
<field name="color">2</field>
</record>
<record id="os_ubuntu_22_04" model="cx.tower.os">
<field name="parent_id" ref="os_ubuntu_20_04" />
<field name="name">Ubuntu 22.04</field>
<field name="color">2</field>
</record>
<record id="os_ubuntu_24_04" model="cx.tower.os">
<field name="parent_id" ref="os_ubuntu_22_04" />
<field name="name">Ubuntu 24.04</field>
<field name="color">2</field>
</record>
<!-- ===============================================
Tags
================================================ -->
<record id="tag_staging" model="cx.tower.tag">
<field name="name">Staging</field>
<field name="color">1</field>
</record>
<record id="tag_production" model="cx.tower.tag">
<field name="name">Production</field>
<field name="color">2</field>
</record>
<record id="tag_custom" model="cx.tower.tag">
<field name="name">Custom</field>
<field name="color">3</field>
</record>
<!-- ===============================================
Keys
================================================ -->
<record id="demo_key_1" model="cx.tower.key">
<field name="name">Demo Key 1 SSH</field>
<field name="key_type">k</field>
<field name="secret_value">Such Much SSH</field>
</record>
<record id="demo_key_2" model="cx.tower.key">
<field name="name">Demo Key 2 Secret</field>
<field name="key_type">s</field>
<field name="secret_value">Wow! Such much secret!</field>
</record>
<!-- ===============================================
Servers
================================================ -->
<record id="server_demo_1" model="cx.tower.server">
<field name="name">Demo Server #1</field>
<field name="color">1</field>
<field name="status">stopped</field>
<field name="ip_v4_address">localhost</field>
<field name="ssh_username">admin</field>
<field name="ssh_password">password</field>
<field name="ssh_auth_mode">p</field>
<field name="host_key">demohostkey</field>
<field name="os_id" ref="os_debian_10" />
<field name="url">demo1.example.com</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_custom')])]"
/>
<field name="user_ids" eval="[(6, 0, [ref('base.user_demo')])]" />
<field name="manager_ids" eval="[(6, 0, [ref('base.user_admin')])]" />
<field name="note">
This server is used in unit tests.
No variables are defined.
</field>
</record>
<record id="server_demo_2" model="cx.tower.server">
<field name="color">2</field>
<field name="name">Demo Server #2</field>
<field name="status">running</field>
<field name="ip_v4_address">localhost</field>
<field name="ssh_username">admin</field>
<field name="ssh_password">password</field>
<field name="ssh_auth_mode">k</field>
<field name="use_sudo">p</field>
<field name="skip_host_key">True</field>
<field name="ssh_key_id" ref="demo_key_1" />
<field name="os_id" ref="os_ubuntu_20_04" />
<field name="url">https://dogememe.example.com</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_production')])]"
/>
<field name="user_ids" eval="[(6, 0, [ref('base.user_demo')])]" />
<field name="manager_ids" eval="[(6, 0, [ref('base.user_admin')])]" />
<field name="note">This server has variables configured</field>
</record>
<record id="mail_follower_server_demo_2" model="mail.followers">
<field name="res_model">cx.tower.server</field>
<field name="res_id" ref="server_demo_2" />
<field name="partner_id" ref="base.partner_demo" />
</record>
<!-- ===============================================
File templates
================================================ -->
<record id="cx_tower_file_template_demo_1" model="cx.tower.file.template">
<field name="name">Demo File Template 1</field>
<field name="file_name">tower_demo_1.txt</field>
<field name="server_dir">{{ demo_path }}</field>
<field name="code">Hello, world!</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_production')])]"
/>
</record>
<record id="cx_tower_file_template_demo_2" model="cx.tower.file.template">
<field name="name">Demo File Template 2</field>
<field name="file_name">{{ demo_branch }}_tower_demo_2.txt</field>
<field name="server_dir">/tmp</field>
<field name="code">Hello, world!</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_production')])]"
/>
</record>
<record id="cx_tower_file_template_demo_3" model="cx.tower.file.template">
<field name="name">Demo File Template 3</field>
<field name="file_name">tower_demo_3.txt</field>
<field name="server_dir">/tmp</field>
<field
name="code"
>How to create a directory: cd {{ demo_path }} &amp;&amp; mkdir {{ demo_dir }}</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_production')])]"
/>
</record>
<record id="cx_tower_file_template_demo_4" model="cx.tower.file.template">
<field name="name">Demo File Template 4</field>
<field name="source">server</field>
<field name="file_name">server_demo_logs.txt</field>
<field name="server_dir">/var/log</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_production')])]"
/>
</record>
<!-- ===============================================
Files
================================================ -->
<record id="cx_tower_file_tower_demo_1" model="cx.tower.file">
<field name="name">tower_demo_1.txt</field>
<field name="source">tower</field>
<field name="template_id" ref="cx_tower_file_template_demo_1" />
<field name="server_id" ref="server_demo_2" />
</record>
<record id="cx_tower_file_tower_demo_2" model="cx.tower.file">
<field name="name">tower_demo_2.txt</field>
<field name="source">tower</field>
<field name="template_id" ref="cx_tower_file_template_demo_2" />
<field name="server_id" ref="server_demo_2" />
</record>
<record id="cx_tower_file_tower_demo_3" model="cx.tower.file">
<field name="name">tower_demo_3.txt</field>
<field name="source">tower</field>
<field name="template_id" ref="cx_tower_file_template_demo_3" />
<field name="server_id" ref="server_demo_2" />
</record>
<record id="cx_tower_file_server_demo_logs" model="cx.tower.file">
<field name="name">server_demo_logs.txt</field>
<field name="source">server</field>
<field name="template_id" ref="cx_tower_file_template_demo_4" />
<field name="server_id" ref="server_demo_2" />
</record>
<record id="cx_tower_file_tower_without_template_demo" model="cx.tower.file">
<field name="name">tower_demo_without_template_{{ demo_branch }}.txt</field>
<field name="source">tower</field>
<field name="server_id" ref="server_demo_2" />
<field name="server_dir">{{ demo_path }}</field>
<field name="code">Please, check url: {{ demo_url }}</field>
</record>
<record id="cx_tower_file_server_demo" model="cx.tower.file">
<field name="name">server_demo.txt</field>
<field name="source">server</field>
<field name="server_id" ref="server_demo_2" />
<field name="server_dir">{{ demo_path }}</field>
</record>
<!-- ===============================================
Variables
================================================ -->
<record id="variable_demo_path" model="cx.tower.variable">
<field name="name">Demo Path</field>
<field name="reference">demo_path</field>
</record>
<record id="variable_demo_dir" model="cx.tower.variable">
<field name="name">Demo Directory</field>
<field name="reference">demo_dir</field>
</record>
<record id="variable_demo_os" model="cx.tower.variable">
<field name="name">Demo Operating System</field>
<field name="reference">demo_os</field>
</record>
<record id="variable_demo_url" model="cx.tower.variable">
<field name="name">Demo URL</field>
<field name="reference">demo_url</field>
</record>
<record id="variable_demo_odoo_version" model="cx.tower.variable">
<field name="name">Demo Odoo Version</field>
<field name="reference">demo_odoo_version</field>
<field name="variable_type">o</field>
</record>
<record id="variable_demo_language" model="cx.tower.variable">
<field name="name">Demo Language</field>
<field name="reference">demo_language</field>
<field name="variable_type">o</field>
</record>
<record id="variable_demo_version" model="cx.tower.variable">
<field name="name">Demo Version</field>
<field name="reference">demo_version</field>
</record>
<record id="variable_demo_org" model="cx.tower.variable">
<field name="name">Demo Organisation</field>
<field name="reference">demo_org</field>
</record>
<record id="variable_demo_branch" model="cx.tower.variable">
<field name="name">Demo Branch</field>
<field name="reference">demo_branch</field>
<field name="validation_pattern">^[a-z0-9]+$</field>
<field
name="validation_message"
>Must be lowercase and contain only letters and numbers!</field>
</record>
<!-- ===============================================
Variables Options for Odoo Version
================================================ -->
<record id="option_demo_odoo_version_14" model="cx.tower.variable.option">
<field name="variable_id" ref="variable_demo_odoo_version" />
<field name="name">14.0</field>
<field name="value_char">14.0</field>
<field name="sequence">10</field>
</record>
<record id="option_demo_odoo_version_15" model="cx.tower.variable.option">
<field name="variable_id" ref="variable_demo_odoo_version" />
<field name="name">15.0</field>
<field name="value_char">15.0</field>
<field name="sequence">20</field>
</record>
<record id="option_demo_odoo_version_16" model="cx.tower.variable.option">
<field name="variable_id" ref="variable_demo_odoo_version" />
<field name="name">16.0</field>
<field name="value_char">16.0</field>
<field name="sequence">30</field>
</record>
<record id="option_demo_odoo_version_17" model="cx.tower.variable.option">
<field name="variable_id" ref="variable_demo_odoo_version" />
<field name="name">17.0</field>
<field name="value_char">17.0</field>
<field name="sequence">40</field>
</record>
<record id="option_demo_odoo_version_18" model="cx.tower.variable.option">
<field name="variable_id" ref="variable_demo_odoo_version" />
<field name="name">18.0</field>
<field name="value_char">18.0</field>
<field name="sequence">50</field>
</record>
<!-- ===============================================
Variables Options for Language
================================================ -->
<record id="option_language_en_us" model="cx.tower.variable.option">
<field name="variable_id" ref="variable_demo_language" />
<field name="name">English (US)</field>
<field name="value_char">en_us</field>
<field name="sequence">10</field>
</record>
<record id="option_language_it" model="cx.tower.variable.option">
<field name="variable_id" ref="variable_demo_language" />
<field name="name">Italian</field>
<field name="value_char">it</field>
<field name="sequence">20</field>
</record>
<record id="option_language_es_mx" model="cx.tower.variable.option">
<field name="variable_id" ref="variable_demo_language" />
<field name="name">Spanish (Mexican)</field>
<field name="value_char">es_mx</field>
<field name="sequence">30</field>
</record>
<record id="option_language_de" model="cx.tower.variable.option">
<field name="variable_id" ref="variable_demo_language" />
<field name="name">German</field>
<field name="value_char">de</field>
<field name="sequence">40</field>
</record>
<record id="option_language_de_ch" model="cx.tower.variable.option">
<field name="variable_id" ref="variable_demo_language" />
<field name="name">German (Switzerland)</field>
<field name="value_char">de_ch</field>
<field name="sequence">50</field>
</record>
<!-- ===============================================
Variable values for Server #1
================================================ -->
<record id="server_1_value_path" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_path" />
<field name="server_id" ref="server_demo_1" />
<field
name="value_char"
>/home/{{ tower.server.username }}/tower/{{ demo_branch }}</field>
</record>
<record id="server_1_value_dir" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_dir" />
<field name="server_id" ref="server_demo_1" />
<field name="value_char">/tmp/repo/{{ demo_branch }}</field>
</record>
<record id="server_1_value_language" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_language" />
<field name="server_id" ref="server_demo_1" />
<field name="option_id" ref="option_language_es_mx" />
</record>
<record id="server_1_value_demo_odoo_version" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_odoo_version" />
<field name="server_id" ref="server_demo_1" />
<field name="option_id" ref="option_demo_odoo_version_16" />
</record>
<!-- ===============================================
Variable values for Server #2
================================================ -->
<record id="server_2_value_path" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_path" />
<field name="server_id" ref="server_demo_2" />
<field name="value_char">/opt/{{ tower.server.reference }}/cetmix-tower</field>
</record>
<record id="server_2_value_dir" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_dir" />
<field name="server_id" ref="server_demo_2" />
<field name="value_char">/opt/{{ tower.server.reference }}/cetmix-tower</field>
</record>
<record id="server_2_value_url" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_url" />
<field name="server_id" ref="server_demo_2" />
<field name="value_char">https://cetmix.com</field>
</record>
<record id="server_2_value_branch" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_branch" />
<field name="server_id" ref="server_demo_2" />
<field name="value_char">staging</field>
</record>
<record id="server_2_value_demo_odoo_version" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_odoo_version" />
<field name="server_id" ref="server_demo_2" />
<field name="option_id" ref="option_demo_odoo_version_17" />
</record>
<record id="server_2_value_language" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_language" />
<field name="server_id" ref="server_demo_2" />
<field name="option_id" ref="option_language_de" />
</record>
<!-- ===============================================
Global Variable values
================================================ -->
<record id="global_value_branch" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_branch" />
<field name="value_char">prod</field>
</record>
<record id="global_value_org" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_org" />
<field name="value_char">Cetmix</field>
</record>
<!-- ===============================================
Flight Plans
================================================ -->
<!-- Flight Plan #1 -->
<record id="plan_demo_1" model="cx.tower.plan">
<field name="name">Demo Flight Plan #1</field>
<field name="note">Create directory and list its content</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_staging')])]"
/>
<field name="access_level">1</field>
<field
name="server_ids"
eval="[(6, 0, [ref('cetmix_tower_server.server_demo_1'), ref('cetmix_tower_server.server_demo_2')])]"
/>
</record>
<!-- Flight Plan #2 -->
<record id="plan_demo_2" model="cx.tower.plan">
<field name="name">Demo Flight Plan #2</field>
<field name="note">Run another flight plan</field>
</record>
<!-- Flight Plan 3 -->
<record id="plan_demo_3" model="cx.tower.plan">
<field name="name">Demo Flight Plan for User</field>
<field name="note">Demo of a user-accessible flight plan</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_staging')])]"
/>
<field name="access_level">1</field>
</record>
<!-- Flight Plan #4 -->
<record id="plan_demo_4" model="cx.tower.plan">
<field name="name">Demo Flight Plan #4</field>
<field name="note">Update and upgrade packages</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_production')])]"
/>
</record>
<!-- Flight Plan #5 -->
<record id="plan_demo_5" model="cx.tower.plan">
<field name="name">Demo Flight Plan #5</field>
<field name="note">Check branch and download log file</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_custom')])]"
/>
</record>
<!-- ===============================================
Commands
================================================ -->
<!-- Command #1 -->
<record id="command_update_upgrade" model="cx.tower.command">
<field name="name">Update packages</field>
<field name="action">ssh_command</field>
<field
name="server_ids"
eval="[(6, 0, [ref('cetmix_tower_server.server_demo_1'), ref('cetmix_tower_server.server_demo_2')])]"
/>
<field name="code">apt-get update &amp;&amp; apt-get upgrade -y</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_staging'),ref('cetmix_tower_server.tag_production')])]"
/>
<field
name="os_ids"
eval="[(6, 0, [ref('cetmix_tower_server.os_debian_11'), ref('cetmix_tower_server.os_ubuntu_22_04'),ref('cetmix_tower_server.os_ubuntu_24_04')])]"
/>
<field name="note">Update and upgrade packages on the host system</field>
<field name="access_level">1</field>
</record>
<!-- Command #2 -->
<record id="command_create_dir" model="cx.tower.command">
<field name="name">Create directory</field>
<field name="action">ssh_command</field>
<field
name="server_ids"
eval="[(6, 0, [ref('cetmix_tower_server.server_demo_1')])]"
/>
<field name="path">/home/{{ tower.server.username }}</field>
<field name="code">mkdir -p {{ demo_dir }}</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_production')])]"
/>
<field name="note">Create a directory on the host system</field>
<field name="access_level">1</field>
</record>
<!-- Command #3 -->
<record id="command_list_dir" model="cx.tower.command">
<field name="name">List files in directory</field>
<field name="action">ssh_command</field>
<field name="path">/home/{{ tower.server.username }}</field>
<field name="code">ls -l</field>
<field name="access_level">1</field>
<field
name="os_ids"
eval="[(6, 0, [ref('cetmix_tower_server.os_debian_12'), ref('cetmix_tower_server.os_ubuntu_24_04')])]"
/>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_custom')])]"
/>
<field name="note">List files in the directory</field>
</record>
<!-- Command #4 -->
<record id="command_upload_file" model="cx.tower.command">
<field name="name">Upload file by template</field>
<field name="action">file_using_template</field>
<field name="path">/home/{{ tower.server.username }}</field>
<field name="file_template_id" ref="cx_tower_file_template_demo_1" />
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_staging')])]"
/>
<field name="note">Upload a file to the host system</field>
</record>
<!-- Command #5 -->
<record id="command_check_branch" model="cx.tower.command">
<field name="name">Run Python Code: Check Branch</field>
<field name="action">python_code</field>
<field name="code">
if {{ demo_branch }}:
result={"exit_code": 0, "message": "Branch is defined!"}
else:
result={"exit_code": -100, "message": "Branch is not defined!"}
</field>
<field name="access_level">1</field>
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_custom')])]"
/>
<field name="note">Run Python Code: Check Branch</field>
</record>
<!-- Command #6 -->
<record id="command_download_file" model="cx.tower.command">
<field name="name">Download log file by template</field>
<field name="action">file_using_template</field>
<field name="path">/home/{{ tower.server.username }}</field>
<field name="file_template_id" ref="cx_tower_file_template_demo_4" />
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_staging')])]"
/>
<field name="note">Download log file by template</field>
</record>
<!-- Command #7 -->
<record id="command_execute_flight_plan" model="cx.tower.command">
<field name="name">Run Demo #1 Flight Plan</field>
<field name="action">plan</field>
<field name="flight_plan_id" ref="plan_demo_1" />
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_staging')])]"
/>
</record>
<!-- ===============================================
Flight Plan 1 Line 1
================================================ -->
<record id="plan_demo_1_line_1" model="cx.tower.plan.line">
<field name="sequence">5</field>
<field name="plan_id" ref="plan_demo_1" />
<field name="command_id" ref="command_create_dir" />
<field name="path">{{ demo_path }}</field>
</record>
<!-- ===============================================
Flight Plan 1 Line Actions
================================================ -->
<record id="plan_demo_1_line_1_action_1" model="cx.tower.plan.line.action">
<field name="line_id" ref="plan_demo_1_line_1" />
<field name="sequence">1</field>
<field name="condition">==</field>
<field name="value_char">0</field>
</record>
<record id="plan_demo_1_line_1_action_2" model="cx.tower.plan.line.action">
<field name="line_id" ref="plan_demo_1_line_1" />
<field name="sequence">2</field>
<field name="condition">&gt;</field>
<field name="value_char">0</field>
<field name="action">ec</field>
<field name="custom_exit_code">255</field>
</record>
<!-- ===============================================
Flight Plan 1 Line 1 Action Variable Value
================================================ -->
<record id="action_1_value_branch" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_branch" />
<field name="plan_line_action_id" ref="plan_demo_1_line_1_action_1" />
<field name="value_char">production</field>
</record>
<record id="action_1_value_language" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_language" />
<field name="plan_line_action_id" ref="plan_demo_1_line_1_action_1" />
<field name="option_id" ref="option_language_en_us" />
</record>
<!-- ===============================================
Flight Plan 1 Line 2
================================================ -->
<record id="plan_demo_1_line_2" model="cx.tower.plan.line">
<field name="sequence">20</field>
<field name="plan_id" ref="plan_demo_1" />
<field name="command_id" ref="command_list_dir" />
<field
name="condition"
>{{ tower.server.status }} == 'running' and {{ demo_demo_odoo_version }} == "17.0"</field>
</record>
<!-- ===============================================
Flight Plan 1 Line 2 Actions
================================================ -->
<record id="plan_demo_1_line_2_action_1" model="cx.tower.plan.line.action">
<field name="line_id" ref="plan_demo_1_line_2" />
<field name="sequence">1</field>
<field name="condition">==</field>
<field name="value_char">-1</field>
<field name="action">ec</field>
<field name="custom_exit_code">100</field>
</record>
<record id="plan_demo_1_line_2_action_2" model="cx.tower.plan.line.action">
<field name="line_id" ref="plan_demo_1_line_2" />
<field name="sequence">2</field>
<field name="condition">&gt;=</field>
<field name="value_char">3</field>
<field name="action">n</field>
</record>
<!-- ===============================================
Flight Plan 1 Line 3
================================================ -->
<record id="plan_demo_1_line_3" model="cx.tower.plan.line">
<field name="sequence">30</field>
<field name="plan_id" ref="plan_demo_1" />
<field name="command_id" ref="command_upload_file" />
</record>
<!-- ===============================================
Flight Plan 2 Line 1
================================================ -->
<record id="plan_demo_2_line_1" model="cx.tower.plan.line">
<field name="sequence">5</field>
<field name="plan_id" ref="plan_demo_2" />
<field name="command_id" ref="command_execute_flight_plan" />
</record>
<!-- ===============================================
Flight Plan 3 Line 1
================================================ -->
<record id="plan_demo_3_line_1" model="cx.tower.plan.line">
<field name="sequence">1</field>
<field name="plan_id" ref="plan_demo_3" />
<field name="command_id" ref="command_list_dir" />
<field name="path">{{ demo_path }}</field>
</record>
<!-- ===============================================
Flight Plan 3 Line 2
================================================ -->
<record id="plan_demo_3_line_2" model="cx.tower.plan.line">
<field name="sequence">2</field>
<field name="plan_id" ref="plan_demo_3" />
<field name="command_id" ref="command_create_dir" />
<field name="path">{{ demo_path }}</field>
</record>
<!-- ===============================================
Flight Plan 4 Line 1
================================================ -->
<record id="plan_demo_4_line_1" model="cx.tower.plan.line">
<field name="sequence">10</field>
<field name="plan_id" ref="plan_demo_4" />
<field name="command_id" ref="command_update_upgrade" />
</record>
<!-- ===============================================
Flight Plan 5 Line 1
================================================ -->
<record id="plan_demo_5_line_1" model="cx.tower.plan.line">
<field name="sequence">10</field>
<field name="plan_id" ref="plan_demo_5" />
<field name="command_id" ref="command_check_branch" />
</record>
<!-- ===============================================
Flight Plan 5 Line 2
================================================ -->
<record id="plan_demo_5_line_2" model="cx.tower.plan.line">
<field name="sequence">20</field>
<field name="plan_id" ref="plan_demo_5" />
<field name="command_id" ref="command_download_file" />
</record>
<!-- ===============================================
Files
================================================ -->
<record id="cx_tower_file_server_demo_logs_1" model="cx.tower.file">
<field name="name">tower_demo_1.txt</field>
<field name="source">server</field>
<field name="template_id" ref="cx_tower_file_template_demo_4" />
<field name="server_id" ref="server_demo_1" />
</record>
<record id="cx_tower_file_server_demo_logs_2" model="cx.tower.file">
<field name="name">tower_demo_2.txt</field>
<field name="source">server</field>
<field name="template_id" ref="cx_tower_file_template_demo_4" />
<field name="server_id" ref="server_demo_2" />
</record>
<!-- ===============================================
Server Log
================================================ -->
<record id="server_log_1" model="cx.tower.server.log">
<field name="name">Log from file</field>
<field name="server_id" ref="server_demo_1" />
<field name="log_type">file</field>
<field name="file_id" ref="cx_tower_file_server_demo_logs_1" />
</record>
<record id="server_log_2" model="cx.tower.server.log">
<field name="name">Log from file</field>
<field name="server_id" ref="server_demo_2" />
<field name="log_type">file</field>
<field name="file_id" ref="cx_tower_file_server_demo_logs_2" />
</record>
<!-- ===============================================
Server Template
================================================ -->
<record id="demo_server_template_1" model="cx.tower.server.template">
<field name="name">Demo Server Template #1</field>
<field name="color">1</field>
<field name="ssh_username">admin</field>
<field name="ssh_password">password</field>
<field name="ssh_auth_mode">p</field>
<field name="os_id" ref="os_debian_10" />
<field name="flight_plan_id" ref="plan_demo_1" />
<field
name="tag_ids"
eval="[(6, 0, [ref('cetmix_tower_server.tag_custom')])]"
/>
</record>
<record id="server_log_for_server_template_1" model="cx.tower.server.log">
<field name="name">Log from file</field>
<field name="server_template_id" ref="demo_server_template_1" />
<field name="log_type">file</field>
<field name="file_template_id" ref="cx_tower_file_template_demo_4" />
</record>
<!-- ===============================================
Variable values for Server Template
================================================ -->
<record id="demo_server_template_1_value_path" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_path" />
<field name="server_template_id" ref="demo_server_template_1" />
<field
name="value_char"
>/opt/{{ tower.server.reference }}/cetmix-tower/{{ demo_branch }}</field>
</record>
<record id="demo_server_template_1_value_url" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_url" />
<field name="server_template_id" ref="demo_server_template_1" />
<field name="value_char">https://cetmix.com</field>
</record>
<record id="demo_server_template_1_value_language" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_language" />
<field name="server_template_id" ref="demo_server_template_1" />
<field name="required">1</field>
</record>
<record
id="demo_server_template_1_value_demo_odoo_version"
model="cx.tower.variable.value"
>
<field name="variable_id" ref="variable_demo_odoo_version" />
<field name="server_template_id" ref="demo_server_template_1" />
<field name="option_id" ref="option_demo_odoo_version_17" />
<field name="required">1</field>
</record>
<!-- ===============================================
Server Log of type "command"
================================================ -->
<record id="server_log_command_1" model="cx.tower.server.log">
<field name="name">Command Log for Server #1</field>
<field name="server_id" ref="server_demo_1" />
<field name="log_type">command</field>
<field name="command_id" ref="command_create_dir" />
</record>
<record id="server_log_command_2" model="cx.tower.server.log">
<field name="name">Command Log for Server #2</field>
<field name="server_id" ref="server_demo_2" />
<field name="log_type">command</field>
<field name="command_id" ref="command_create_dir" />
</record>
<record id="server_log_command_template_1" model="cx.tower.server.log">
<field name="name">Command Log for Server Template #1</field>
<field name="server_template_id" ref="demo_server_template_1" />
<field name="log_type">command</field>
<field name="command_id" ref="command_create_dir" />
</record>
<!-- ===============================================
New Variables for Flight Plan Execution
================================================ -->
<record id="variable_demo_flight_plan_status_unique" model="cx.tower.variable">
<field name="name">Demo Flight Plan Execution Status</field>
<field name="reference">demo_flight_plan_status_unique</field>
</record>
<record id="variable_demo_flight_plan_start_time_unique" model="cx.tower.variable">
<field name="name">Demo Flight Plan Start Time Unique</field>
<field name="reference">demo_flight_plan_start_time_unique</field>
</record>
<record id="variable_demo_flight_plan_end_time_unique" model="cx.tower.variable">
<field name="name">Demo Flight Plan End Time Unique</field>
<field name="reference">demo_flight_plan_end_time_unique</field>
</record>
<!-- Variable values for Flight Plan Execution -->
<record id="flight_plan_status_value_unique" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_flight_plan_status_unique" />
<field name="value_char">completed</field>
</record>
<record id="flight_plan_start_time_value_unique" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_flight_plan_start_time_unique" />
<field name="value_char">initial_value</field>
</record>
<record id="flight_plan_end_time_value_unique" model="cx.tower.variable.value">
<field name="variable_id" ref="variable_demo_flight_plan_end_time_unique" />
<field name="value_char">final_value</field>
</record>
<!-- ===============================================
Flight Plan 1 Line 1 Action Variable Values
================================================ -->
<record
id="action_1_value_demo_flight_plan_status_unique"
model="cx.tower.variable.value"
>
<field name="variable_id" ref="variable_demo_flight_plan_status_unique" />
<field name="plan_line_action_id" ref="plan_demo_1_line_1_action_1" />
<field name="value_char">completed</field>
</record>
<record
id="action_1_value_demo_flight_plan_start_time_unique"
model="cx.tower.variable.value"
>
<field name="variable_id" ref="variable_demo_flight_plan_start_time_unique" />
<field name="plan_line_action_id" ref="plan_demo_1_line_1_action_1" />
<field name="value_char">initial_value</field>
</record>
<record
id="action_1_value_demo_flight_plan_end_time_unique"
model="cx.tower.variable.value"
>
<field name="variable_id" ref="variable_demo_flight_plan_end_time_unique" />
<field name="plan_line_action_id" ref="plan_demo_1_line_1_action_1" />
<field name="value_char">final_value</field>
</record>
<!-- ===============================================
Action Variable Values for Other Actions
================================================ -->
<record
id="action_2_value_demo_flight_plan_status_unique"
model="cx.tower.variable.value"
>
<field name="variable_id" ref="variable_demo_flight_plan_status_unique" />
<field name="plan_line_action_id" ref="plan_demo_1_line_2_action_1" />
<field name="value_char">completed</field>
</record>
<record
id="action_2_value_demo_flight_plan_start_time_unique"
model="cx.tower.variable.value"
>
<field name="variable_id" ref="variable_demo_flight_plan_start_time_unique" />
<field name="plan_line_action_id" ref="plan_demo_1_line_2_action_1" />
<field name="value_char">initial_value</field>
</record>
<record
id="action_2_value_demo_flight_plan_end_time_unique"
model="cx.tower.variable.value"
>
<field name="variable_id" ref="variable_demo_flight_plan_end_time_unique" />
<field name="plan_line_action_id" ref="plan_demo_1_line_2_action_1" />
<field name="value_char">final_value</field>
</record>
<!-- ===============================================
Shortcuts
================================================ -->
<record id="shortcut_for_command" model="cx.tower.shortcut">
<field name="name">Command Shortcut</field>
<field name="action">command</field>
<field name="command_id" ref="command_list_dir" />
<field
name="server_ids"
eval="[(6, 0, [ref('server_demo_1'),ref('server_demo_2')])]"
/>
<field name="access_level">1</field>
<field
name="note"
>Runs a command. Use as an example to create your own shortcuts.</field>
</record>
<record id="shortcut_for_flight_plan" model="cx.tower.shortcut">
<field name="name">Flight Plan Shortcut</field>
<field name="action">plan</field>
<field name="plan_id" ref="plan_demo_1" />
<field
name="server_ids"
eval="[(6, 0, [ref('server_demo_1'),ref('server_demo_2')])]"
/>
<field name="access_level">2</field>
<field
name="note"
>Runs a flight plan. Use as an example to create your own shortcuts.</field>
</record>
<record id="shortcut_for_server_template" model="cx.tower.shortcut">
<field name="name">Flight Plan Shortcut for Server Template</field>
<field name="action">plan</field>
<field name="plan_id" ref="plan_demo_1" />
<field
name="server_template_ids"
eval="[(6, 0, [ref('demo_server_template_1')])]"
/>
<field name="access_level">2</field>
<field
name="note"
>Runs a flight plan for a server template. Use as an example to create your own shortcuts.</field>
</record>
<!-- ===============================================
Scheduled Tasks
================================================ -->
<record id="cx_tower_scheduled_task_demo_1" model="cx.tower.scheduled.task">
<field name="name">Scheduled Task Demo #1</field>
<field name="reference">scheduled_task_demo_1</field>
<field name="active">True</field>
<field name="sequence">1</field>
<field name="server_ids" eval="[(4, ref('server_demo_1'))]" />
<field name="server_template_ids" eval="[(4, ref('demo_server_template_1'))]" />
<field name="action">command</field>
<field name="command_id" ref="command_update_upgrade" />
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="next_call" eval="(DateTime.now() - relativedelta(hours=1))" />
</record>
<record id="cx_tower_scheduled_task_demo_2" model="cx.tower.scheduled.task">
<field name="name">Scheduled Task Demo #2</field>
<field name="reference">scheduled_task_demo_2</field>
<field name="active">True</field>
<field name="sequence">2</field>
<field
name="server_ids"
eval="[(6, 0, [ref('server_demo_1'), ref('server_demo_2')])]"
/>
<field name="action">plan</field>
<field name="plan_id" ref="plan_demo_1" />
<field name="interval_number">2</field>
<field name="interval_type">days</field>
<field name="next_call" eval="(DateTime.now() - relativedelta(days=2))" />
</record>
</odoo>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
import logging
from odoo import SUPERUSER_ID, api
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Generate references for files.
"""
_logger.info("Starting reference generation for files.")
env = api.Environment(cr, SUPERUSER_ID, {})
model_obj = env["cx.tower.file"]
records_without_reference = model_obj.search([("reference", "=", False)])
for record in records_without_reference:
record_reference = record._generate_or_fix_reference(record.name)
record.write({"reference": record_reference})
_logger.info(f"Generated reference for file {record.name}: {record_reference}")
_logger.info("Reference generation for files completed.")

View File

@@ -0,0 +1,119 @@
import logging
from odoo import SUPERUSER_ID, api
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Move SSH credentials, host keys, SSH keys, and secret values
to the vault-backed storage.
"""
# 1. SSH password and host key are now stored in secrets
_logger.info("Moving SSH password and host key to vault.")
env = api.Environment(cr, SUPERUSER_ID, {})
# Read SSH password and host key from servers using SQL query
cr.execute(
"""
SELECT id, ssh_password, host_key
FROM cx_tower_server
WHERE ssh_password IS NOT NULL OR host_key IS NOT NULL
"""
)
server_records = cr.fetchall()
server_model = env["cx.tower.server"]
success = False
try:
for record in server_records:
_logger.info(
f"Moving SSH password and host key to vault for server {record[0]}"
)
server_model.browse(record[0]).write(
{"ssh_password": record[1], "host_key": record[2]}
)
_logger.info("Moving SSH password and host key to vault completed.")
success = True
# Clear SSH password and host key from servers
except Exception as e:
_logger.error(f"Error moving SSH password and host key to vault: {e}")
raise e
finally:
if success:
cr.execute(
"""
UPDATE cx_tower_server
SET ssh_password = NULL, host_key = NULL
WHERE ssh_password IS NOT NULL OR host_key IS NOT NULL
"""
)
_logger.info("Cleared SSH password and host key from servers.")
# 2. SSH keys are now stored in secrets
_logger.info("Moving SSH keys to vault.")
success = False
# Read SSH keys from keys using SQL query
cr.execute(
"""
SELECT id, secret_value
FROM cx_tower_key
WHERE key_type = 'k'
"""
)
ssh_key_records = cr.fetchall()
ssh_key_model = env["cx.tower.key"]
try:
for record in ssh_key_records:
_logger.info(f"Moving SSH key to vault record {record[0]}")
ssh_key_model.browse(record[0]).write({"secret_value": record[1]})
_logger.info("Moving SSH keys to vault completed.")
success = True
except Exception as e:
_logger.error(f"Error moving SSH keys to vault: {e}")
raise e
finally:
if success:
# Clear SSH key from keys
cr.execute(
"""
UPDATE cx_tower_key
SET secret_value = NULL
WHERE secret_value IS NOT NULL
"""
)
_logger.info("Cleared SSH key from keys.")
# 3. Secret values are now stored in secrets
_logger.info("Moving secret values to vault.")
success = False
# Read secret values from key values using SQL query
cr.execute(
"""
SELECT id, secret_value
FROM cx_tower_key_value
"""
)
secret_value_records = cr.fetchall()
secret_value_model = env["cx.tower.key.value"]
try:
for record in secret_value_records:
_logger.info(f"Moving secret value to vault record {record[0]}")
secret_value_model.browse(record[0]).write({"secret_value": record[1]})
_logger.info("Moving secret values to vault completed.")
success = True
except Exception as e:
_logger.error(f"Error moving secret values to vault: {e}")
raise e
finally:
if success:
# Clear secret value from key values
cr.execute(
"""
UPDATE cx_tower_key_value
SET secret_value = NULL
WHERE secret_value IS NOT NULL
"""
)
_logger.info("Cleared secret value from key values.")

View File

@@ -0,0 +1,37 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import cx_tower_variable_mixin
from . import cx_tower_template_mixin
from . import cx_tower_access_mixin
from . import cx_tower_access_role_mixin
from . import cx_tower_reference_mixin
from . import cx_tower_tag_mixin
from . import cx_tower_key_mixin
from . import cx_tower_vault_mixin
from . import cx_tower_vault
from . import cx_tower_variable
from . import cx_tower_variable_value
from . import cx_tower_file
from . import cx_tower_file_template
from . import cx_tower_server
from . import cx_tower_os
from . import cx_tower_tag
from . import cx_tower_command
from . import cx_tower_custom_variable_value_mixin
from . import cx_tower_key
from . import cx_tower_key_value
from . import cx_tower_command_log
from . import cx_tower_plan
from . import cx_tower_plan_line
from . import cx_tower_plan_line_action
from . import cx_tower_plan_log
from . import cx_tower_server_log
from . import cx_tower_server_template
from . import cx_tower_shortcut
from . import cx_tower_scheduled_task
from . import cx_tower_scheduled_task_cv
from . import cetmix_tower
from . import cx_tower_variable_option
from . import ir_actions_server
from . import res_config_settings
from . import res_partner

View File

@@ -0,0 +1,300 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
import time
from odoo import _, api, models
from odoo.exceptions import ValidationError
from .constants import NOT_FOUND, SSH_CONNECTION_ERROR
_logger = logging.getLogger(__name__)
class CetmixTower(models.AbstractModel):
"""Generic model used to simplify Odoo automation.
Used to keep main integration function in a single place.
For example when writing automated actions one can use
`env["cetmix.tower"].create_server_from_template(..)`
instead of
`env["cx.tower.server.template"].create_server_from_template(..)
"""
_name = "cetmix.tower"
_description = "Cetmix Tower Odoo Automation"
@api.model
def server_create_from_template(self, template_reference, server_name, **kwargs):
"""Shortcut for the same method of the 'cx.tower.server.template' model.
Important! Add dedicated tests for this function if modified later.
"""
return self.env["cx.tower.server.template"].create_server_from_template(
template_reference=template_reference, server_name=server_name, **kwargs
)
@api.model
def server_run_command(
self, server_reference, command_reference, get_result=True, **variable_values
):
"""Run command on selected server.
Args:
server_reference (Char): Server reference
command_reference (Char): Command reference
get_result (bool, optional): Get the result of the command.
If False, the result will be saved to the log.
Defaults to True.
**variable_values:
Dict: with variable values.
The keys are the variable references and the values are the variable values.
eg `{'odoo_version': '16.0'}`
Returns:
Dict: with two keys if `get_result` is True:
- exit_code (Int): Exit code of the command
- message (Char): Message of the command
"""
server = self.env["cx.tower.server"].get_by_reference(server_reference)
if not server:
return {"exit_code": NOT_FOUND, "message": _("Server not found")}
command = self.env["cx.tower.command"].get_by_reference(command_reference)
if not command:
return {"exit_code": NOT_FOUND, "message": _("Command not found")}
# Will return command result if get_result is True
# Otherwise will save to log and return None
command_result = server.with_context(no_command_log=get_result).run_command(
command, **{"variable_values": variable_values} if variable_values else {}
)
# Return command result if get_result is True
if command_result:
status = command_result.get("status")
response = command_result.get("response", "")
error = command_result.get("error", "")
return {
"exit_code": status,
"message": response or error,
}
def server_run_flight_plan(
self, server_reference, flight_plan_reference, **variable_values
):
"""Run flight plan on selected server.
Args:
server_reference (Char): Server reference
flight_plan_reference (Char): Flight plan reference
**variable_values:
Dict: with variable values.
The keys are the variable references and the values are the variable values.
eg `{'odoo_version': '16.0'}`
Returns:
cx.tower.plan.log(): flight plan log record or False if error
"""
server = self.env["cx.tower.server"].get_by_reference(server_reference)
if not server:
# This is not the best way to handle this, but it's the only way to
# avoid complex response handling
return False
flight_plan = self.env["cx.tower.plan"].get_by_reference(flight_plan_reference)
if not flight_plan:
# This is not the best way to handle this, but it's the only way to
# avoid complex response handling
return False
return server.run_flight_plan(
flight_plan,
**{"variable_values": variable_values} if variable_values else {},
)
@api.model
def server_set_variable_value(self, server_reference, variable_reference, value):
"""Set variable value for selected server.
Modifies existing variable value or creates a new one.
Args:
server_reference (Char): Server reference
variable_reference (Char): Variable reference
value (Char): Variable value
Returns:
Dict: with who keys:
- exit_code (Char)
- message (Char)
"""
server = self.env["cx.tower.server"].get_by_reference(server_reference)
if not server:
return {"exit_code": NOT_FOUND, "message": _("Server not found")}
variable = self.env["cx.tower.variable"].get_by_reference(variable_reference)
if not variable:
return {"exit_code": NOT_FOUND, "message": _("Variable not found")}
# Check if variable is already defined for the server
variable_value_record = variable.value_ids.filtered(
lambda v: v.server_id == server
)
if variable_value_record:
variable_value_record.value_char = value
result = {"exit_code": 0, "message": _("Variable value updated")}
else:
self.env["cx.tower.variable.value"].create(
{
"variable_id": variable.id,
"server_id": server.id,
"value_char": value,
}
)
result = {"exit_code": 0, "message": _("Variable value created")}
return result
@api.model
def server_get_variable_value(
self, server_reference, variable_reference, check_global=True
):
"""Get variable value for selected server.
Args:
server_reference (Char): Server reference
variable_reference (Char): Variable reference
check_global (bool, optional): Check for global value if variable
is not defined for selected server. Defaults to True.
Returns:
Char: variable value or None
"""
# Get server by reference
server = self.env["cx.tower.server"].get_by_reference(server_reference)
if not server:
return None
result = self.env["cx.tower.variable.value"].get_by_variable_reference(
variable_reference=variable_reference,
server_id=server.id,
check_global=check_global,
)
# Get server defined value first
value = result.get("server")
# Get global value if value is not set
if not value and check_global:
value = result.get("global")
return value
@api.model
def server_check_ssh_connection(
self,
server_reference,
attempts=5,
wait_time=10,
try_command=True,
try_file=True,
):
"""Check if SSH connection to the server is available.
This method only checks if the connection is available,
it does not execute any commands to check if they are working.
Args:
server_reference (Char): Server reference.
attempts (int): Number of attempts to try the connection.
Default is 5.
wait_time (int): Wait time in seconds between connection attempts.
Default is 10 seconds.
try_command (bool): Try to execute a command.
Default is True.
try_file (bool): Try file operations.
Default is True.
Raises:
ValidationError:
If the provided server reference is invalid or
the server cannot be found.
Returns:
dict: {
"exit_code": int,
0 for success,
error code for failure
"message": str # Description of the result
}
"""
server = self.env["cx.tower.server"].get_by_reference(server_reference)
if not server:
raise ValidationError(_("No server found for the provided reference."))
# Try connecting multiple times
for attempt in range(1, attempts + 1):
try:
_logger.info(
"Attempt %s of %s to connect to server %s",
attempt,
attempts,
server_reference,
)
result = server.test_ssh_connection(
raise_on_error=True,
return_notification=False,
try_command=try_command,
try_file=try_file,
)
if result.get("status") == 0:
return {
"exit_code": 0,
"message": _("Connection successful."),
}
if attempt == attempts:
return {
"exit_code": SSH_CONNECTION_ERROR,
"message": _(
"Failed to connect after %(attempts)s attempts. "
"Error: %(err)s",
attempts=attempts,
err=result.get("error", ""),
),
}
except Exception as e: # pylint: disable=broad-except
if attempt == attempts:
return {
"exit_code": SSH_CONNECTION_ERROR,
"message": _("Failed to connect. Error: %(err)s", err=e),
}
time.sleep(wait_time)
@api.model
def server_validate_secret(
self, secret_value, secret_reference, server_reference=None
):
"""
Validate the provided secret value against the actual secret.
Accepts either a full inline reference (e.g. #!cxtower.secret.<REFERENCE>!#)
or just a <REFERENCE>.
Args:
secret_value (Char): Value to validate
secret_reference (Char): Reference code or inline reference
server_reference (Char, optional): Reference code of the server
Returns:
Bool: True if the value matches the secret, False otherwise
"""
server = self.env["cx.tower.server"]
if server_reference:
server = server.get_by_reference(server_reference)
# Try to extract reference from inline format using _extract_key_parts
key_parts = self.env["cx.tower.key"]._extract_key_parts(secret_reference)
if key_parts:
# _extract_key_parts returns a tuple: (key_type, reference).
# We only need the reference part here.
secret_reference = key_parts[1]
value = self.env["cx.tower.key"]._resolve_key_type_secret(
secret_reference, server_id=server.id
)
return value == secret_value

View File

@@ -0,0 +1,125 @@
from odoo import _
# ***
# This file is used to define commonly used constants
# ***
# Returned when a general error occurs
GENERAL_ERROR = -100
# Returned when a resource is not found
NOT_FOUND = -101
# -- SSH
# Returned when an SSH connection error occurs
SSH_CONNECTION_ERROR = 503
# -- Command: -200 > -299
# Returned when trying to execute another instance of a command on the same server
# and this command doesn't allow parallel run
ANOTHER_COMMAND_RUNNING = -201
# Returned when no runner is found for command action
NO_COMMAND_RUNNER_FOUND = -202
# Returned when the command failed to execute due to a python code execution error
PYTHON_COMMAND_ERROR = -203
# Returned when the command failed to execute because the condition was not met
PLAN_LINE_CONDITION_CHECK_FAILED = -205
# Returned when the command timed out
COMMAND_TIMED_OUT = -206
COMMAND_TIMED_OUT_MESSAGE = _("Command timed out and was terminated")
# Returned when the command is not compatible with the server
COMMAND_NOT_COMPATIBLE_WITH_SERVER = -207
# Returned when the command was stopped by user
COMMAND_STOPPED = -208
# -- Plan: -300 > -399
# Returned when trying to execute another instance of a flightplan on the same server
# and this flightplan doesn't allow parallel run
ANOTHER_PLAN_RUNNING = -301
# Returned when trying to start plan without lines
PLAN_IS_EMPTY = -302
# Returned when a plan tries to parse a command log record which doesn't have
# a valid plan reference in it
PLAN_NOT_ASSIGNED = -303
# Returned when a plan tries to parse a command log record which doesn't have
# a valid plan line reference in it
PLAN_LINE_NOT_ASSIGNED = -304
# Returned when any of the commands in the plan is not compatible with the server
PLAN_NOT_COMPATIBLE_WITH_SERVER = -306
# Returned when the flight plan was stopped by user
PLAN_STOPPED = -308
# -- File: -400 > -499
# Returned when the file could not be created on the server
FILE_CREATION_FAILED = -400
# Returned when the file could not be uploaded to the server
FILE_UPLOAD_FAILED = -401
# Returned when the file could not be downloaded from the server
FILE_DOWNLOAD_FAILED = -402
# -- Default values
# Default Python code used in Python code command
DEFAULT_PYTHON_CODE = _(
"""# Please refer to the 'Help' tab and documentation for more information.
#
# You can return command result in the 'result' variable which is a dictionary:
# result = {"exit_code": 0, "message": "Some message"}
# default value is {"exit_code": 0, "message": None}
""" # noqa: E501
)
# Default Python code help displayed in the "Help" tab
DEFAULT_PYTHON_CODE_HELP = _(
"""
<h3>Help with Python expressions</h3>
<div style="margin-bottom: 10px;">
<p>
Each Python code command returns the <code>result</code> value which is a dictionary.
<br>There are two keys in the dictionary:
<ul>
<li><code>exit_code</code>: Integer. Exit code of the command. "0" means success, any other value means failure. Default value is "0".</li>
<li><code>message</code>: String. Message to be logged. Default value is "None".</li>
</ul>
You can also access the <code>custom_values</code> dictionary that contains custom values provided to the command or flight plan.
Custom values can be modified, thus can be used to pass data between commands in a flight plan.
Please keep in mind that custom values are persistent only between commands in a flight plan and are not saved to the database.
<br/>
Here is an example of a python code command:
<code style='white-space: pre-wrap'>
server_name = server.name
build_name = custom_values.get("build_name")
if build_name:
result = {"exit_code": 0, "message": "Build name for " + server_name + " is " + build_name}
else:
result = {"exit_code": 0, "message": "No build name provided for " + server_name}
custom_values["build_name"] = "New build name"
</code>
</p>
<br>
Please refer to the <a href="https://cetmix.com/tower/documentation/command/#python-code-commands" target="_blank">official documentation</a> for more information and examples.
</div>
<p
>Various fields may use Python code or Python expressions. The
following variables can be used:</p>
""" # noqa: E501
)

View File

@@ -0,0 +1,37 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class CxTowerAccessMixin(models.AbstractModel):
"""Used to implement template access levels in models."""
_name = "cx.tower.access.mixin"
_description = "Cetmix Tower access mixin"
access_level = fields.Selection(
lambda self: self._selection_access_level(),
default=lambda self: self._default_access_level(),
required=True,
index=True,
)
def _selection_access_level(self):
"""Available access levels
Returns:
List of tuples: available options.
"""
return [
("1", "User"),
("2", "Manager"),
("3", "Root"),
]
def _default_access_level(self):
"""Default access level
Returns:
Char: `access_level` field selection value
"""
return "2"

View File

@@ -0,0 +1,99 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class CxTowerAccessRoleMixin(models.AbstractModel):
"""Used to implement access roles in models."""
_name = "cx.tower.access.role.mixin"
_description = "Cetmix Tower access role mixin"
# IMPORTANT: inherit these fields in your model
# add 'relation' key explicitly to the field.
# Use 'cx.tower.server' as model as a reference.
user_ids = fields.Many2many(
comodel_name="res.users",
column1="record_id",
column2="user_id",
string="Users",
domain=lambda self: [
("groups_id", "in", [self.env.ref("cetmix_tower_server.group_user").id])
],
default=lambda self: self._default_user_ids(),
help="Users who can view this record",
copy=False,
)
manager_ids = fields.Many2many(
comodel_name="res.users",
column1="record_id",
column2="manager_id",
string="Managers",
groups="cetmix_tower_server.group_manager",
domain=lambda self: [
("groups_id", "in", [self.env.ref("cetmix_tower_server.group_manager").id])
],
default=lambda self: self._default_manager_ids(),
help="Managers who can modify this record",
copy=False,
)
def _default_user_ids(self):
"""
Default Users for new Records.
"""
# If user is in group_user, add them to the list
if self.env.user.has_group("cetmix_tower_server.group_user"):
return [self.env.user.id]
# Otherwise, return an empty list. Eg if created using sudo()
return []
def _default_manager_ids(self):
"""
Default Managers for new Records.
"""
# If user is manager, add them to the list
if self.env.user.has_group("cetmix_tower_server.group_manager"):
return [self.env.user.id]
# Otherwise, return an empty list. Eg if created using sudo()
return []
@api.model_create_multi
def create(self, vals_list):
"""
Create records with post-create fields.
"""
post_create_fields = self._get_post_create_fields()
post_create_vals_list = []
for vals in vals_list:
post_create_vals = {}
for key in post_create_fields:
if key in vals:
post_create_vals[key] = vals.pop(key)
post_create_vals_list.append(post_create_vals)
# Create records without post-create fields
res = super().create(vals_list)
if post_create_vals_list:
# Create related records with post-create field
for post_create_vals, record in zip(post_create_vals_list, res): # noqa: B905 we need to run on Python 3.10
if post_create_vals:
record.write(post_create_vals)
return res
def _get_post_create_fields(self):
"""
Get post-create fields.
Some records may create related records which use rules
that depend on `user_ids` and `manager_ids` fields.
However at the moment of record creation, these fields are not yet set.
So first we create the record without these fields, then we create
the related records to avoid access violations.
Returns:
list: List of fields to be set after record creation.
"""
return []

View File

@@ -0,0 +1,550 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from types import SimpleNamespace
from dns import exception, resolver, reversename
from pytz import timezone
from odoo import _, api, fields, models, tools
from odoo.exceptions import UserError
from odoo.tools import ormcache
from odoo.tools.float_utils import float_compare
from odoo.tools.safe_eval import wrap_module
from .constants import DEFAULT_PYTHON_CODE, DEFAULT_PYTHON_CODE_HELP
requests = wrap_module(__import__("requests"), ["post", "get", "delete", "request"])
json = wrap_module(__import__("json"), ["dumps"])
hashlib = wrap_module(
__import__("hashlib"),
[
"sha1",
"sha224",
"sha256",
"sha384",
"sha512",
"sha3_224",
"sha3_256",
"sha3_384",
"sha3_512",
"shake_128",
"shake_256",
"blake2b",
"blake2s",
"md5",
"new",
],
)
hmac = wrap_module(
__import__("hmac"),
["new", "compare_digest"],
)
tldextract = wrap_module(__import__("tldextract"), ["extract"])
dns_resolver = wrap_module(resolver, ["resolve", "query"])
dns_reversename = wrap_module(reversename, ["from_address", "to_address"])
dns_exception = wrap_module(exception, ["DNSException"])
dns = SimpleNamespace(
resolver=dns_resolver,
reversename=dns_reversename,
exception=dns_exception,
)
class CxTowerCommand(models.Model):
"""Command to run on a server"""
_name = "cx.tower.command"
_inherit = [
"cx.tower.template.mixin",
"cx.tower.reference.mixin",
"cx.tower.access.mixin",
"cx.tower.access.role.mixin",
"cx.tower.key.mixin",
"cx.tower.tag.mixin",
]
_description = "Cetmix Tower Command"
_order = "name"
active = fields.Boolean(default=True)
allow_parallel_run = fields.Boolean(
help="If enabled, multiple instances of the same command "
"can be run on the same server at the same time.\n"
"Otherwise, ANOTHER_COMMAND_RUNNING status will be returned if another"
" instance of the same command is already running"
)
server_ids = fields.Many2many(
comodel_name="cx.tower.server",
relation="cx_tower_server_command_rel",
column1="command_id",
column2="server_id",
string="Servers",
help="Servers on which the command will be run.\n"
"If empty, command can be run on all servers",
)
tag_ids = fields.Many2many(
relation="cx_tower_command_tag_rel",
column1="command_id",
column2="tag_id",
)
os_ids = fields.Many2many(
comodel_name="cx.tower.os",
relation="cx_tower_os_command_rel",
column1="command_id",
column2="os_id",
string="OSes",
)
note = fields.Text()
action = fields.Selection(
selection=lambda self: self._selection_action(),
required=True,
default=lambda self: self._selection_action()[0][0],
)
path = fields.Char(
string="Default Path",
help="Location where command will be run. "
"You can use {{ variables }} in path",
)
file_template_id = fields.Many2one(
comodel_name="cx.tower.file.template",
help="This template will be used to create or update the pushed file",
)
template_code = fields.Text(
string="Template Code",
related="file_template_id.code",
readonly=True,
help="Code of the associated file template",
)
flight_plan_line_ids = fields.One2many(
comodel_name="cx.tower.plan.line",
related="flight_plan_id.line_ids",
readonly=True,
help="Lines of the associated flight plan",
)
code = fields.Text(
compute="_compute_code",
store=True,
readonly=False,
)
command_help = fields.Html(
compute="_compute_command_help",
compute_sudo=True,
)
flight_plan_id = fields.Many2one(
comodel_name="cx.tower.plan",
help="Flight plan run by the command",
)
flight_plan_used_ids = fields.Many2many(
comodel_name="cx.tower.plan",
help="Flight plan this command is used in",
relation="cx_tower_command_flight_plan_used_id_rel",
column1="command_id",
column2="plan_id",
store=True,
copy=False,
)
flight_plan_used_ids_count = fields.Integer(
compute="_compute_flight_plan_used_ids_count",
help="Flight plan this command is used in",
)
server_status = fields.Selection(
selection=lambda self: self.env["cx.tower.server"]._selection_status(),
help="Set the following status if command finishes with success. "
"Leave 'Undefined' if you don't need to update the status",
)
no_split_for_sudo = fields.Boolean(
string="No Split for sudo",
help="If enabled, do not split command on '&&' when using sudo."
"Prepend sudo once to the whole command.",
)
variable_ids = fields.Many2many(
comodel_name="cx.tower.variable",
relation="cx_tower_command_variable_rel",
column1="command_id",
column2="variable_id",
)
if_file_exists = fields.Selection(
selection=[
("skip", "Skip"),
("overwrite", "Overwrite"),
("raise", "Raise Error"),
],
default="skip",
help="What to do if file already exists on the server.\n"
"- Skip: Do not create or update the file.\n"
"- Overwrite: Replace the existing file with the new one.\n"
"- Raise Error: Raise an error if the file already exists.",
)
disconnect_file = fields.Boolean(
string="Disconnect from Template",
help=(
"If enabled, disconnects the file from its template "
"after running the command.\n"
),
)
# ---- Access. Add relation for mixin fields
user_ids = fields.Many2many(
relation="cx_tower_command_user_rel",
)
manager_ids = fields.Many2many(
relation="cx_tower_command_manager_rel",
)
@classmethod
def _get_depends_fields(cls):
"""
Define dependent fields for computing `variable_ids` in command-related models.
This implementation specifies that the fields `code` and `path`
are used to determine the variables associated with a command.
Returns:
list: A list of field names (str) representing the dependencies.
Example:
The following fields trigger recomputation of `variable_ids`:
- `code`: The command's script or running logic.
- `path`: The default running path for the command.
"""
return ["code", "path"]
# -- Selection
def _selection_action(self):
"""Actions that can be run by a command.
Returns:
List of tuples: available options.
"""
return [
("ssh_command", "SSH command"),
("python_code", "Run Python code"),
("file_using_template", "Create file using template"),
("plan", "Run flight plan"),
]
# -- Defaults
def _get_default_python_code(self):
"""
Default python command code
"""
return DEFAULT_PYTHON_CODE
def _get_default_python_code_help(self):
"""
Default python code help
"""
# Available libraries are Odoo objects + Python libraries
available_libraries = self._get_python_command_odoo_objects()
available_libraries.update(self._get_python_command_libraries())
help_text_fragments = []
for key, value in available_libraries.items():
help_text_fragments.append(f"<li><code>{key}</code>: {value['help']}</li>")
help_text_fragments.append(
f"<li><code>custom_values</code>: {_('Flight plan custom values')}</li>"
)
help_text = "<ul>" + "".join(help_text_fragments) + "</ul>"
return f"{DEFAULT_PYTHON_CODE_HELP}{help_text}"
# -- Computes
@api.depends("action")
def _compute_code(self):
"""
Compute default code
"""
default_python_code = self._get_default_python_code()
for command in self:
if command.action == "python_code":
command.code = default_python_code
continue
command.code = False
@api.depends("action")
def _compute_command_help(self):
"""
Compute command help
"""
default_python_code_help = self._get_default_python_code_help()
for command in self:
if command.action == "python_code":
command.command_help = default_python_code_help
else:
command.command_help = False
@api.depends("flight_plan_used_ids")
def _compute_flight_plan_used_ids_count(self):
"""
Compute flight plan ids count
"""
for command in self:
command.flight_plan_used_ids_count = len(command.flight_plan_used_ids)
def action_open_command_logs(self):
"""
Open current current command log records
"""
action = self.env["ir.actions.actions"]._for_xml_id(
"cetmix_tower_server.action_cx_tower_command_log"
)
action["domain"] = [("command_id", "=", self.id)]
return action
def action_open_plans(self):
"""
Open plans this command is used in
"""
action = self.env["ir.actions.actions"]._for_xml_id(
"cetmix_tower_server.action_cx_tower_plan"
)
action["domain"] = [("id", "in", self.flight_plan_used_ids.ids)]
return action
def _check_server_compatibility(self, server):
"""Check if the command is compatible with the server
Args:
server (cx.tower.server()): Server object
Returns:
bool: True if the command is compatible with the server, False otherwise
"""
self.ensure_one()
return not self.server_ids or server.id in self.server_ids.ids
# -- Business logic
@ormcache()
@api.model
def _get_python_command_libraries(self):
"""
Get available python imports. Use this method to import python libraries.
Please be advised, that this method is cached.
If you need to use a non-cached import, eg for Odoo objects,
use the `_get_python_command_odoo_objects` method instead.
Returns:
dict: Available libraries:
{"<library_name>": {
"import": <library_import>,
"help": <library_help_html>
}}
"""
python_libraries = {
"time": {
"import": tools.safe_eval.time,
"help": _("Python 'time' library"),
},
"datetime": {
"import": tools.safe_eval.datetime,
"help": _("Python 'datetime' library"),
},
"dateutil": {
"import": tools.safe_eval.dateutil,
"help": _("Python 'dateutil' library"),
},
"timezone": {
"import": timezone,
"help": _("Python 'timezone' library"),
},
"requests": {
"import": requests,
"help": _(
"Python 'requests' library. Available methods: 'post', 'get',"
" 'delete', 'request'"
),
},
"json": {
"import": json,
"help": _("Python 'json' library. Available methods: 'dumps'"),
},
"float_compare": {
"import": float_compare,
"help": _("Float compare. Odoo helper function to compare floats."),
},
"UserError": {
"import": UserError,
"help": _("UserError. Helper to raise UserError."),
},
"hashlib": {
"import": hashlib,
"help": _(
"Python 'hashlib' library. "
"<a href='https://docs.python.org/3/library/hashlib.html'"
" target='_blank'>Documentation</a>. "
"Available methods: 'sha1', 'sha224', "
"'sha256', 'sha384',"
" 'sha512', 'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512', "
"'shake_128', 'shake_256',"
" 'blake2b', 'blake2s', 'md5', 'new'"
),
},
"hmac": {
"import": hmac,
"help": _(
"Python 'hmac' library. "
"<a href='https://docs.python.org/3/library/hmac.html'"
" target='_blank'>Documentation</a>. "
"Use 'new' to create HMAC objects. "
"Available methods on the HMAC *object*: 'update', 'copy',"
" 'digest', 'hexdigest'. "
" Module-level function: 'compare_digest'."
),
},
"tldextract": {
"import": tldextract,
"help": _(
"Python 'tldextract' library. Use "
"<code>tldextract.extract()</code> to parse domains. "
"Check <a href='https://github.com/john-kurkowski/tldextract'"
" target='_blank'>tldextract</a> for more information."
),
},
"dns": {
"import": dns,
"help": _(
"Python 'dnspython' library. "
"<a href='https://dnspython.readthedocs.io'"
" target='_blank'>Documentation</a>."
"<ul><li><code>dns.resolver</code>: "
"wrapped dnspython. Use "
'<code>dns.resolver.resolve(hostname, "A")</code> for '
"DNS lookups.</li>"
"<li><code>dns.reversename</code>: wrapped dnspython. "
'Use <code>dns.reversename.from_address("8.8.8.8")</code>'
" to build and reverse PTR records.</li>"
"<li><code>dns.exception</code>: wrapped dnspython. "
"Catch "
"<code>dns.exception.DNSException</code> to handle "
"DNS-related errors.</li>"
"</ul>"
),
},
}
custom_python_libraries = self._custom_python_libraries()
for libraries in custom_python_libraries.values():
python_libraries.update(libraries)
return python_libraries
def _get_python_command_odoo_objects(self, server=None):
"""
This method is used to import Odoo objects.
Because Odoo objects can be records, this method is not cached.
Use this method to import Odoo objects that are not cached.
If you need to import some static objects, use the
`_get_python_command_libraries` method instead.
Args:
server: Server to get the Odoo objects for.
Returns:
dict: Available Odoo objects:
{"<object_name>": {
"import": <object_import>,
"help": <object_help_html>
}}
"""
return {
"uid": {"import": self._uid, "help": _("Current Odoo user ID")},
"user": {"import": self.env.user, "help": _("Current Odoo user")},
"env": {"import": self.env, "help": _("Odoo Environment")},
"server": {
"import": server,
"help": _("Current Cetmix Tower server this command is running on"),
},
"tower": {
"import": self.env["cetmix.tower"],
"help": _(
"Cetmix Tower "
"<a href='https://cetmix.com/tower/documentation/odoo_automation'"
" target='_blank'>helper class</a> shortcut"
),
},
"tower_servers": {
"import": self.env["cx.tower.server"],
"help": _("A helper shortcut to <code>env['cx.tower.server']</code>"),
},
"tower_commands": {
"import": self.env["cx.tower.command"],
"help": _("A helper shortcut to <code>env['cx.tower.command']</code>"),
},
"tower_plans": {
"import": self.env["cx.tower.plan"],
"help": _("A helper shortcut to <code>env['cx.tower.plan']</code>"),
},
}
def _custom_python_libraries(self):
"""
This function is designed to be used in custom modules
extending Cetmix Tower to add custom python libraries
to the evaluation context.
Returns:
Dict: Custom python libraries.
The following format is used:
{
<module_name>: {"<library_name>": {
"import": <library_import>,
"help": <library_help_html>
}
}
Where:
<module_name> Odoo module technical name.
<library_name> is the name of the library how it will be used in the code.
<library_import> is the library to import.
<library_help_html> is the help text for the library shown in the "Help" tab.
Example:
```python
# Custom module extending Cetmix Tower
custom_python_libraries = super()._custom_python_libraries()
custom_python_libraries.update({
"cetmix_tower_aws": {
"boto3": {
"import": boto3,
"help": "Python 'boto3' library. "
"<a href='https://boto3.amazonaws.com/v1/documentation/api/latest/index.html'"
" target='_blank'>Documentation</a>."
},
"custom_library_name": {
"import": custom_library_import,
"help": "Custom library help text"
}
}
})
return custom_python_libraries
```
"""
return {}
def _get_python_command_eval_context(self, server=None, **kwargs):
"""
Get the evaluation context for the python command.
This method is used to get the evaluation context for the python command.
Args:
server: Server to get the evaluation context for.
Returns:
dict: Evaluation context for the python command.
"""
# Get the Odoo objects first
imports = self._get_python_command_odoo_objects(server=server)
# Update with the libraries
imports.update(self._get_python_command_libraries())
eval_context = {key: value["import"] for key, value in imports.items()}
eval_context["custom_values"] = kwargs.get("variable_values", {})
return eval_context

View File

@@ -0,0 +1,369 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from ansi2html import Ansi2HTMLConverter
from odoo import _, api, fields, models
from .constants import COMMAND_STOPPED, GENERAL_ERROR
html_converter = Ansi2HTMLConverter(inline=True)
_logger = logging.getLogger(__name__)
class CxTowerCommandLog(models.Model):
"""Command execution log"""
_name = "cx.tower.command.log"
_description = "Cetmix Tower Command Log"
_order = "start_date desc, id desc"
active = fields.Boolean(default=True)
name = fields.Char(compute="_compute_name", store=True)
label = fields.Char(
help="Custom label. Can be used for search/tracking",
index="trigram",
unaccent=False,
)
server_id = fields.Many2one(
comodel_name="cx.tower.server", required=True, index=True, ondelete="cascade"
)
# -- Time
start_date = fields.Datetime(string="Started")
finish_date = fields.Datetime(string="Finished")
duration = fields.Float(
help="Time consumed for execution, seconds",
compute="_compute_duration",
store=True,
)
duration_current = fields.Float(
string="Duration, sec",
compute="_compute_duration_current",
compute_sudo=True,
help="For how long a flight plan is already running",
)
# -- Command
is_running = fields.Boolean(
help="Command is being executed right now",
compute="_compute_duration",
store=True,
)
command_id = fields.Many2one(
comodel_name="cx.tower.command", required=True, index=True, ondelete="restrict"
)
access_level = fields.Selection(
related="command_id.access_level",
readonly=True,
store=True,
index=True,
)
command_action = fields.Selection(related="command_id.action", store=True)
path = fields.Char(string="Execution Path", help="Where command was executed")
code = fields.Text(string="Command Code", help="Command code that was executed")
command_status = fields.Integer(
string="Exit Code",
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"
"-206 command timed out,\n"
"-207 command is not compatible with server,\n"
"-208 command is stopped by user,\n"
"503 if SSH connection error occurred",
)
command_response = fields.Text(string="Response")
command_error = fields.Text(string="Error")
command_result_html = fields.Html(
compute="_compute_command_result_html",
help="Result converted to HTML. Used for SSH commands.",
)
use_sudo = fields.Selection(
string="Use sudo",
selection=[("n", "Without password"), ("p", "With password")],
help="Run commands using 'sudo'",
)
condition = fields.Char(
readonly=True,
)
is_skipped = fields.Boolean(
readonly=True,
)
# -- Flight Plan
plan_log_id = fields.Many2one(comodel_name="cx.tower.plan.log", ondelete="cascade")
triggered_plan_log_id = fields.Many2one(comodel_name="cx.tower.plan.log")
triggered_plan_command_log_ids = fields.One2many(
comodel_name="cx.tower.command.log",
inverse_name="plan_log_id",
related="triggered_plan_log_id.command_log_ids",
readonly=True,
string="Triggered Flight Plan Commands",
)
scheduled_task_id = fields.Many2one(
"cx.tower.scheduled.task",
ondelete="set null",
help="Scheduled task that triggered this command",
)
variable_values = fields.Json(
default={},
help="Custom variable values passed to the command",
)
@api.depends("name", "command_id.name")
def _compute_name(self):
for rec in self:
rec.name = ": ".join((rec.server_id.name, rec.command_id.name)) # type: ignore
@api.depends("start_date", "finish_date")
def _compute_duration(self):
for command_log in self:
if not command_log.start_date:
command_log.is_running = False
continue
if not command_log.finish_date:
command_log.is_running = True
continue
duration = (
command_log.finish_date - command_log.start_date
).total_seconds()
command_log.update(
{
"duration": duration,
"is_running": False,
}
)
@api.depends("is_running")
def _compute_duration_current(self):
"""Shows relative time between now() and start time for running commands,
and computed duration for finished ones.
"""
now = fields.Datetime.now()
for command_log in self:
if command_log.is_running:
command_log.duration_current = (
now - command_log.start_date
).total_seconds()
else:
command_log.duration_current = command_log.duration
@api.depends("command_response", "command_error")
def _compute_command_result_html(self):
for command_log in self:
command_result = command_log.command_response or command_log.command_error
if command_result:
try:
command_log.command_result_html = html_converter.convert(
command_result
)
except Exception as e:
_logger.error("Error converting command response to HTML: %s", e)
command_log.command_result_html = _(
"<p><strong>Error converting command"
" response to HTML: %(error)s</strong></p>",
error=e,
)
else:
command_log.command_result_html = False
def start(self, server_id, command_id, start_date=None, **kwargs):
"""Creates initial log record when command is started
Args:
server_id (int) id of the server.
command_id (int) id of the command.
start_date (datetime) command start date time.
**kwargs (dict): optional values
Returns:
(cx.tower.command.log()) new command log record or False
"""
vals = {
"server_id": server_id,
"command_id": command_id,
"start_date": start_date if start_date else fields.Datetime.now(),
}
# Apply kwargs
vals.update(kwargs)
log_record = self.sudo().create(vals)
return log_record
def stop(self):
"""
Stop the command execution.
"""
user_name = self.env.user.name
for log in self:
if not log.is_running:
continue
log.finish(
status=COMMAND_STOPPED,
error=_("Stopped by user %(user)s", user=user_name),
)
# Ensure flight plan log is stopped too
if log.plan_log_id and log.plan_log_id.is_running:
log.plan_log_id.stop()
def finish(
self, finish_date=None, status=None, response=None, error=None, **kwargs
):
"""Save final command result when command is finished.
This method can be called for multiple command logs at once.
Args:
finish_date (datetime) command finish date time.
status (int, optional): command execution status. Defaults to None.
response (Char, optional): Command response. Defaults to None.
error (Char, optional): Command error. Defaults to None.
**kwargs (dict): optional values
"""
self_with_sudo = self.sudo()
# Duration
now = fields.Datetime.now()
date_finish = finish_date if finish_date else now
vals = {
"finish_date": date_finish,
"command_status": GENERAL_ERROR if status is None else status,
"command_response": response,
"command_error": error,
}
# Apply kwargs and write
vals.update(kwargs)
self_with_sudo.write(vals)
# Trigger post finish hook
for command_log in self_with_sudo:
command_log._command_finished()
def record(
self,
server_id,
command_id,
start_date=None,
finish_date=None,
status=0,
response=None,
error=None,
**kwargs,
):
"""Record completed command directly without using start/stop
Args:
server_id (int) id of the server.
command_id (int) id of the command.
start_date (datetime) command start date time.
finish_date (datetime) command finish date time.
status (int, optional): command execution status. Defaults to 0.
response (list, optional): SSH response. Defaults to None.
error (list, optional): SSH error. Defaults to None.
**kwargs (dict): values to store
Returns:
(cx.tower.command.log()) new command log record
"""
vals = kwargs or {}
now = fields.Datetime.now()
vals.update(
{
"server_id": server_id,
"command_id": command_id,
"start_date": start_date or now,
"finish_date": finish_date or now,
"command_status": status,
"command_response": response,
"command_error": error,
}
)
rec = self.sudo().create(vals)
rec._command_finished()
return rec
def _command_finished(self):
"""Triggered when command is finished
Inherit to implement your own hooks
Returns:
bool: True if event was handled
"""
self.ensure_one()
# Do not notify if command is run from a Flight Plan.
if self.plan_log_id: # type: ignore
self.plan_log_id._plan_command_finished(self) # type: ignore
return True
# Check if notifications are enabled
ICP_sudo = self.env["ir.config_parameter"].sudo()
notification_type_success = ICP_sudo.get_param(
"cetmix_tower_server.notification_type_success"
)
notification_type_error = ICP_sudo.get_param(
"cetmix_tower_server.notification_type_error"
)
# Prepare notifications
if not notification_type_success and not notification_type_error:
return True
# Use context timestamp to avoid timezone issues
context_timestamp = fields.Datetime.context_timestamp(
self, fields.Datetime.now()
)
# Action for button
action = self.env["ir.actions.act_window"]._for_xml_id(
"cetmix_tower_server.action_cx_tower_command_log"
)
context = self.env.context.copy()
params = dict(context.get("params") or {})
params["button_name"] = _("View Log")
context["params"] = params
action.update(
{
"views": [(False, "form")],
"context": context,
"res_id": self.id,
}
)
# Send notification
if self.command_status == 0 and notification_type_success:
# Success notification
self.create_uid.notify_success(
message=_(
"%(timestamp)s<br/>" "Command '%(name)s' finished successfully",
name=self.command_id.name,
timestamp=context_timestamp,
),
title=self.server_id.name,
sticky=notification_type_success == "sticky",
action=action,
)
# Error notification
if self.command_status != 0 and notification_type_error:
self.create_uid.notify_danger(
message=_(
"%(timestamp)s<br/>" "Command '%(name)s' finished with error",
name=self.command_id.name,
timestamp=context_timestamp,
),
title=self.server_id.name,
sticky=notification_type_error == "sticky",
action=action,
)
return True

View File

@@ -0,0 +1,52 @@
from odoo import api, fields, models
class CxTowerCustomVariableValueMixin(models.AbstractModel):
"""
Custom variable values.
"""
_name = "cx.tower.custom.variable.value.mixin"
_description = "Custom variable values"
variable_id = fields.Many2one(
"cx.tower.variable",
)
variable_type = fields.Selection(related="variable_id.variable_type", readonly=True)
value_char = fields.Char(
string="Value",
compute="_compute_value_char",
readonly=False,
store=True,
help="Automatically populated from selected option. "
"Manual edits will be overwritten when option changes.",
)
option_id = fields.Many2one(
"cx.tower.variable.option", domain="[('variable_id', '=', variable_id)]"
)
variable_value_id = fields.Many2one("cx.tower.variable.value")
required = fields.Boolean(
related="variable_value_id.required",
readonly=True,
store=True,
)
@api.depends("option_id", "variable_id", "variable_type")
def _compute_value_char(self):
"""
Compute value_char based on selected option for option-type variables.
For non-option variables, value_char is cleared to allow manual input.
"""
for rec in self:
if rec.variable_id and rec.variable_type == "o" and rec.option_id:
rec.value_char = rec.option_id.value_char
else:
rec.value_char = ""
@api.onchange("variable_id")
def _onchange_variable_id(self):
"""
Reset option_id when variable changes.
"""
self.update({"option_id": None})

View File

@@ -0,0 +1,741 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from base64 import b64decode, b64encode
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.tools import exception_to_unicode
# mapping of field names from template and field names from file
TEMPLATE_FILE_FIELD_MAPPING = {
"code": "code",
"file_name": "name",
"file_type": "file_type",
"server_dir": "server_dir",
"keep_when_deleted": "keep_when_deleted",
"auto_sync": "auto_sync",
}
# to convert to 'relativedelta' object
INTERVAL_TYPES = {
"minutes": lambda interval: relativedelta(minutes=interval),
"hours": lambda interval: relativedelta(hours=interval),
"days": lambda interval: relativedelta(days=interval),
"weeks": lambda interval: relativedelta(days=7 * interval),
"months": lambda interval: relativedelta(months=interval),
"years": lambda interval: relativedelta(years=interval),
}
class CxTowerFile(models.Model):
"""Files"""
_name = "cx.tower.file"
_inherit = [
"cx.tower.template.mixin",
"cx.tower.reference.mixin",
"mail.thread",
"mail.activity.mixin",
"cx.tower.key.mixin",
]
_description = "Cetmix Tower File"
_order = "name"
active = fields.Boolean(default=True)
name = fields.Char(help="File name WITHOUT path. Eg 'test.txt'")
rendered_name = fields.Char(
compute="_compute_render",
compute_sudo=True,
)
template_id = fields.Many2one(
"cx.tower.file.template",
inverse="_inverse_template_id",
index=True,
)
server_dir = fields.Char(
string="Directory on Server",
required=True,
default="",
help="Eg '/home/user' or '/var/log'",
)
rendered_server_dir = fields.Char(
compute="_compute_render",
compute_sudo=True,
)
full_server_path = fields.Char(
string="Full Path",
compute="_compute_render",
compute_sudo=True,
)
source = fields.Selection(
[
("tower", "Tower"),
("server", "Server"),
],
help="""
- Tower: file is pushed from Tower to server.
- Server: file is pulled from server to Tower.
""",
)
auto_sync = fields.Boolean(
help="If enabled file will be synced automatically using cron",
default=False,
)
# selection format: interval_number(integer)-interval_type(name of interval)
# it will be parsed as 'relativedelta' object
auto_sync_interval = fields.Selection(
selection=lambda self: self._selection_auto_sync_interval(),
)
sync_date_next = fields.Datetime(
string="Next Sync Date",
required=True,
default=fields.Datetime.now,
help="Date and time of the next synchronisation",
)
sync_date_last = fields.Datetime(
string="Last Sync Date",
readonly=True,
tracking=True,
help="Date and time of the latest successful synchronisation",
)
server_response = fields.Text(
copy=False,
help="Server response received during the last operation.\n"
"Default value if no error happened is 'ok'.\n"
"Otherwise there will be a server error message logged.",
)
server_id = fields.Many2one(
comodel_name="cx.tower.server", required=False, ondelete="cascade"
)
code_on_server = fields.Text(
readonly=True,
help="Latest version of file content on server",
)
rendered_code = fields.Char(
compute="_compute_render",
compute_sudo=True,
help="File content with variables rendered",
)
keep_when_deleted = fields.Boolean(
help="File will be kept on server when deleted in Tower",
)
file_type = fields.Selection(
selection=lambda self: self._selection_file_type(),
default=lambda self: self._default_file_type(),
required=True,
)
file = fields.Binary(
string="Binary Content",
attachment=True,
)
variable_ids = fields.Many2many(
comodel_name="cx.tower.variable",
relation="cx_tower_file_variable_rel",
column1="file_id",
column2="variable_id",
)
@classmethod
def _get_depends_fields(cls):
"""
Define dependent fields for computing `variable_ids` in file-related models.
This implementation specifies that the fields `code`, `server_dir`,
and `name` are used to compute the variables associated with a file.
Returns:
list: A list of field names (str) representing the dependencies.
Example:
The following fields trigger recomputation of `variable_ids`:
- `code`: The content of the file.
- `server_dir`: The directory on the server where the file is located.
- `name`: The name of the file.
"""
return ["code", "server_dir", "name"]
# -- Selection
def _selection_file_type(self):
"""Available file types
Returns:
List of tuples: available options.
"""
return [
("text", "Text"),
("binary", "Binary"),
]
def _selection_auto_sync_interval(self):
"""
Selection of auto sync interval
"""
return [
("10-minutes", "10 min"),
("30-minutes", "30 min"),
("1-hours", "1 hour"),
("2-hours", "2 hour"),
("6-hours", "6 hour"),
("12-hours", "12 hour"),
("1-days", "1 day"),
("1-weeks", "1 week"),
("1-months", "1 month"),
("1-years", "1 year"),
]
# -- Defaults
def _default_file_type(self):
"""Default file type
Returns:
Char: `file_type` field selection value
"""
return "text"
# -- Computes
@api.depends("server_id", "template_id", "name", "server_dir", "code")
def _compute_render(self):
"""
Compute file name, directory and code
"""
for file in self:
if not file.server_id:
file.update(
{
"rendered_name": False,
"rendered_server_dir": False,
"rendered_code": False,
"full_server_path": False,
}
)
continue
variables = list(
set(
file.get_variables_from_code(file.name)
+ file.get_variables_from_code(file.server_dir)
+ file.get_variables_from_code(file.code)
)
)
render_code_custom = file.render_code_custom
var_vals = file.server_id.get_variable_values(variables).get(
file.server_id.id
)
rendered_code = ""
if file.file_type == "text" and file.source == "tower":
rendered_code = (
var_vals
and file.code
and render_code_custom(file.code, **var_vals)
or file.code
)
rendered_name = (
var_vals
and file.name
and render_code_custom(file.name, **var_vals)
or file.name
)
rendered_server_dir = (
var_vals
and file.server_dir
and render_code_custom(file.server_dir, **var_vals)
or file.server_dir
)
file.update(
{
"rendered_name": rendered_name,
"rendered_server_dir": rendered_server_dir,
"rendered_code": rendered_code,
"full_server_path": f"{rendered_server_dir}/{rendered_name}",
}
)
# -- Onchange
@api.onchange("template_id")
def _onchange_template_id(self):
"""
Update file data by template values
"""
for file in self:
if file.template_id:
file.update(file._get_file_values_from_related_template())
@api.onchange("source")
def _onchange_source(self):
"""
Reset file template after change source
"""
self.update({"template_id": False})
def _inverse_template_id(self):
"""
Replace file fields values by template values
"""
for file in self:
if file.template_id:
file.write(file._get_file_values_from_related_template())
# -- Create/Write/Unlink
@api.model_create_multi
def create(self, vals_list):
"""
Override to sync files
"""
vals_list = [self._sanitize_values(vals) for vals in vals_list]
records = super().create(vals_list)
records._post_create_write("create")
return records
def write(self, vals):
"""
Override to sync files from tower
"""
vals = self._sanitize_values(vals)
result = super().write(vals)
# sync tower files after change
sync_fields = self._get_tower_sync_field_names()
files_to_sync = self.filtered(
lambda file: file.auto_sync
and file.source == "tower"
and any(field in vals for field in sync_fields)
)
if files_to_sync:
files_to_sync._post_create_write("write")
return result
def unlink(self):
"""
Override to delete from server tower files with
`keep_when_deleted` set to False
"""
self.filtered(
lambda file_: (
file_.server_id
and file_.source == "tower"
and not file_.keep_when_deleted
)
).delete()
return super().unlink()
# -- Actions
def action_unlink_from_template(self):
"""
Unlink file from template to make it editable
"""
self.ensure_one()
self.template_id = False
def action_push_to_server(self):
"""
Push the file to server
"""
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=server_files[0].rendered_name,
),
"sticky": False,
},
}
self.upload(raise_error=True)
single_msg = _("File uploaded!")
plural_msg = _("Files uploaded!")
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Success"),
"message": single_msg if len(self) == 1 else plural_msg,
"sticky": False,
},
}
def action_pull_from_server(self):
"""
Pull file from server
"""
tower_files = self.filtered(lambda file_: file_.source == "tower")
server_files = self - tower_files
tower_files.action_get_current_server_code()
res = server_files.download(raise_error=True)
if isinstance(res, dict):
return res
single_msg = _("File downloaded!")
plural_msg = _("Files downloaded!")
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Success"),
"message": single_msg if len(self) == 1 else plural_msg,
"sticky": False,
},
}
def action_delete_from_server(self):
"""
Delete file from server
"""
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 delete file '%(f)s'.\n"
"Delete operation is not supported for 'server' type files.",
f=server_files[0].rendered_name,
),
"sticky": False,
},
}
self.delete(raise_error=True)
single_msg = _("File deleted!")
plural_msg = _("Files deleted!")
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Success"),
"message": single_msg if len(self) == 1 else plural_msg,
"sticky": False,
},
}
def action_get_current_server_code(self):
"""
Get actual file code from server
"""
for file in self:
if file.source != "tower":
raise UserError(
_(
"File %(f)s is not 'tower' type. "
"This operation is supported for 'tower' "
"files only",
f=file.name,
)
)
# Calling `_process` directly to get server version of a `tower` file
res = self.with_context(is_server_code_version_process=True)._process(
"download"
)
# Type check because _process method could return
# a display_notification action dict
if isinstance(res, dict):
return res
file.code_on_server = res
# -- Business logic
def _post_create_write(self, op_type="write"):
"""Helper function that is called after file creation or update.
Use this function to implement custom hooks.
Args:
op_type (str, optional): Operation type. Defaults to "write".
Possible options:
- "create"
- "write"
"""
# Pull all `auto_sync` server files
server_files_to_sync = self.filtered(
lambda file: file.auto_sync and file.source == "server"
)
if server_files_to_sync:
server_files_to_sync.action_pull_from_server()
# Push all `auto_sync` tower files
tower_files_to_sync = self.filtered(
lambda file: file.auto_sync and file.source == "tower"
)
if tower_files_to_sync:
tower_files_to_sync.action_push_to_server()
def _get_file_values_from_related_template(self):
"""
Return file values from related template
"""
self.ensure_one()
if not self.template_id:
return {}
values = self.template_id.read(list(TEMPLATE_FILE_FIELD_MAPPING), load=False)[0]
if (
self.env.context.get("is_custom_server_dir")
and self.server_dir
and "server_dir" in values
):
del values["server_dir"]
return {
key: values[name]
for name, key in TEMPLATE_FILE_FIELD_MAPPING.items()
if name in values
}
@api.model
def _sanitize_values(self, values):
"""
Check the values and reformat if necessary
"""
if "server_dir" in values:
server_dir = values["server_dir"].strip()
if server_dir.endswith("/") and server_dir != "/":
server_dir = server_dir[:-1]
values.update(
{
"server_dir": server_dir,
}
)
return values
def download(self, raise_error=False):
"""Wrapper function for file download.
Use it for custom hooks implementation.
Args:
raise_error (bool, optional):
Will raise and exception on error if set to 'True'.
Defaults to False.
"""
return self._process("download", raise_error)
def upload(self, raise_error=False):
"""Wrapper function for file upload.
Use it for custom hooks implementation.
Args:
raise_error (bool, optional):
Will raise and exception on error if set to 'True'.
Defaults to False.
"""
self._process("upload", raise_error)
def delete(self, raise_error=False):
"""Wrapper function for file removal.
Use it for custom hooks implementation.
Args:
raise_error (bool, optional):
Will raise and exception on error if set to 'True'.
Defaults to False.
"""
self._process("delete", raise_error)
def _process_download(
self,
tower_key_obj,
is_server_code_version_process=False,
):
"""
Processing of file download.
Note: moved this functionality to a separate function from
the general `_process` method because it is already too complex.
Args:
tower_key_obj (RecordSet): `cx.tower.key`
recordset to parse file path.
is_server_code_version_process (bool):
Flag to fetch actual file content from server
for a `tower` type file.
Returns:
[dict|str|None]:
display_notification action dict if there was an error
during the operation.
file content if `is_server_code_version_process` is True.
None otherwise.
"""
self.ensure_one()
code = self.server_id.download_file(
tower_key_obj._parse_code(self.full_server_path),
)
if self.file_type == "text" and b"\x00" in code:
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Failure"),
"message": _(
"Cannot download %(f)s from server: "
"Binary content is not supported "
"for 'Text' file type",
)
% {"f": self.rendered_name},
"sticky": True,
},
}
# In case server version of a 'tower' file is requested
if is_server_code_version_process:
return code
if self.file_type == "binary":
self.file = b64encode(code)
else:
self.code = code
def _process(self, action, raise_error=False):
"""Upload or download file to/from server.
Important!
This function will return a value only in case `is_server_code_version_process`
key is present in context.
This key is used to fetch actual file content from server
for a `tower` type file.
In all other cases it will update the file content and save
server response into the `server_response` field.
Args:
action (Selection): Action to process.
Possible options:
- "upload": Upload file.
- "download": Download file.
- "delete": Delete file.
raise_error (bool, optional): Raise exception if there was an error
during the operation. Defaults to False.
Raises:
UserError: In case file format doesn't match the requested operation.
Eg if trying to upload 'server' type file.
ValidationError: In case there is an error while performing
an action with a file.
Returns:
Char: file content or False.
"""
tower_key_obj = self.env["cx.tower.key"]
is_server_code_version_process = self.env.context.get(
"is_server_code_version_process"
)
for file in self:
if not is_server_code_version_process and (
(action == "download" and file.source != "server")
or (action == "upload" and file.source != "tower")
or (action == "delete" and file.source != "tower")
):
if raise_error:
raise UserError(
_(
"File %(f)s shouldn't have the '%(src)s' source "
" for the '%(act)s' action",
f=file.name,
src=file.source,
act=action,
)
)
return False
if action == "delete":
try:
file.check_access_rights("unlink")
file.check_access_rule("unlink")
except AccessError as e:
if raise_error:
raise AccessError(
_(
"Due to security restrictions you are "
"not allowed to delete %(fp)s",
fp=file.full_server_path,
)
) from e
return False
try:
if action == "download":
res = file._process_download(
tower_key_obj, is_server_code_version_process
)
if res:
return res
elif action == "upload":
if file.file_type == "binary":
file_content = b64decode(file.file)
else:
file_content = tower_key_obj._parse_code(file.rendered_code)
file.server_id.upload_file(
file_content,
tower_key_obj._parse_code(file.full_server_path),
)
elif action == "delete":
file.server_id.delete_file(
tower_key_obj._parse_code(file.full_server_path)
)
else:
return False
file.sudo().server_response = "ok"
except Exception as error:
if raise_error:
raise ValidationError(
_(
"Cannot pull %(f)s from server: %(err)s",
f=file.rendered_name,
err=exception_to_unicode(error),
)
) from error
file.server_response = repr(error)
if not is_server_code_version_process:
self._update_file_sync_date(fields.Datetime.now())
@api.model
def _get_tower_sync_field_names(self):
"""
Return the list of field names to start synchronization
after changing these fields
"""
return ["name", "server_dir", "code"]
@api.model
def _run_auto_pull_files(self):
"""
Run auto sync files
"""
now = fields.Datetime.now()
files = self.search(
[
("source", "=", "server"),
("auto_sync", "=", True),
("sync_date_next", "<=", now),
]
)
files.download(raise_error=False)
def _update_file_sync_date(self, last_sync_date):
"""
Compute and update next date of sync
"""
for file in self:
vals = {}
if file.source == "server" and file.auto_sync:
interval, interval_type = file.auto_sync_interval.split("-")
vals.update(
{
"sync_date_next": last_sync_date
+ INTERVAL_TYPES[interval_type](int(interval))
}
)
if file.server_response == "ok":
vals.update({"sync_date_last": last_sync_date})
file.sudo().write(vals)
# Check cx.tower.reference.mixin for the function documentation
def _get_pre_populated_model_data(self):
res = super()._get_pre_populated_model_data()
res.update({"cx.tower.file": ["cx.tower.server", "server_id"]})
return res

View File

@@ -0,0 +1,228 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from .cx_tower_file import TEMPLATE_FILE_FIELD_MAPPING
class CxTowerFileTemplate(models.Model):
"""File template to manage multiple files at once"""
_name = "cx.tower.file.template"
_inherit = [
"cx.tower.reference.mixin",
"cx.tower.key.mixin",
"cx.tower.template.mixin",
"cx.tower.access.role.mixin",
"cx.tower.tag.mixin",
]
_description = "Cetmix Tower File Template"
_order = "name"
active = fields.Boolean(default=True)
file_name = fields.Char(
help="Default full file name with file type for example: test.txt",
)
code = fields.Text(string="File content")
server_dir = fields.Char(string="Directory on server")
file_ids = fields.One2many("cx.tower.file", "template_id")
file_count = fields.Integer(
"File(s)",
compute="_compute_file_count",
)
tag_ids = fields.Many2many(
relation="cx_tower_file_template_tag_rel",
column1="file_template_id",
column2="tag_id",
)
note = fields.Text(help="This field is used to put some notes regarding template.")
keep_when_deleted = fields.Boolean(
help="File will be kept on server when deleted in Tower",
)
auto_sync = fields.Boolean(
help="If enabled, files created from this template will have "
"Auto Sync enabled by default. Used only with 'Tower' source.",
)
file_type = fields.Selection(
selection=lambda self: self.env["cx.tower.file"]._selection_file_type(),
default=lambda self: self.env["cx.tower.file"]._default_file_type(),
required=True,
)
source = fields.Selection(
[
("tower", "Tower"),
("server", "Server"),
],
required=True,
default="tower",
)
variable_ids = fields.Many2many(
comodel_name="cx.tower.variable",
relation="cx_tower_file_template_variable_rel",
column1="file_template_id",
column2="variable_id",
)
# ---- Access. Add relation for mixin fields
user_ids = fields.Many2many(
relation="cx_tower_file_template_user_rel",
domain=lambda self: [
("groups_id", "in", [self.env.ref("cetmix_tower_server.group_manager").id])
],
)
manager_ids = fields.Many2many(
relation="cx_tower_file_template_manager_rel",
)
@classmethod
def _get_depends_fields(cls):
"""
Define dependent fields for computing
`variable_ids` in file template-related models.
This implementation specifies that the fields `code`, `server_dir`,
and `file_name` are used to compute the
variables associated with a file template.
Returns:
list: A list of field names (str) representing the dependencies.
Example:
The following fields trigger recomputation
of `variable_ids`:
- `code`: The template content for the file.
- `server_dir`: The target directory on the
server where the template is applied.
- `file_name`: The name of the generated file.
"""
return ["code", "server_dir", "file_name"]
# -- Computes
@api.depends("file_ids")
def _compute_file_count(self):
"""
Compute total template files
"""
for template in self:
template.file_count = len(template.file_ids)
# -- Create/Write/Unlink
def write(self, vals):
"""
Override to update files related with the templates
"""
result = super().write(vals)
if any([field_ in vals for field_ in TEMPLATE_FILE_FIELD_MAPPING]):
for file in self.mapped("file_ids"):
file.write(file._get_file_values_from_related_template())
return result
# -- Actions
def action_open_files(self):
"""
Open current template files
"""
action = self.env["ir.actions.actions"]._for_xml_id(
"cetmix_tower_server.cx_tower_file_action"
)
action["domain"] = [("id", "in", self.file_ids.ids)]
return action
# -- Business logic
def create_file(self, server, server_dir="", if_file_exists="raise"):
"""
Create a new file using the current template for the selected server.
If the same file already exists, just ignore it or raise an error based on the
parameter.
:param server: recordset
The server (cx.tower.server) on which the file should be created. This is a
required parameter.
:param if_file_exists: str, optional
Defines the behavior if the file already exists on the server.
:param server_dir: str, optional
The directory on the server where the file should be created. If not set,
the server_dir field of the template will be used.
:return: cx.tower.file
Returns the newly created file record (cx.tower.file) if the file was
created successfully or if_file_exists is set to "overwrite".
Returns the existing file record if the file already exists
and if_file_exists is set to "skip".
:raises ValidationError:
If the file already exists on the server if_file_exists is set to "raise".
"""
self.ensure_one()
# Explicit guard against invalid behavior values
valid_behaviors = {"skip", "raise", "overwrite"}
if if_file_exists not in valid_behaviors:
raise ValidationError(
f"Invalid if_file_exists value: {if_file_exists}. "
f"Expected one of {valid_behaviors}."
)
file_model = self.env["cx.tower.file"]
existing_files = file_model.search(
[
("server_id", "=", server.id),
("source", "=", self.source),
],
order="id DESC",
)
existing_dir = server_dir or self.server_dir
# Render the server directory and file name from the template
variables = list(
set(
self.get_variables_from_code(self.file_name)
+ self.get_variables_from_code(existing_dir)
)
)
var_vals = server.get_variable_values(variables).get(server.id) or {}
unrendered_path = (
f"{existing_dir}/{self.file_name}" if existing_dir else self.file_name
)
rendered_path = self.render_code_custom(unrendered_path, **var_vals)
# Filter existing files by rendered path
existing_files = existing_files.filtered(
lambda f: f.full_server_path == rendered_path
)
# Filter existing files by template if it exists, otherwise take the first one
existing_file = (
existing_files.filtered(lambda f: f.template_id == self)[:1]
or existing_files[:1]
)
if existing_file and if_file_exists == "skip":
return existing_file.with_context(file_creation_skipped=True)
if existing_file and if_file_exists == "raise":
raise ValidationError(_("File already exists on server."))
if existing_file and if_file_exists == "overwrite":
existing_file.with_context(is_custom_server_dir=True).write(
{
"template_id": self.id,
}
)
return existing_file
vals = {
"name": self.file_name,
"server_id": server.id,
"server_dir": existing_dir,
"template_id": self.id,
"code": self.code,
"file_type": self.file_type,
"source": self.source,
"auto_sync": self.auto_sync,
}
new_file = file_model.with_context(is_custom_server_dir=True).create(vals)
# Return new_file if no file exists
return new_file

View File

@@ -0,0 +1,414 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class CxTowerKey(models.Model):
"""SSH Private key and secret storage"""
_name = "cx.tower.key"
_description = "Cetmix Tower Key/Secret Storage"
_inherit = [
"cx.tower.reference.mixin",
"cx.tower.access.role.mixin",
"cx.tower.vault.mixin",
]
_order = "name"
KEY_PREFIX = "#!cxtower"
KEY_TERMINATOR = "!#"
SECRET_FIELDS = ["secret_value"]
key_type = fields.Selection(
selection=[
("k", "SSH Key"),
("s", "Secret"),
],
required=True,
)
reference_code = fields.Char(
compute="_compute_reference_code",
help="Key reference for inline usage",
)
secret_value = fields.Text(
string="SSH Private Key",
)
value_ids = fields.One2many(
string="Values",
comodel_name="cx.tower.key.value",
inverse_name="key_id",
)
server_ssh_ids = fields.One2many(
string="Used as SSH Key",
comodel_name="cx.tower.server",
inverse_name="ssh_key_id",
readonly=True,
help="Used as SSH key in the following servers",
)
note = fields.Text()
# ---- Access. Add relation for mixin fields
user_ids = fields.Many2many(
relation="cx_tower_key_user_rel",
domain=lambda self: [
("groups_id", "in", [self.env.ref("cetmix_tower_server.group_manager").id])
],
)
manager_ids = fields.Many2many(
relation="cx_tower_key_manager_rel",
)
@api.depends("reference", "key_type")
def _compute_reference_code(self):
"""Compute key reference
Eg '#!cxtower.secret.KEY!#'
"""
for rec in self:
if rec.reference:
key_prefix = self._compose_key_prefix(rec.key_type)
if key_prefix:
rec.reference_code = f"#!cxtower.{key_prefix}.{rec.reference}!#"
else:
rec.reference_code = None
else:
rec.reference_code = None
@api.returns("self", lambda value: value.id)
def copy(self, default=None):
"""Copy key. Ensure secret value is copied.
Args:
default (dict, optional): Default values. Defaults to None.
Returns:
self: Copied key
"""
default = default or {}
default["secret_value"] = self._get_secret_value("secret_value")
result = super().copy(default=default)
# Copy key values
for value in self.value_ids:
value.copy(
{
"key_id": result.id,
}
)
return result
def _get_reference_pattern(self):
"""
Override mixin method
"""
return "[a-zA-Z0-9_]"
def _compose_key_prefix(self, key_type):
"""Compose key prefix based on key type.
Override to implement own key prefixes.
Args:
key_type (_type_): _description_
Raises:
ValidationError: _description_
Returns:
Char: key prefix
"""
if key_type == "s":
key_prefix = "secret"
else:
key_prefix = None
return key_prefix
def _parse_code_and_return_key_values(self, code, pythonic_mode=False, **kwargs):
"""Replaces key placeholders in code with the corresponding values,
returning key values.
This function is meant to be used in the flow where key values
are needed for some follow up operations such as command log clean up.
NB:
- key format must follow "#!cxtower.key.KEY_ID!#" pattern.
eg #!cxtower.secret.GITHUB_TOKEN!# for GITHUB_TOKEN key
Args:
code (Text): code to process
pythonic_mode (Bool): If True, all variables in kwargs are converted to
strings and wrapped in double quotes.
Default is False.
kwargs (dict): optional arguments
Returns:
Dict(): 'code': Command text, 'key_values': List of key values
"""
# No need to search if code is too short
if len(code) <= len(self.KEY_PREFIX) + 3 + len(
self.KEY_TERMINATOR
): # at least one dot separator and two symbols
return {"code": code, "key_values": None}
# Get key strings
key_strings = self._extract_key_strings(code)
# Set key values
key_values = []
# Replace keys with values
for key_string in key_strings:
# Replace key including key terminator
key_value = self._parse_key_string(key_string, **kwargs)
if pythonic_mode and key_value:
# save key value as string in pythonic mode
key_value = f'"{key_value}"'
# Escape newline characters to ensure the key value remains
# a valid single-line string. This prevents syntax errors
# when the string is used in contexts where unescaped
# newlines would break Python syntax or evaluation logic.
key_value = key_value.replace("\n", "\\n")
# Save key value if not saved yet
if key_value and key_value not in key_values:
key_values.append(key_value)
# Handle False and None values
if not key_value:
key_value = str(key_value)
# Replace key with value
code = code.replace(key_string, key_value)
return {"code": code, "key_values": key_values}
def _parse_code(self, code, **kwargs):
"""Replaces key placeholders in code with the corresponding values.
Args:
code (Text): code to proceed
kwargs (dict): optional arguments
Returns:
Text: code with key values in place and list of key values.
Use key values
"""
return self._parse_code_and_return_key_values(code, **kwargs)["code"]
def _extract_key_strings(self, code):
"""Extract all keys from code
Args:
code (Text): description
**kwargs (dict): optional arguments
Returns:
[str]: list of key strings
"""
key_strings = []
key_terminator_len = len(self.KEY_TERMINATOR)
index_from = 0 # initial position
while index_from >= 0:
index_from = code.find(self.KEY_PREFIX, index_from)
if index_from >= 0:
# Key end
index_to = code.find(self.KEY_TERMINATOR, index_from)
# Extract key value only if key terminator is found
if index_to > 0:
# Extract key string including key terminator
extract_to = index_to + key_terminator_len
key_string = code[index_from:extract_to]
# Add only if not added before
if key_string not in key_strings:
key_strings.append(key_string)
# Update index from
index_from = extract_to
else:
# No terminator found, move past this occurrence of prefix
index_from += len(self.KEY_PREFIX)
else:
# No more prefixes found
break
return key_strings
def _parse_key_string(self, key_string, **kwargs):
"""Parse key string and call resolver based on the key type.
Each key string consists of 3 parts:
- key marker: #!cxtower
- key type: e.g. "secret", "password", "login" etc
- key ID: e.g "qwerty123", "mystrongpassword" etc
Inherit this function to implement your own parser or resolver
Args:
key_string (str): key string
**kwargs (dict) optional values
Returns:
str: key value or None if not able to parse
"""
key_parts = self._extract_key_parts(key_string)
if key_parts is None:
return None
key_type, reference = key_parts
key_value = self._resolve_key(key_type, reference, **kwargs)
return key_value
def _extract_key_parts(self, key_string):
"""Extract and validate key parts from the key string.
Args:
key_string (str): key string
Returns:
tuple: (key_type, reference) if valid, else None
"""
key_parts = (
key_string.replace(" ", "").replace(self.KEY_TERMINATOR, "").split(".")
)
# Must be 3 parts including pre!
if len(key_parts) == 3 and key_parts[0] == self.KEY_PREFIX:
return key_parts[1], key_parts[2]
return None
def _resolve_key(self, key_type, reference, **kwargs):
"""Resolve key
Inherit this function to implement your own resolvers
Args:
reference (str): key reference
**kwargs (dict) optional values
Returns:
str: value or None if not able to parse
"""
if key_type == "secret":
return self._resolve_key_type_secret(reference, **kwargs)
def _resolve_key_type_secret(self, reference, **kwargs):
"""Resolve key of type "secret".
Use this function as a custom parser example
Args:
reference (str): key reference
**kwargs (dict) optional values
Returns:
str: value or False if not able to parse
"""
if not reference:
return
# Compose domain used to fetch keys
#
# Keys are checked in the following order:
# 1. Partner and Server specific
# 2. Server specific
# 3. Partner specific
# 4. General (no server or partner specified)
server_id = kwargs.get("server_id")
partner_id = kwargs.get("partner_id")
# Fetch key
key = self.sudo().search([("reference", "=", reference)], limit=1)
if not key:
return
# Check if key has custom values
key_values = key.value_ids
key_value = None
# 1. Server and Partner specific key first
if key_values and server_id and partner_id:
filtered_key_values = key_values.filtered(
lambda k: k.server_id.id == server_id and k.partner_id.id == partner_id
)
if filtered_key_values:
key_value = filtered_key_values[0]
# 2. Server specific key first
if not key_value and key_values and server_id:
filtered_key_values = key_values.filtered(
lambda k: k.server_id.id == server_id and not k.partner_id
)
if filtered_key_values:
key_value = filtered_key_values[0]
# 3. Partner specific key next
if not key_value and key_values and partner_id:
filtered_key_values = key_values.filtered(
lambda k: k.partner_id.id == partner_id and not k.server_id
)
if filtered_key_values:
key_value = filtered_key_values[0]
# 4. General key next
if not key_value and key_values:
filtered_key_values = key_values.filtered(
lambda k: not k.partner_id and not k.server_id
)
if filtered_key_values:
key_value = filtered_key_values[0]
if key_value:
return key_value._get_secret_value("secret_value")
def _replace_with_spoiler(self, code, key_values):
"""Helper function that replaces clean text keys in code with spoiler.
Eg
'Code with passwordX and passwordY` will look like:
'Code with *** and ***'
Important: this function doesn't parse keys by itself.
You need to get and provide key values yourself.
Args:
code (Text): code to clean
key_values (List): secret values to be cleaned from code
Returns:
Text: cleaned code
"""
if not key_values:
return code
# Replace keys with values
for key_value in key_values:
# If key_value includes quotes, remove them for the replacement
key_value = key_value.strip('"')
# If key_value contains an escaped line break replace then remove escaping
key_value = key_value.replace("\\n", "\n")
# Replace key including key terminator
code = code.replace(key_value, self.SECRET_VALUE_PLACEHOLDER)
return code
def _set_secret_values(self, vals):
"""Set secret value.
Override this method in case you need
to implement custom key storages.
Args:
vals (dict): Dictionary of field names to secret values
"""
self.ensure_one()
if self.key_type == "s":
# Set general value or create new one if not exists
general_value = self.value_ids.filtered(
lambda x: not x.server_id and not x.partner_id
)
if general_value:
general_value._set_secret_values(vals)
else:
create_vals = {"key_id": self.id}
create_vals.update(vals)
self.value_ids.create(create_vals)
elif self.key_type == "k":
return super()._set_secret_values(vals)

View File

@@ -0,0 +1,70 @@
from odoo import api, fields, models
class CxTowerKeyMixin(models.AbstractModel):
"""Mixin for managing secrets and SSH keys"""
_name = "cx.tower.key.mixin"
_description = "Cetmix Tower Key/Secret Mixin"
secret_ids = fields.Many2many(
comodel_name="cx.tower.key",
compute="_compute_secret_ids",
compute_sudo=True,
readonly=True,
store=True,
string="Secrets",
)
@api.depends("code")
def _compute_secret_ids(self):
"""
Compute the secret IDs based on the references found in the code field.
This method updates the secret_ids Many2many field by extracting secret
references from the code field. If no code is present, the field is cleared.
It ensures updates are only triggered when there are differences between
the current and new secret IDs.
"""
for record in self:
if record.code:
new_secrets = self._extract_secret_ids(record.code)
# This will create a recordset that contains the difference
if record.secret_ids != new_secrets:
record.secret_ids = new_secrets
else:
record.secret_ids = [(5, 0, 0)]
@api.model
def _extract_secret_ids(self, code):
"""
Extract secret IDs based on references found in the given `code`.
Args:
code: Text containing potential secret references.
Returns:
list: List of secret IDs corresponding to the references in `code`.
"""
key_model = self.env["cx.tower.key"]
key_strings = key_model._extract_key_strings(code)
key_refs = []
for key_string in key_strings:
key_parts = key_model._extract_key_parts(key_string)
if key_parts:
key_refs.append(key_parts[1])
return key_model.search(self._compose_secret_search_domain(key_refs))
def _compose_secret_search_domain(self, key_refs):
"""Compose domain for searching secrets by references.
Args:
key_refs (List[str]): List of secret references.
Returns:
List: final domain for searching secrets.
"""
return [("reference", "in", key_refs)]

View File

@@ -0,0 +1,112 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class CxTowerKeyValue(models.Model):
"""Secret value storage"""
_name = "cx.tower.key.value"
_inherit = [
"cx.tower.reference.mixin",
"cx.tower.vault.mixin",
]
_description = "Cetmix Tower Secret Value Storage"
SECRET_FIELDS = ["secret_value"]
name = fields.Char(related="key_id.name", readonly=False)
key_id = fields.Many2one(
comodel_name="cx.tower.key",
string="Key",
required=True,
ondelete="cascade",
domain="[('key_type', '=', 's')]",
)
server_id = fields.Many2one(
comodel_name="cx.tower.server",
ondelete="cascade",
help="Server to which the key belongs",
)
partner_id = fields.Many2one(
comodel_name="res.partner",
ondelete="cascade",
help="Partner to which the key belongs",
)
is_global = fields.Boolean(
string="Global",
compute="_compute_is_global",
help="This value is applicable to all servers and partners",
)
secret_value = fields.Text()
@api.depends("server_id", "partner_id")
def _compute_is_global(self):
for record in self:
record.is_global = not record.server_id and not record.partner_id
@api.constrains("key_id", "server_id", "partner_id")
def _check_key_id(self):
for rec in self:
if not rec.key_id:
continue
# Only keys of type 'secret' can have custom secret values
if rec.key_id.key_type != "s":
raise ValidationError(
_(
"Custom secret values can be defined"
" only for key type 'secret'"
)
)
# Only one global secret value can be defined for a key
global_values = rec.key_id.value_ids.filtered(
lambda x, rec=rec: not x.server_id and not x.partner_id
)
if len(global_values) > 1:
raise ValidationError(
_("Only one global secret value can be defined for a key")
)
# Only one secret value can be defined for a server and partner
server_partner_values = rec.key_id.value_ids.filtered(
lambda x, rec=rec: x.server_id == rec.server_id
and x.partner_id == rec.partner_id
)
if len(server_partner_values) > 1:
raise ValidationError(
_(
"Only one secret value can be defined"
" for a server and partner"
)
)
# Only one secret value can be defined for a server
server_values = rec.key_id.value_ids.filtered(
lambda x, rec=rec: x.server_id == rec.server_id and not x.partner_id
)
if len(server_values) > 1:
raise ValidationError(
_("Only one secret value can be defined for a server")
)
# Only one secret value can be defined for a partner
partner_values = rec.key_id.value_ids.filtered(
lambda x, rec=rec: x.partner_id == rec.partner_id and not x.server_id
)
if len(partner_values) > 1:
raise ValidationError(
_("Only one secret value can be defined for a partner")
)
@api.returns("self", lambda value: value.id)
def copy(self, default=None):
"""Copy key value. Ensure secret value is copied.
Args:
default (dict, optional): Default values. Defaults to None.
Returns:
self: Copied key value
"""
default = default or {}
default["secret_value"] = self._get_secret_value("secret_value")
return super().copy(default=default)

View File

@@ -0,0 +1,17 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class CxTowerOs(models.Model):
"""Operating System"""
_name = "cx.tower.os"
_inherit = [
"cx.tower.reference.mixin",
]
_description = "Cetmix Tower Operating System"
_order = "name"
color = fields.Integer(help="For better visualization in views")
parent_id = fields.Many2one(string="Previous Version", comodel_name="cx.tower.os")

View File

@@ -0,0 +1,373 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from operator import indexOf
from odoo import _, api, fields, models
from odoo.tools.safe_eval import expr_eval
from .constants import (
ANOTHER_PLAN_RUNNING,
PLAN_LINE_CONDITION_CHECK_FAILED,
PLAN_LINE_NOT_ASSIGNED,
PLAN_NOT_ASSIGNED,
PLAN_NOT_COMPATIBLE_WITH_SERVER,
)
class CxTowerPlan(models.Model):
"""Cetmix Tower flight plan"""
_name = "cx.tower.plan"
_description = "Cetmix Tower Flight Plan"
_inherit = [
"cx.tower.reference.mixin",
"cx.tower.access.mixin",
"cx.tower.access.role.mixin",
"cx.tower.tag.mixin",
]
_order = "name asc"
active = fields.Boolean(default=True)
allow_parallel_run = fields.Boolean(
help="If enabled, multiple instances of the same flight plan "
"can be run on the same server at the same time.\n"
"Otherwise, ANOTHER_PLAN_RUNNING status will be returned if another"
" instance of the same flight plan is already running"
)
color = fields.Integer(help="For better visualization in views")
server_ids = fields.Many2many(string="Servers", comodel_name="cx.tower.server")
tag_ids = fields.Many2many(
relation="cx_tower_plan_tag_rel",
column1="plan_id",
column2="tag_id",
)
line_ids = fields.One2many(
string="Lines",
comodel_name="cx.tower.plan.line",
inverse_name="plan_id",
auto_join=True,
copy=True,
)
command_ids = fields.Many2many(
string="Commands",
comodel_name="cx.tower.command",
relation="cx_tower_command_flight_plan_used_id_rel",
column1="plan_id",
column2="command_id",
help="Commands used in this flight plan",
compute="_compute_command_ids",
store=True,
)
note = fields.Text()
on_error_action = fields.Selection(
string="On Error",
selection=[
("e", "Exit with command exit code"),
("ec", "Exit with custom exit code"),
("n", "Run next command"),
],
required=True,
default="e",
help="This action will be triggered on error "
"if no command action can be applied",
)
custom_exit_code = fields.Integer(
help="Will be used instead of the command exit code"
)
access_level_warn_msg = fields.Text(
compute="_compute_command_access_level",
compute_sudo=True,
)
# ---- Access. Add relation for mixin fields
user_ids = fields.Many2many(
relation="cx_tower_plan_user_rel",
)
manager_ids = fields.Many2many(
relation="cx_tower_plan_manager_rel",
)
@api.depends("line_ids.command_id.access_level", "access_level")
def _compute_command_access_level(self):
"""Check if the access level of a command in the plan
is higher than the plan's access level"""
for record in self:
commands = record.mapped("line_ids").mapped("command_id")
# Retrieve all commands associated with the flight plan
commands_with_higher_access = commands.filtered(
lambda c, access_level=record.access_level: c.access_level
> access_level
)
if commands_with_higher_access:
command_names = ", ".join(commands_with_higher_access.mapped("name"))
record.access_level_warn_msg = _(
"The access level of command(s) '%(command_names)s' included in the"
" current Flight plan is higher than the access level of the"
" Flight plan itself. Please ensure that you want to allow"
" those commands to be run anyway.",
command_names=command_names,
)
else:
record.access_level_warn_msg = False
@api.depends("line_ids", "line_ids.command_id")
def _compute_command_ids(self):
"""Compute command ids"""
for plan in self:
plan.command_ids = [(6, 0, plan.line_ids.mapped("command_id").ids)]
def action_open_plan_logs(self):
"""
Open current flight plan log records
"""
action = self.env["ir.actions.actions"]._for_xml_id(
"cetmix_tower_server.action_cx_tower_plan_log"
)
action["domain"] = [("plan_id", "=", self.id)]
return action
def _get_dependent_model_relation_fields(self):
"""Check cx.tower.reference.mixin for the function documentation"""
res = super()._get_dependent_model_relation_fields()
return res + ["line_ids"]
def _is_plan_incompatible_with_server(self, server):
"""
Check if the flight plan is compatible with the server.
Note: this function uses the inverse logic to simplify the checks.
Args:
server (cx.tower.server()): Server object
Returns:
Char or False: Incompatible reason or False if compatible
"""
# Check if the flight plan is compatible with the server
if not self.server_ids:
return False
if server.id not in self.server_ids.ids:
return _("Flight plan is not compatible with the server")
# Check if the flight plan commands are compatible with the server
for command in self.command_ids:
# Check the entire command first
if not command._check_server_compatibility(server):
return _(
"Command %(command_name)s is not compatible with the server",
command_name=command.name,
) # pylint: disable=no-member
# Check if the nested flight plan is compatible with the server
if command.action == "plan":
plan_check_result = (
command.flight_plan_id._is_plan_incompatible_with_server(server)
)
if plan_check_result:
return plan_check_result
return False
def _get_post_create_fields(self):
res = super()._get_post_create_fields()
return res + ["line_ids"]
def _run_single(self, server, **kwargs):
"""Run single Flight Plan on a single server
Args:
server (cx.tower.server()): Server object
kwargs (dict): Optional arguments
Following are supported but not limited to:
- "plan_log": {values passed to flightplan logger}
- "log": {values passed to logger}
- "key": {values passed to key parser}
- "variable_values", dict(): custom variable values
in the format of `{variable_reference: variable_value}`
eg `{'odoo_version': '16.0'}`
Will be applied only if user has write access to the server.
Returns:
log_record (cx.tower.plan.log()): plan log record
"""
self.ensure_one()
# Ensure we have a single server record
server.ensure_one()
# Check plan access before running
# This is needed to avoid possible access violations
self.check_access_rights("read")
self.check_access_rule("read")
# Access log as root to bypass access restrictions
plan_log_obj = self.env["cx.tower.plan.log"].sudo()
# Check if flight plan and all its commands can be run on this server
# This check is skipped if 'from_command' context key is set to True
if not self.env.context.get("from_command"):
plan_is_incompatible = self._is_plan_incompatible_with_server(server)
if plan_is_incompatible:
# Create a log record with the custom message and exit
plan_log_kwargs = kwargs.get("plan_log", {})
plan_log_kwargs["custom_message"] = plan_is_incompatible
kwargs["plan_log"] = plan_log_kwargs
plan_log = plan_log_obj.record(
server=server,
plan=self,
status=PLAN_NOT_COMPATIBLE_WITH_SERVER,
**kwargs,
)
return plan_log
# Check if the same plan is being run on this server right now
if not self.allow_parallel_run or self.env.context.get(
"prevent_plan_recursion"
):
running_count = plan_log_obj.search_count(
[
("server_id", "=", server.id),
("plan_id", "=", self.id), # pylint: disable=no-member
("is_running", "=", True),
]
)
if running_count > 0:
plan_log = plan_log_obj.record(
server=server, plan=self, status=ANOTHER_PLAN_RUNNING, **kwargs
)
return plan_log
# Start Flight Plan and return the log record
return plan_log_obj.start(server, self, fields.Datetime.now(), **kwargs)
def _get_next_action_values(self, command_log):
"""Get next action values based of previous command result:
- Action to proceed
- Exit code
- Next line of the plan if next line should be run
Args:
command_log (cx.tower.command.log()): Command log record
Returns:
action, exit_code, next_line (Selection, Integer, cx.tower.plan.line())
"""
# Iterate all actions and return the first matching one.
# If no action is found return the default plan values
# If the line is the last one return last command exit code
if not command_log.plan_log_id: # Exit with custom code "Plan not found"
return "ec", PLAN_NOT_ASSIGNED, None
current_line = command_log.plan_log_id.plan_line_executed_id
if not current_line:
return "ec", PLAN_LINE_NOT_ASSIGNED, None
# Default values
exit_code = command_log.command_status
server = command_log.server_id
# Check line condition
variable_values = (
command_log.variable_values or command_log.plan_log_id.variable_values or {}
)
if not current_line._is_executable_line(
server, variable_values=variable_values
):
# Immediately return to the next line if condition fails
return self._get_next_action_state(
"n", PLAN_LINE_CONDITION_CHECK_FAILED, current_line
)
# Check plan action lines
for action_line in current_line.action_ids:
conditional_expression = (
f"{exit_code} {action_line.condition} {action_line.value_char}"
)
# Evaluate expression using safe_eval
if expr_eval(conditional_expression):
action = action_line.action
# Use custom exit code if action requires it
if action == "ec" and action_line.custom_exit_code:
exit_code = action_line.custom_exit_code
# Apply action-defined values into the variable values context
for variable_value in action_line.variable_value_ids:
ref = variable_value.variable_id.reference
variable_values[ref] = variable_value.value_char
# Persist the updated custom values only in logs
# so they remain available within the current flight plan context
updated_values = dict(variable_values)
command_log.variable_values = updated_values
if command_log.plan_log_id:
command_log.plan_log_id.variable_values = updated_values
return self._get_next_action_state(action, exit_code, current_line)
# If no action matched, fallback to default ones
return self._get_next_action_state(None, exit_code, current_line)
def _get_next_action_state(self, action, exit_code, current_line):
"""
Determine the next action, exit code, and next line based on the current state.
"""
lines = current_line.plan_id.line_ids
is_last_line = current_line == lines[-1]
# If no conditions were met fallback to default ones
if not action:
action = "n" if exit_code == 0 else current_line.plan_id.on_error_action
# Exit with custom code
if action == "ec":
exit_code = current_line.plan_id.custom_exit_code
# Determine the next line if current is not the last one
next_line = None
if action == "n" and not is_last_line:
next_line = lines[indexOf(lines, current_line) + 1]
if is_last_line:
action = "e"
return action, exit_code, next_line
def _run_next_action(self, command_log):
"""Run next action based on the command result
Args:
command_log (cx.tower.command.log()): Command log record
"""
self.ensure_one()
action, exit_code, plan_line = self._get_next_action_values(command_log)
plan_log = command_log.plan_log_id
# Update log message
if exit_code == PLAN_LINE_CONDITION_CHECK_FAILED:
# save log exit code as success
exit_code = 0
# Run next line
if action == "n" and plan_line:
server = command_log.server_id
variable_values = command_log.variable_values or plan_log.variable_values
if plan_line._is_executable_line(server, variable_values=variable_values):
plan_line._run(server, plan_log, variable_values=variable_values)
else:
plan_line._skip(
server,
plan_log,
log={"variable_values": dict(variable_values or {})},
)
# Exit
if action in ["e", "ec"]:
plan_log.finish(exit_code)
# NB: we are not putting any fallback here in case
# someone needs to inherit and extend this function

View File

@@ -0,0 +1,301 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.safe_eval import safe_eval
from .constants import PLAN_LINE_CONDITION_CHECK_FAILED
_logger = logging.getLogger(__name__)
class CxTowerPlanLine(models.Model):
"""Flight Plan Line"""
_name = "cx.tower.plan.line"
_inherit = [
"cx.tower.reference.mixin",
]
_order = "sequence, plan_id"
_description = "Cetmix Tower Flight Plan Line"
active = fields.Boolean(related="plan_id.active", readonly=True)
sequence = fields.Integer(default=10)
name = fields.Char(related="command_id.name", readonly=True)
plan_id = fields.Many2one(
string="Flight Plan",
comodel_name="cx.tower.plan",
auto_join=True,
ondelete="cascade",
)
action = fields.Selection(
selection=lambda self: self.command_id._selection_action(),
compute="_compute_action",
required=True,
readonly=False,
)
command_id = fields.Many2one(
comodel_name="cx.tower.command",
required=True,
ondelete="restrict",
domain="[('action', '=', action)]",
)
note = fields.Text(related="command_id.note", readonly=True)
path = fields.Char(
help="Location where command will be executed. Overrides command default path. "
"You can use {{ variables }} in path",
)
use_sudo = fields.Boolean(
help="Will use sudo based on server settings."
"If no sudo is configured will run without sudo"
)
action_ids = fields.One2many(
string="Actions",
comodel_name="cx.tower.plan.line.action",
inverse_name="line_id",
auto_join=True,
copy=True,
help="Actions trigger based on command result."
" If empty next command will be executed",
)
command_code = fields.Text(
related="command_id.code",
readonly=True,
)
tag_ids = fields.Many2many(related="command_id.tag_ids", readonly=True)
access_level = fields.Selection(
related="plan_id.access_level",
readonly=True,
store=True,
)
condition = fields.Char(
help="Conditions under which this Flight Plan Line "
"will be launched. e.g.: {{ odoo_version}} == '14.0'",
)
variable_ids = fields.Many2many(
comodel_name="cx.tower.variable",
relation="cx_tower_plan_line_variable_rel",
column1="plan_line_id",
column2="variable_id",
string="Variables",
compute="_compute_variable_ids",
store=True,
)
# -- Command related entities
plan_run_id = fields.Many2one(
comodel_name="cx.tower.plan",
related="command_id.flight_plan_id",
readonly=True,
string="Run Flight Plan",
)
plan_run_line_ids = fields.One2many(
comodel_name="cx.tower.plan.line",
related="command_id.flight_plan_id.line_ids",
string="Flight Plan Lines",
readonly=True,
)
file_template_id = fields.Many2one(
comodel_name="cx.tower.file.template",
related="command_id.file_template_id",
readonly=True,
)
file_template_code = fields.Text(
string="Template Code",
related="file_template_id.code",
readonly=True,
)
@api.depends("condition")
def _compute_variable_ids(self):
"""
Compute variable_ids based on condition field.
"""
template_mixin_obj = self.env["cx.tower.template.mixin"]
for record in self:
record.variable_ids = template_mixin_obj._prepare_variable_commands(
["condition"], force_record=record
)
def _compute_action(self):
"""
Compute action based on command.
"""
# We set action only once, so there is no 'depends' in this function
for record in self:
if record.action:
continue
if record.command_id:
record.action = record.command_id.action
else:
record.action = False
@api.constrains("command_id")
def _check_command_id(self):
"""
Check recursive plan line execution.
"""
for line in self:
# Check recursive plan line execution
visited_plans = set()
self._check_recursive_plan(line.command_id, visited_plans)
@api.onchange("action")
def _inverse_action(self):
"""
Reset command when action changes.
"""
self.command_id = False
def _check_recursive_plan(self, command, visited_plans):
"""
Recursively check if the command plan creates a cycle.
Raise a ValidationError if a cycle is detected.
"""
if command.flight_plan_id and command.action == "plan":
if command.flight_plan_id.id in visited_plans:
raise ValidationError(
_(
"Recursive plan call detected in plan %(name)s.",
name=command.flight_plan_id.name,
)
)
visited_plans.add(command.flight_plan_id.id)
# recursively check the lines in the plan
for line in command.flight_plan_id.line_ids:
self._check_recursive_plan(line.command_id, visited_plans)
def _run(self, server, plan_log_record, **kwargs):
"""Run command from the Flight Plan line
Args:
server (cx.tower.server()): Server object
plan_log_record (cx.tower.plan.log()): Log record object
kwargs (dict): Optional arguments
Following are supported but not limited to:
- "plan_log": {values passed to flightplan logger}
- "log": {values passed to command logger}
- "key": {values passed to key parser}
"""
self.ensure_one()
# Set current line as currently executed in log
plan_log_record.plan_line_executed_id = self
# It is necessary to save information about which plan log
# was created for a command log that has the command action “plan”
flight_plan_command_log = kwargs.get("flight_plan_command_log")
if flight_plan_command_log:
flight_plan_command_log.triggered_plan_log_id = plan_log_record.id
# Pass plan_log to command so it will be saved in command log
log_vals = kwargs.get("log", {})
log_vals.update({"plan_log_id": plan_log_record.id})
kwargs.update({"log": log_vals})
# Set 'sudo' value
use_sudo = self.use_sudo and server.use_sudo
# Use sudo to bypass access rules for execute command with higher access level
command_as_root = self.sudo().command_id
# Set path
path = self.path or command_as_root.path
server.run_command(command_as_root, path, sudo=use_sudo, **kwargs)
def _is_executable_line(self, server, variable_values=None):
"""
Check if this line can be executed based on its condition.
Args:
server (cx.tower.server()): The server on which conditions are checked.
variable_values (dict, optional): Custom values provided when running the
flight plan. These values are merged with server variables when
rendering the condition.
Returns:
bool: True if the line can be executed, otherwise False.
"""
self.ensure_one()
condition = self.condition
if condition:
# Collect variable references used in the condition
variables = self.command_id.get_variables_from_code(condition)
# Values from server variables referenced in the condition
server_values = {}
if variables:
variable_values_dict = server.get_variable_values(variables)
server_values = variable_values_dict.get(server.id, {}) or {}
# Merge with custom values passed to the flight plan (if any)
merged_values = {**server_values, **(variable_values or {})}
# Render condition with all available values (in pythonic mode)
if merged_values:
condition = self.command_id.render_code_custom(
condition, pythonic_mode=True, **merged_values
)
# For evaluate a string that contains an expression that mostly uses
# Python constants, arithmetic expressions and the objects directly provided
# in context we need use `safe_eval`
# We catch all exceptions and return False to avoid raising an exception
try:
result = safe_eval(condition)
except Exception as e:
_logger.error(
"Error evaluating condition '%s' for plan line '%s' "
"in plan '%s' for server '%s'. Line is skipped. Error: %s",
condition,
self.name,
self.plan_id.name,
server.name,
str(e),
)
result = False
return result
return True # Assume the line can be executed if no condition is specified
def _skip(self, server, plan_log_record, **kwargs):
"""
Triggered when plan line skipped by condition
"""
self.ensure_one()
# Set current line as currently executed in log
plan_log_record.plan_line_executed_id = self
# Log the unsuccessful execution attempt
now = fields.Datetime.now()
log_vals = kwargs.get("log", {})
self.env["cx.tower.command.log"].record(
server.id,
self.command_id.id,
now,
now,
PLAN_LINE_CONDITION_CHECK_FAILED,
None,
_("Plan line condition check failed."),
plan_log_id=plan_log_record.id,
condition=self.condition,
is_skipped=True,
**log_vals,
)
def _get_dependent_model_relation_fields(self):
"""Check cx.tower.reference.mixin for the function documentation"""
res = super()._get_dependent_model_relation_fields()
return res + ["action_ids"]
def _get_pre_populated_model_data(self):
"""Check cx.tower.reference.mixin for the function documentation"""
res = super()._get_pre_populated_model_data()
res.update({"cx.tower.plan.line": ["cx.tower.plan", "plan_id"]})
return res

View File

@@ -0,0 +1,101 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
class CxTowerPlanLineAction(models.Model):
"""Flight Plan Line Action"""
_inherit = ["cx.tower.variable.mixin", "cx.tower.reference.mixin"]
_name = "cx.tower.plan.line.action"
_description = "Cetmix Tower Flight Plan Line Action"
active = fields.Boolean(default=True)
name = fields.Char(compute="_compute_name")
sequence = fields.Integer(default=10)
line_id = fields.Many2one(
comodel_name="cx.tower.plan.line", auto_join=True, ondelete="cascade"
)
plan_id = fields.Many2one(
comodel_name="cx.tower.plan",
related="line_id.plan_id",
store=True,
readonly=True,
)
condition = fields.Selection(
selection=[
("==", "=="),
("!=", "!="),
(">", ">"),
(">=", ">="),
("<", "<"),
("<=", "<="),
],
required=True,
)
value_char = fields.Char(string="Result", required=True)
action = fields.Selection(
selection=[
("e", "Exit with command exit code"),
("ec", "Exit with custom exit code"),
("n", "Run next command"),
],
required=True,
default="n",
)
custom_exit_code = fields.Integer(
help="Will be used instead of the command exit code"
)
access_level = fields.Selection(
related="line_id.access_level",
readonly=True,
store=True,
)
variable_value_ids = fields.One2many(
# Other field properties are defined in mixin
inverse_name="plan_line_action_id",
copy=True,
)
@api.depends("condition", "action", "value_char")
def _compute_name(self):
action_selection_vals = dict(self._fields["action"].selection) # type: ignore
for rec in self:
# Some values are not updated until record is not saved.
# This is a disclaimer to avoid misunderstanding
if not isinstance(rec.id, int):
rec.name = _(
"...save record to see the final expression "
"or click the line to edit"
)
# Compose name based on values
elif rec.condition and rec.action and rec.value_char:
action_string = action_selection_vals.get(rec.action)
# Add custom exit code if action presumes it
if rec.action == "ec":
action_string = f"{action_string} {rec.custom_exit_code}"
rec.name = " ".join(
(
_("If exit code"),
rec.condition,
rec.value_char,
_("then"),
action_string,
)
)
else:
rec.name = _("Wrong action")
def _get_dependent_model_relation_fields(self):
"""Check cx.tower.reference.mixin for the function documentation"""
res = super()._get_dependent_model_relation_fields()
return res + ["variable_value_ids"]
def _get_pre_populated_model_data(self):
"""Check cx.tower.reference.mixin for the function documentation"""
res = super()._get_pre_populated_model_data()
res.update({"cx.tower.plan.line.action": ["cx.tower.plan.line", "line_id"]})
return res

View File

@@ -0,0 +1,427 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from .constants import PLAN_IS_EMPTY, PLAN_STOPPED
class CxTowerPlanLog(models.Model):
"""Flight Plan Log"""
_name = "cx.tower.plan.log"
_description = "Cetmix Tower Flight Plan Log"
_order = "start_date desc, id desc"
active = fields.Boolean(default=True)
name = fields.Char(compute="_compute_name", compute_sudo=True, store=True)
label = fields.Char(
help="Custom label. Can be used for search/tracking",
index="trigram",
unaccent=False,
)
server_id = fields.Many2one(
comodel_name="cx.tower.server", required=True, index=True, ondelete="cascade"
)
plan_id = fields.Many2one(
string="Flight Plan",
comodel_name="cx.tower.plan",
required=True,
index=True,
ondelete="cascade",
)
access_level = fields.Selection(
related="plan_id.access_level",
readonly=True,
store=True,
index=True,
)
# -- Time
start_date = fields.Datetime(string="Started")
finish_date = fields.Datetime(string="Finished")
duration = fields.Float(
help="Time consumed for execution, seconds",
compute="_compute_duration",
store=True,
)
duration_current = fields.Float(
string="Duration, sec",
compute="_compute_duration_current",
help="For how long a flight plan is already running",
)
# -- Commands
is_running = fields.Boolean(
help="Plan is being executed right now", compute="_compute_duration", store=True
)
is_stopped = fields.Boolean(
string="Stopped", default=False, help="Flight plan was stopped by user"
)
plan_line_executed_id = fields.Many2one(
comodel_name="cx.tower.plan.line",
help="Flight Plan line that is being currently executed",
)
command_log_ids = fields.One2many(
comodel_name="cx.tower.command.log", inverse_name="plan_log_id", auto_join=True
)
plan_status = fields.Integer(
string="Status",
help="0 if plan is finished successfully. \n"
"-301 if another instance of this flight plan is running, \n"
"-302 if plan is empty, \n"
"-303 if plan reference is missing, \n"
"-304 if plan line reference is missing, \n"
"-306 if plan is not compatible with server,\n"
"-308 if plan is stopped by user",
)
custom_message = fields.Text(
help="Custom message to be displayed in the plan log",
)
parent_flight_plan_log_id = fields.Many2one(
"cx.tower.plan.log", string="Main Log", ondelete="cascade"
)
scheduled_task_id = fields.Many2one(
"cx.tower.scheduled.task",
ondelete="set null",
help="Scheduled task that triggered this flight plan",
)
variable_values = fields.Json(
default={},
help="Custom variable values passed to the flight plan",
)
@api.depends("server_id.name", "name")
def _compute_name(self):
for rec in self:
rec.name = ": ".join((rec.server_id.name, rec.plan_id.name)) # type: ignore
@api.depends("start_date", "finish_date")
def _compute_duration(self):
for plan_log in self:
# Not started yet
if not plan_log.start_date:
continue
# If plan is finished, compute duration
if plan_log.finish_date:
plan_log.update(
{
"duration": (
plan_log.finish_date - plan_log.start_date
).total_seconds(),
"is_running": False,
}
)
continue
# If plan is running, set is_running to True
plan_log.is_running = True
@api.depends("is_running")
def _compute_duration_current(self):
"""Shows relative time between now() and start time for running plans,
and computed duration for finished ones.
"""
now = fields.Datetime.now()
for plan_log in self:
if plan_log.is_running:
plan_log.duration_current = (now - plan_log.start_date).total_seconds()
else:
plan_log.duration_current = plan_log.duration
def start(self, server, plan, start_date=None, **kwargs):
"""
Runs plan on server.
Creates initial log records for each command that cannot be executed until
it finds the first executable command.
Args:
server (cx.tower.server()) server.
plan (cx.tower.plan()) Flight Plan.
start_date (datetime) flight plan start date time.
**kwargs (dict): optional values
Following keys are supported but not limited to:
- "plan_log": {values passed to flightplan logger}
- "log": {values passed to logger}
- "key": {values passed to key parser}
- "no_command_log" (bool): If True, no logs will be recorded for
non-executable lines.
- "variable_values", dict(): custom variable values
in the format of `{variable_reference: variable_value}`
eg `{'odoo_version': '16.0'}`
Will be applied only if user has write access to the server.
Returns:
cx.tower.plan.log(): New flightplan log record.
"""
def get_executable_line(plan, server, variable_values=None):
"""
Generator to get each line and check if it's executable.
"""
for line in plan.line_ids:
yield (
line,
line._is_executable_line(server, variable_values=variable_values),
)
vals = {
"server_id": server.id,
"plan_id": plan.id,
"is_running": True,
"start_date": start_date or fields.Datetime.now(),
}
# Extract and apply plan log kwargs
plan_log_kwargs = kwargs.get("plan_log")
if plan_log_kwargs:
vals.update(plan_log_kwargs)
# Extract and apply variable values
variable_values = kwargs.get("variable_values")
if variable_values:
vals["variable_values"] = variable_values
plan_log = self.sudo().create(vals)
# Process each line until the first executable one is found
for line, is_executable in get_executable_line(
plan, server, variable_values=variable_values
):
if is_executable:
line._run(server, plan_log, **kwargs)
break
else:
if self._context.get("no_command_log"):
continue
line._skip(
server,
plan_log,
log={"variable_values": dict(variable_values or {})},
)
break
else:
plan_log.finish(plan_status=PLAN_IS_EMPTY)
return plan_log
def stop(self):
"""
Force stop this plan log (and currently running command if possible).
"""
user_name = self.env.user.name
for log in self:
if not log.is_running:
continue
# Finish plan log
log.finish(
plan_status=PLAN_STOPPED,
custom_message=_("Stopped by user %(user)s", user=user_name),
is_stopped=True,
)
# Stop running command
running_cmd_logs = log.command_log_ids.filtered(lambda c: c.is_running)
running_cmd_logs.stop()
def action_stop(self):
"""
Action to stop the running plans.
"""
self.stop()
if len(self) > 1: # more than one plan is running
title = _("Flight Plans Stopped")
message = ", ".join([plan.name for plan in self])
else:
title = _("Flight Plan Stopped")
message = self.name
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": title,
"message": message,
"sticky": False,
"next": {
"type": "ir.actions.act_window_close",
},
},
}
def finish(self, plan_status, **kwargs):
"""Finish plan execution
Args:
plan_status (Integer) plan execution code
**kwargs (dict): optional values
"""
values = {
"is_running": False,
"plan_status": plan_status,
"finish_date": fields.Datetime.now(),
}
self.ensure_one()
# Apply kwargs
if kwargs:
values.update(kwargs)
self.sudo().write(values)
# Call hook
self._plan_finished()
# Check if we were deleting a server
if (
self.server_id._is_being_deleted()
and self.server_id.plan_delete_id == self.plan_id
):
if plan_status == 0:
# And finally delete the server
self.with_context(server_force_delete=True).server_id.unlink()
else:
# Set deletion error if flightplan failed
self.server_id.status = "delete_error"
def record(self, server, plan, status, start_date=None, finish_date=None, **kwargs):
"""
Record plan log without running it.
Args:
server (cx.tower.server()) server.
plan (cx.tower.plan()) Flight Plan.
status (int) plan execution code
start_date (datetime) flight plan start date time.
finish_date (datetime) flight plan finish date time.
**kwargs (dict): optional values
Following keys are supported but not limited to:
- "plan_log": {values passed to flightplan logger}
- "log": {values passed to logger}
- "key": {values passed to key parser}
- "no_command_log" (bool): If True, no logs will be recorded for
non-executable lines.
Returns:
cx.tower.plan.log(): New flightplan log record.
"""
default_date = fields.Datetime.now()
vals = {
"server_id": server.id,
"plan_id": plan.id,
"start_date": start_date or default_date,
"finish_date": finish_date or default_date,
"plan_status": status,
}
# Extract and apply plan log kwargs
plan_log_kwargs = kwargs.get("plan_log")
if plan_log_kwargs:
vals.update(plan_log_kwargs)
plan_log = self.sudo().create(vals)
plan_log._plan_finished()
return plan_log
def _plan_finished(self):
"""Triggered when flightplan in finished
Inherit to implement your own hooks
Returns:
bool: True if event was handled
"""
self.ensure_one()
# Do not notify if a plan that was run from another plan has been executed
if self.parent_flight_plan_log_id:
return True
# Check if notifications are enabled
ICP_sudo = self.env["ir.config_parameter"].sudo()
notification_type_success = ICP_sudo.get_param(
"cetmix_tower_server.notification_type_success"
)
notification_type_error = ICP_sudo.get_param(
"cetmix_tower_server.notification_type_error"
)
# Prepare notifications
if not notification_type_success and not notification_type_error:
return True
# Use context timestamp to avoid timezone issues
context_timestamp = fields.Datetime.context_timestamp(
self, fields.Datetime.now()
)
# Action for button
action = self.env["ir.actions.act_window"]._for_xml_id(
"cetmix_tower_server.action_cx_tower_plan_log"
)
context = self.env.context.copy()
params = dict(context.get("params") or {})
params["button_name"] = _("View Log")
context["params"] = params
# Add record id and context to the action
action.update(
{
"context": context,
"res_id": self.id,
"views": [(False, "form")],
}
)
# Send notification
if self.plan_status == 0 and notification_type_success:
# Success notification
self.create_uid.notify_success(
message=_(
"%(timestamp)s<br/>" "Flight Plan '%(name)s' finished successfully",
name=self.plan_id.name,
timestamp=context_timestamp,
),
title=self.server_id.name,
sticky=notification_type_success == "sticky",
action=action,
)
# Error notification
if self.plan_status != 0 and notification_type_error:
self.create_uid.notify_danger(
message=_(
"%(timestamp)s<br/>"
"Flight Plan '%(name)s'"
" finished with error",
name=self.plan_id.name,
timestamp=context_timestamp,
),
title=self.server_id.name,
sticky=notification_type_error == "sticky",
action=action,
)
return True
def _plan_command_finished(self, command_log):
"""This function is triggered when a command from this log is finished.
Next action is triggered based on command status (ak exit code)
Args:
command_log (cx.tower.command.log()): Command log object
"""
self.ensure_one()
# Prevent scheduling further actions if this log was stopped
if self.is_stopped:
return
# Update plan log variable values from command log
# Overwrite with command log values (last command's values take precedence)
self.variable_values = command_log.variable_values
# Get next line to execute
self.plan_id._run_next_action(command_log) # type: ignore

View File

@@ -0,0 +1,481 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import re
from collections import defaultdict
from odoo import _, api, fields, models
from odoo.osv import expression
from odoo.tools import ormcache
class CxTowerReferenceMixin(models.AbstractModel):
"""
Used to create and manage unique record references.
"""
_name = "cx.tower.reference.mixin"
_description = "Cetmix Tower reference mixin"
_rec_names_search = ["name", "reference"]
# Used to check the reference before it's being fixed.
# Ensures there's at least one valid symbol
# that can be used later as a new reference basis.
REFERENCE_PRELIMINARY_PATTERN = r"[\da-zA-Z]"
name = fields.Char(required=True, index="trigram")
reference = fields.Char(
index=True,
unaccent=False,
help="Can contain English letters, digits and '_'. Leave blank to autogenerate",
)
_sql_constraints = [
("reference_unique", "UNIQUE(reference)", "Reference must be unique")
]
@api.model_create_multi
def create(self, vals_list):
"""
Overrides create to ensure 'reference' is auto-corrected
or validated for each record.
Add `reference_mixin_override` context key to skip the reference check
Args:
vals_list (list[dict]): List of dictionaries with record values.
Returns:
Records: The created record(s).
"""
if vals_list and not self._context.get("reference_mixin_override"):
# Check if we need to populate references based on parent record
auto_generate_settings = self._get_pre_populated_model_data().get(
self._name
)
if auto_generate_settings:
parent_model, relation_field = auto_generate_settings
vals_list = self._pre_populate_references(
parent_model, relation_field, vals_list
)
# Fix or create references
for vals in vals_list:
if not vals:
continue
# Remove leading and trailing whitespaces from name
vals_name = vals.get("name")
name = vals_name.strip() if vals_name else vals_name
# Remove leading and trailing whitespaces from reference
vals_reference = vals.get("reference")
reference = vals_reference.strip() if vals_reference else vals_reference
# Nothing can be done if no name or reference is provided
if not name and not reference:
continue
# Save name back to vals if it was modified
if vals_name != name:
vals["name"] = name
# Generate reference
vals.update(
{"reference": self._generate_or_fix_reference(reference or name)}
)
res = super().create(vals_list)
self.clear_caches()
return res
def write(self, vals):
"""
Updates record, auto-correcting or validating 'reference'
based on 'name' or existing value.
Add `reference_mixin_override` context key to skip the reference check
Args:
vals (dict): Values to update, may include 'reference'.
Returns:
Result of the super `write` call.
"""
if not self._context.get("reference_mixin_override") and "reference" in vals:
reference = vals.get("reference", False)
if not reference:
# Get name from vals
updated_name = vals.get("name")
# No name in vals. Update records one by one
if not updated_name:
for record in self:
record_vals = vals.copy()
record_vals.update(
{"reference": self._generate_or_fix_reference(record.name)}
)
super(CxTowerReferenceMixin, record).write(record_vals)
return True
# Name is present in vals
reference = self._generate_or_fix_reference(updated_name)
else:
reference = self._generate_or_fix_reference(reference)
vals.update({"reference": reference})
res = super().write(vals)
# Update references of dependent models
if "reference" in vals:
self._update_dependent_model_references()
# Clear caches
self.clear_caches()
return res
def unlink(self):
"""
Overrides unlink to clear cache for this method
"""
res = super().unlink()
self.clear_caches()
return res
def copy(self, default=None):
"""
Overrides the copy method to ensure unique reference values
for duplicated records.
Args:
default (dict, optional): Default values for the new record.
Returns:
Record: The newly copied record with adjusted defaults.
"""
self.ensure_one()
if default is None:
default = {}
# skip copying 'name' because this function can be used in models
# where 'name' field is not stored
if not self.env.context.get("reference_mixin_skip_copy"):
default["name"] = self._get_copied_name(force_name=default.get("name"))
if "reference" not in default:
default["reference"] = self._generate_or_fix_reference(default["name"])
return super().copy(default=default)
def _get_reference_pattern(self):
"""
Returns the regex pattern used for validating and correcting references.
This allows for easy modification of the pattern in one place.
Important: pattern must be enclosed in square brackets!
Returns:
str: A regex pattern
"""
return "[a-z0-9_]"
def _get_pre_populated_model_data(self):
"""Returns List of models that should try to generate
references based on the related model reference.
Eg flight plan lines references are generated based on the flight plan one.
Returns:
dict: Model values dictionary:
{model_name: [parent_model, relation_field]}
"""
return {}
def _get_extra_vals_fields(self):
"""Returns list of extra fields that are needed for reference generation.
This method if used to make custom reference generation logic more flexible.
Eg for 'cx.tower.variable.value':
'server_id', 'server_template_id', 'plan_line_action_id'.
So for common models like 'cx.tower.server' this method is not needed.
Returns:
list: List of fields:
[field_name1, field_name2, ...]
"""
return []
def _get_dependent_model_relation_fields(self):
"""Returns list of fields that reference dependent models.
Eg flight plan lines references are generated based on the flight plan one.
Returns:
list: List of fields:
[field_name1, field_name2, ...]
"""
return []
def _update_dependent_model_references(self):
"""Update references of dependent models"""
dependent_model_relation_fields = self._get_dependent_model_relation_fields()
if dependent_model_relation_fields:
for field in dependent_model_relation_fields:
related_model_name = self[field]._name
# Check if the related model has auto-generate settings
auto_generate_settings = (
self[field]._get_pre_populated_model_data().get(related_model_name)
)
if auto_generate_settings:
parent_model, relation_field = auto_generate_settings
else:
continue
# Parse the field for all records
for record in self:
related_records = record[field]
# Get vals list
rec_vals_list = related_records.read(
[relation_field] + related_records._get_extra_vals_fields()
)
# Transform Many2one tuples to IDs
for rv in rec_vals_list:
for k, v in rv.items():
# Transform m2o fields from (id, name) to id
if isinstance(v, tuple):
rv[k] = v[0]
related_records._pre_populate_references(
parent_model, relation_field, rec_vals_list
)
ref_by_id = {rv["id"]: rv["reference"] for rv in rec_vals_list}
for related_record in related_records:
related_record.reference = ref_by_id[related_record.id]
def _generate_or_fix_reference(self, reference_source):
"""
Generate a new reference of fix an existing one.
Args:
reference_source (str): Original string.
Returns:
str: Generated or fixed reference.
"""
# Check if reference matches the pattern
reference_pattern = self._get_reference_pattern()
if re.fullmatch(rf"{reference_pattern}+", reference_source):
reference = reference_source
# Fix reference if it doesn't match
else:
# Modify the pattern to be used in `sub`
inner_pattern = reference_pattern[1:-1]
reference = (
re.sub(
rf"[^{inner_pattern}]",
"",
reference_source.strip().replace(" ", "_").lower(),
)
or self._get_model_generic_reference()
)
# Check if the same reference already exists and add a suffix if yes
counter = 1
final_reference = reference
# If exclude same records from search results
if self and not self.env.context.get("reference_mixin_skip_self"):
domain = [("id", "not in", self.ids)]
else:
domain = []
final_domain = expression.AND([domain, [("reference", "=", final_reference)]])
# Search all records without restrictions including archived
self_with_sudo_and_context = self.sudo().with_context(active_test=False)
while self_with_sudo_and_context.search_count(final_domain) > 0:
counter += 1
final_reference = f"{reference}_{counter}"
final_domain = expression.AND(
[domain, [("reference", "=", final_reference)]]
)
return final_reference
def _get_copied_name(self, force_name=None):
"""
Return a copied name of the record
by adding the suffix (copy) at the end
and counter until the name is unique.
Args:
force_name (str): Used to use force name instead of record name.
Returns:
An unique name for the copied record
"""
self.ensure_one()
original_name = force_name or self.name
copy_name = _("%(name)s (copy)", name=original_name)
counter = 1
# Ensures that the generated copy name is unique by
# appending a counter until a unique name is found.
while self.search_count([("name", "=", copy_name)]) > 0:
counter += 1
copy_name = _(
"%(name)s (copy %(number)s)",
name=original_name,
number=str(counter),
)
return copy_name
def _get_model_generic_reference(self):
"""Get generic reference for current model.
Generic references are used as a fallback in the automatic
reference generation.
When a reference cannot be generated neither from the 'reference'
nor from the 'name' field values.
Eg for the 'cx.tower.plan' model such reference will look like
'tower_plan'.
Returns:
Char: generated prefix
"""
model_prefix = self._name.replace("cx.tower.", "").replace(".", "_")
return model_prefix
def get_by_reference(self, reference):
"""Get record based on its reference.
Important: references are case sensitive!
Args:
reference (Char): record reference
Returns:
Record: Record that matches provided reference
"""
return self.browse(self._get_id_by_reference(reference))
@ormcache("self.env.uid", "self.env.su", "reference")
def _get_id_by_reference(self, reference):
"""Get record id based on its reference.
Important: references are case sensitive!
Args:
reference (Char): record reference
Returns:
Record: Record id that matches provided reference
"""
records = self.search([("reference", "=", reference)])
# This is in case some models will remove reference uniqueness constraint
return records and records[0].id
@api.model
def _prepare_references(self, model, key_name, vals_list):
"""
Prepare a dictionary of references for given model records.
This function extracts unique IDs from a list of dictionaries (vals_list)
based on a specified key (key_name), fetches the corresponding records
from the specified model, and returns a dictionary mapping record IDs to
their references.
Args:
model (str): The name of the model to fetch records from.
key_name (str): The key in the dictionaries of vals_list that contains
the record IDs.
vals_list (list of dict): A list of dictionaries containing the values
to be processed.
Returns:
dict: A dictionary mapping record IDs to their references.
"""
if not vals_list:
# No entries to process, return an empty dictionary
return {}
try:
CxModel = self.env[model]
except KeyError as err:
raise ValueError(
_(
(
"Model '%(model)s' does not exist. "
"Please provide a valid model name."
),
model=model,
)
) from err
# Extract all unique ids from vals_list
line_ids = {
vals.get(key_name)
for vals in vals_list
if vals.get(key_name) and not vals.get("reference")
}
# Fetch all line references in a single query
lines = CxModel.browse(line_ids)
return {line.id: line.reference for line in lines if line.reference}
@api.model
def _pre_populate_references(self, model_name, field_name, vals_list):
"""
Populates reference fields in a list of dictionaries (vals_list)
intended for record creation.
This method generates unique references for each dictionary entry in
`vals_list` based on a specified field that links to records in
another model (indicated by `model_name`). It uses existing references
from the related records as a basis and appends a suffix and an
incrementing index to ensure uniqueness.
If reference is present in values it will not be overwritten.
Args:
model_name (str): The name of the related model to extract
reference data from.
field_name (str): The key in each dictionary in `vals_list`
containing the related record's ID.
vals_list (list of dict): A list of dictionaries where each dictionary
represents values for a new record.
Returns:
list: The modified `vals_list`, with a unique 'reference'
entry in each dictionary.
"""
# Extract parent record references from vals_list
parent_record_refs = self._prepare_references(model_name, field_name, vals_list)
line_index_dict = defaultdict(int)
# Used to make reference more readable
model_reference = self._get_model_generic_reference()
# Populate vals with references
for vals in vals_list:
# Skip if reference is provided explicitly and has symbols
existing_reference = vals.get("reference")
if existing_reference and bool(
re.search(self.REFERENCE_PRELIMINARY_PATTERN, existing_reference)
):
continue
# Compose based on related record reference if exists
record_id = vals.get(field_name)
if record_id and parent_record_refs.get(record_id):
line_index_dict[record_id] += 1
line_index = line_index_dict[record_id]
vals[
"reference"
] = f"{parent_record_refs[record_id]}_{model_reference}_{line_index}"
else:
# Handle cases where the field is not present
line_index_dict["no_record"] += 1
line_index = line_index_dict["no_record"]
vals["reference"] = f"no_{model_reference}_{line_index}"
return vals_list

View File

@@ -0,0 +1,296 @@
import logging
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class CxTowerScheduledTask(models.Model):
_name = "cx.tower.scheduled.task"
_description = "Scheduled Task"
_inherit = ["cx.tower.access.role.mixin", "cx.tower.reference.mixin"]
_order = "sequence, next_call"
active = fields.Boolean(default=True)
sequence = fields.Integer(default=10)
server_ids = fields.Many2many(
"cx.tower.server",
"cx_tower_scheduled_task_server_rel",
"scheduled_task_id",
"server_id",
string="Servers",
)
server_template_ids = fields.Many2many(
string="Server Templates",
comodel_name="cx.tower.server.template",
relation="cx_tower_server_template_scheduled_task_rel",
column1="scheduled_task_id",
column2="server_template_id",
)
action = fields.Selection(
[("command", "Command"), ("plan", "Flight Plan")], required=True
)
command_id = fields.Many2one("cx.tower.command", string="Command")
plan_id = fields.Many2one(string="Flight Plan", comodel_name="cx.tower.plan")
is_running = fields.Boolean(default=False, readonly=True)
interval_number = fields.Integer(default=1, help="Repeat every x.")
interval_type = fields.Selection(
[
("minutes", "Minutes"),
("hours", "Hours"),
("days", "Days"),
("weeks", "Weeks"),
("months", "Months"),
],
string="Interval Unit",
default="months",
)
next_call = fields.Datetime(
string="Next Execution Date",
required=True,
default=fields.Datetime.now,
help="Next planned execution date for this task.",
)
last_call = fields.Datetime(
string="Last Execution Date", help="Previous time the task ran successfully."
)
custom_variable_value_ids = fields.One2many(
"cx.tower.scheduled.task.cv",
"scheduled_task_id",
string="Custom Variable Values",
)
warning_message = fields.Text(
compute="_compute_warning_message",
)
# ---- Access. Add relation for mixin fields
user_ids = fields.Many2many(
relation="cx_tower_scheduled_task_user_rel",
)
manager_ids = fields.Many2many(
relation="cx_tower_scheduled_task_manager_rel",
)
_sql_constraints = [
(
"interval_positive",
"CHECK (interval_number > 0)",
"Interval number must be greater than zero.",
),
]
@api.depends("interval_number", "interval_type")
def _compute_warning_message(self):
"""
Show warning on the task form if interval in the scheduled task
is less than interval in the underlaying cron job.
"""
cron = self.env.ref(
"cetmix_tower_server.ir_cron_run_scheduled_tasks", raise_if_not_found=False
)
if not cron:
self.warning_message = False
return
# Using now's date as the base point ensures a consistent and comparable
# reference when calculating the next scheduled execution for both the cron
# and the tasks.
now = fields.Datetime.now()
# _get_next_call is designed for tasks, but can also be used for the
# cron record, as both share the same interval fields. This keeps interval
# comparison logic consistent.
cron_next = self._get_next_call(cron, now)
for task in self:
task_next = self._get_next_call(task, now)
if task_next < cron_next:
task.warning_message = _(
"The selected task interval is too low in relation to the general "
"system settings. This may lead to task execution delays."
)
else:
task.warning_message = False
def action_run(self):
"""
Run scheduled action and reschedule next call.
"""
return self._run()
def action_open_command_logs(self):
"""
Open current scheduled task command log records
"""
action = self.env["ir.actions.actions"]._for_xml_id(
"cetmix_tower_server.action_cx_tower_command_log"
)
action["domain"] = [("scheduled_task_id", "=", self.id)] # pylint: disable=no-member
return action
def action_open_plan_logs(self):
"""
Open current scheduled task flightplan log records
"""
action = self.env["ir.actions.actions"]._for_xml_id(
"cetmix_tower_server.action_cx_tower_plan_log"
)
action["domain"] = [("scheduled_task_id", "=", self.id)] # pylint: disable=no-member
return action
@api.model
def _run_scheduled_tasks(self):
"""
Cron: finds due tasks and runs their actions (command/plan).
Handles errors per-task and reserves tasks atomically to avoid double execution.
"""
now = fields.Datetime.now()
due_tasks = self.search(
[
("next_call", "<=", now),
("active", "=", True),
("is_running", "=", False),
]
)
if not due_tasks:
return
due_tasks.with_context(from_cron=True)._run()
def _run(self):
"""
Run scheduled action and reschedule next call.
"""
tasks = self._reserve_tasks()
if not tasks:
return
if self.env.context.get("from_cron"):
# WARNING: Explicit commit!
# This commit is made **only** when called from cron (context["from_cron"]).
# Reason: To atomically reserve scheduled tasks by setting is_running=True,
# so that only one cron worker processes each task, even if multiple workers
# pick up the cron job at the same time. Without this commit, the change
# would not be visible to other transactions until the end of the cron
# transaction, leading to a race condition and possible double execution.
# Explicit commits are strongly discouraged in Odoo business logic and
# should be used only with clear justification and in strictly controlled
# contexts (like this cron scenario). Never add this commit for general
# business flows!
self.env.cr.commit() # pylint: disable=invalid-commit
errors = []
for task in tasks:
try:
with self.env.cr.savepoint():
if task.action == "command" and task.command_id:
task._run_command()
elif task.action == "plan" and task.plan_id:
task._run_plan()
except Exception as e:
_logger.exception(f"Scheduled task {task.id} failed: {e}")
task_error = _(
"Unable to run scheduled task '%(f)s'. Error: %(e)s",
f=task.display_name,
e=e,
)
errors.append(task_error)
finally:
finished_at = fields.Datetime.now()
# Always update the scheduling, even if the task failed
task.write(
{
"last_call": finished_at,
"next_call": self._get_next_call(task, finished_at),
"is_running": False,
}
)
if errors:
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Failure"),
"message": "\n".join(errors),
},
}
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Success"),
"message": _("Scheduled tasks run successfully."),
},
}
def _get_next_call(self, task, from_date):
"""
Calculate next_call datetime
"""
num = task.interval_number or 1
intervals = {
"minutes": timedelta(minutes=num),
"hours": timedelta(hours=num),
"days": timedelta(days=num),
"weeks": timedelta(weeks=num),
"months": relativedelta(months=num),
}
return from_date + intervals.get(task.interval_type, timedelta())
def _run_command(self):
"""Run command on selected servers."""
variable_values = {
value.variable_id.reference: value.value_char
for value in self.custom_variable_value_ids
}
kwargs = {
"log": {"scheduled_task_id": self.id},
"variable_values": variable_values,
}
for server in self.server_ids:
server.run_command(self.command_id, **kwargs)
def _run_plan(self):
"""Run flight plan on selected servers."""
variable_values = {
value.variable_id.reference: value.value_char
for value in self.custom_variable_value_ids
}
kwargs = {
"plan_log": {"scheduled_task_id": self.id},
"variable_values": variable_values,
}
for server in self.server_ids:
server.run_flight_plan(self.plan_id, **kwargs)
def _reserve_tasks(self, limit=None):
"""
Atomically select and lock free tasks for processing.
"""
sql = """
SELECT id
FROM cx_tower_scheduled_task
WHERE is_running = FALSE AND id IN %s
ORDER BY id
"""
params = [tuple(self.ids)]
if limit:
sql += " LIMIT %s"
params.append(limit)
sql += " FOR UPDATE SKIP LOCKED"
self.env.cr.execute(sql, tuple(params))
task_ids = [row[0] for row in self.env.cr.fetchall()]
if not task_ids:
return self.browse()
tasks = self.browse(task_ids)
tasks.write({"is_running": True})
return tasks

View File

@@ -0,0 +1,18 @@
from odoo import fields, models
class CxTowerScheduledTaskCv(models.Model):
"""
Custom variable values for scheduled tasks.
"""
_inherit = "cx.tower.custom.variable.value.mixin"
_name = "cx.tower.scheduled.task.cv"
_description = "Custom variable values for scheduled tasks"
scheduled_task_id = fields.Many2one(
"cx.tower.scheduled.task",
string="Scheduled Task",
required=True,
ondelete="cascade",
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
# Copyright (C) 2022 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from ansi2html import Ansi2HTMLConverter
from odoo import _, api, fields, models
from odoo.exceptions import AccessError
_logger = logging.getLogger(__name__)
html_converter = Ansi2HTMLConverter(inline=True)
class CxTowerServerLog(models.Model):
"""Server log management.
Used to track various server logs.
N.B. Do not mistake for command of flight plan log!
"""
_name = "cx.tower.server.log"
_inherit = ["cx.tower.access.mixin", "cx.tower.reference.mixin"]
_description = "Cetmix Tower Server Log"
NO_LOG_FETCHED_MESSAGE = _("<log is empty>")
active = fields.Boolean(default=True)
server_id = fields.Many2one("cx.tower.server", ondelete="cascade")
log_type = fields.Selection(
selection=lambda self: self._selection_log_type(),
required=True,
groups="cetmix_tower_server.group_root,cetmix_tower_server.group_manager",
default=lambda self: self._selection_log_type()[0][0],
)
command_id = fields.Many2one(
"cx.tower.command",
domain="[('action', 'in', ['ssh_command', 'python_code']), "
"'|', ('server_ids', 'in', [server_id]), ('server_ids', '=', False)]",
groups="cetmix_tower_server.group_root,cetmix_tower_server.group_manager",
help="Command that will be executed to get the log data.\n"
"Be careful with commands that don't support parallel execution!",
)
use_sudo = fields.Boolean(
groups="cetmix_tower_server.group_root,cetmix_tower_server.group_manager",
help="Will use sudo based on server settings."
"If no sudo is configured will run without sudo",
)
file_id = fields.Many2one(
"cx.tower.file",
domain="[('server_id', '=', server_id)]",
groups="cetmix_tower_server.group_root,cetmix_tower_server.group_manager",
help="File that will be executed to get the log data",
)
log_text = fields.Text(readonly=True, copy=False)
log_html = fields.Html(compute="_compute_log_html")
# --- Server template related
server_template_id = fields.Many2one("cx.tower.server.template", ondelete="cascade")
file_template_id = fields.Many2one(
"cx.tower.file.template",
ondelete="cascade",
groups="cetmix_tower_server.group_root,cetmix_tower_server.group_manager",
help="This file template will be used to create log files"
" when server is created from a template",
)
def _selection_log_type(self):
"""Actions that can be run by a command.
Returns:
List of tuples: available options.
"""
return [
("command", "Command"),
("file", "File"),
]
@api.depends("log_text")
def _compute_log_html(self):
for record in self:
if record.log_text:
try:
record.log_html = html_converter.convert(record.log_text)
# We catch all exceptions to avoid breaking the log display
except Exception as e:
_logger.error("Error converting log text to HTML: %s", e)
record.log_html = False
else:
record.log_html = False
def copy(self, default=None):
return super(
CxTowerServerLog, self.with_context(reference_mixin_skip_self=True)
).copy(default)
def action_open_log(self):
"""
Open log record in current window
"""
self.ensure_one()
self.action_update_log()
return {
"type": "ir.actions.act_window",
"name": self.name,
"res_model": "cx.tower.server.log",
"res_id": self.id, # pylint: disable=no-member
"view_mode": "form",
"target": "current",
}
def write(self, vals):
"""Override to protect log_text from direct modifications.
Bypass with context key 'cx_allow_log_text_update' for internal updates.
"""
if "log_text" in vals and not self.env.context.get("cx_allow_log_text_update"):
raise AccessError(_("You are not allowed to modify the server log output."))
return super().write(vals)
def action_update_log(self):
"""Update log text from source"""
# We are using `sudo` to override command/file access limitations
for rec in self.sudo().with_context(cx_allow_log_text_update=True):
rec.log_text = rec._get_formatted_log_text()
def _get_log_text(self):
"""
Get log text from source
Use this function to get pure log text from source.
Returns:
Text: log text
"""
self.ensure_one()
if self.log_type == "file" and self.file_id:
return self._get_log_from_file()
elif self.log_type == "command" and self.command_id:
return self._get_log_from_command()
def _get_formatted_log_text(self):
"""
Get formatted log text.
Use this function to get formatted log text.
Returns:
Text: formatted log text
"""
log_text = self._get_log_text()
if log_text:
return self._format_log_text(log_text)
return self.NO_LOG_FETCHED_MESSAGE
def _format_log_text(self, log_text):
"""
Format log text.
Use this function to format log text.
Returns:
Text: formatted log text
"""
return log_text
def _get_log_from_file(self):
"""Get log from a file.
Override this function to implement custom log handler
Returns:
Text: log text
"""
self.ensure_one()
if self.file_id.source == "server":
return self.file_id.code
if self.file_id.source == "tower":
return self.file_id.code_on_server
def _get_log_from_command(self):
"""Get log from a command.
Returns:
Text: log text
"""
self.ensure_one()
use_sudo = self.use_sudo and self.server_id.use_sudo
command_result = self.server_id.with_context(no_command_log=True).run_command(
self.command_id, sudo=use_sudo
)
log_text = self.NO_LOG_FETCHED_MESSAGE
if command_result:
response = command_result["response"]
error = command_result["error"]
if response:
log_text = response
elif error:
log_text = error
return log_text
def _get_copied_name(self, force_name=None):
# Original name is preserved when log is duplicated
return force_name or self.name

View File

@@ -0,0 +1,653 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class CxTowerServerTemplate(models.Model):
"""Server Template. Used to simplify server creation"""
_name = "cx.tower.server.template"
_inherit = [
"cx.tower.reference.mixin",
"mail.thread",
"mail.activity.mixin",
"cx.tower.access.role.mixin",
"cx.tower.tag.mixin",
]
_description = "Cetmix Tower Server Template"
_order = "name"
active = fields.Boolean(default=True)
# --- Connection
ssh_port = fields.Integer(string="SSH port", default=22)
ssh_username = fields.Char(string="SSH Username")
ssh_password = fields.Char(string="SSH Password")
ssh_key_id = fields.Many2one(
comodel_name="cx.tower.key",
string="SSH Private Key",
domain=[("key_type", "=", "k")],
)
ssh_auth_mode = fields.Selection(
string="SSH Auth Mode",
selection=[
("p", "Password"),
("k", "Key"),
],
)
use_sudo = fields.Selection(
string="Use sudo",
selection=[("n", "Without password"), ("p", "With password")],
help="Run commands using 'sudo'",
)
# --- Attributes
color = fields.Integer(help="For better visualization in views")
os_id = fields.Many2one(string="Operating System", comodel_name="cx.tower.os")
tag_ids = fields.Many2many(
relation="cx_tower_server_template_tag_rel",
column1="server_template_id",
column2="tag_id",
)
# --- Variables
# We are not using variable mixin because we don't need to parse values
variable_value_ids = fields.One2many(
string="Variable Values",
comodel_name="cx.tower.variable.value",
auto_join=True,
inverse_name="server_template_id",
)
# --- Server logs
server_log_ids = fields.One2many(
comodel_name="cx.tower.server.log", inverse_name="server_template_id"
)
# --- Shortcuts
shortcut_ids = fields.Many2many(
comodel_name="cx.tower.shortcut",
relation="cx_tower_server_template_shortcut_rel",
column1="server_template_id",
column2="shortcut_id",
string="Shortcuts",
)
# --- Scheduled Tasks
scheduled_task_ids = fields.Many2many(
comodel_name="cx.tower.scheduled.task",
relation="cx_tower_server_template_scheduled_task_rel",
column1="server_template_id",
column2="scheduled_task_id",
string="Scheduled Tasks",
)
# --- Flight Plan
flight_plan_id = fields.Many2one(
"cx.tower.plan",
help="This flight plan will be run upon server creation",
domain="[('server_ids', '=', False)]",
)
# ---- Delete plan
plan_delete_id = fields.Many2one(
"cx.tower.plan",
string="On Delete Plan",
groups="cetmix_tower_server.group_manager",
help="This Flightplan will be executed when the server is deleted",
)
# --- Created Servers
server_ids = fields.One2many(
comodel_name="cx.tower.server",
inverse_name="server_template_id",
)
server_count = fields.Integer(
compute="_compute_server_count",
)
# -- Other
note = fields.Text()
# ---- Access. Add relation for mixin fields
user_ids = fields.Many2many(
relation="cx_tower_server_template_user_rel",
domain=lambda self: [
("groups_id", "in", [self.env.ref("cetmix_tower_server.group_manager").id])
],
)
manager_ids = fields.Many2many(
relation="cx_tower_server_template_manager_rel",
)
@api.depends("server_ids")
def _compute_server_count(self):
"""
Compute total server counts created from the templates
"""
for template in self:
template.server_count = len(template.server_ids)
def copy(self, default=None):
"""Duplicate the server template along with variable values and server logs."""
default = dict(default or {})
# Duplicate the server template itself
new_template = super().copy(default)
# Duplicate variable values
for variable_value in self.variable_value_ids:
variable_value.with_context(reference_mixin_skip_self=True).copy(
{"server_template_id": new_template.id}
)
# Duplicate server logs
for server_log in self.server_log_ids:
server_log.copy({"server_template_id": new_template.id})
return new_template
def action_create_server(self):
"""
Returns wizard action to create new server
"""
self.ensure_one()
context = self.env.context.copy()
context.update(
{
"default_server_template_id": self.id, # pylint: disable=no-member
"default_color": self.color,
"default_ssh_port": self.ssh_port,
"default_ssh_username": self.ssh_username,
"default_ssh_password": self.ssh_password,
"default_ssh_key_id": self.ssh_key_id.id,
"default_ssh_auth_mode": self.ssh_auth_mode,
"default_plan_delete_id": self.plan_delete_id.id,
}
)
if self.variable_value_ids:
context.update(
{
"default_line_ids": [
(
0,
0,
{
"variable_value_id": line.id,
},
)
for line in self.variable_value_ids
]
}
)
return {
"type": "ir.actions.act_window",
"name": _("Create Server"),
"res_model": "cx.tower.server.template.create.wizard",
"view_mode": "form",
"target": "new",
"context": context,
}
def action_open_servers(self):
"""
Return action to open related servers
"""
self.ensure_one()
action = self.env["ir.actions.act_window"]._for_xml_id(
"cetmix_tower_server.action_cx_tower_server"
)
action.update(
{
"domain": [("server_template_id", "=", self.id)], # pylint: disable=no-member
}
)
return action
@api.model
def create_server_from_template(self, template_reference, server_name, **kwargs):
"""This is a wrapper function that is meant to be called
when we need to create a server from specific server template
Args:
template_reference (Char): Server template reference
server_name (Char): Name of the new server
Kwargs:
partner (res.partner(), optional): Partner this server belongs to.
ipv4 (Char, optional): IP v4 address. Defaults to None.
ipv6 (Char, optional): IP v6 address.
Must be provided in case IP v4 is not. Defaults to None.
ssh_password (Char, optional): SSH password. Defaults to None.
ssh_key (Char, optional): SSH private key record reference.
Defaults to None.
configuration_variables (Dict, optional): Custom configuration variable.
Following format is used:
`variable_reference`: `variable_value_char`
eg:
{'branch': 'prod', 'odoo_version': '16.0'}
pick_all_template_variables (bool): This parameter ensures that the server
being created considers existing variables from the template.
If enabled, the template variables will also be included in the server
variables. The default value is True.
Returns:
cx.tower.server: newly created server record
"""
template = self.get_by_reference(template_reference)
return template._create_new_server(server_name, **kwargs)
def _create_new_server(self, name, **kwargs):
"""Creates a new server from template
Args:
name (Char): Name of the new server
Kwargs:
partner (res.partner(), optional): Partner this server belongs to.
ipv4 (Char, optional): IP v4 address. Defaults to None.
ipv6 (Char, optional): IP v6 address.
Must be provided in case IP v4 is not. Defaults to None.
ssh_password (Char, optional): SSH password. Defaults to None.
ssh_key (Char, optional): SSH private key record reference.
Defaults to None.
configuration_variables (Dict, optional): Custom configuration variable.
Following format is used:
`variable_reference`: `variable_value_char`
eg:
{'branch': 'prod', 'odoo_version': '16.0'}
pick_all_template_variables (bool): This parameter ensures that the server
being created considers existing variables from the template.
If enabled, the template variables will also be included in the server
variables. The default value is True.
Returns:
cx.tower.server: newly created server record
"""
self.ensure_one()
# Retrieve the passed variables
configuration_variables = kwargs.get("configuration_variables", {})
# We validate mandatory variables
if not kwargs.get("pick_all_template_variables"):
self._validate_required_variables(configuration_variables)
# We are using sudo to ensure all values are copied
server_values = self.sudo()._prepare_server_values(
name=name,
server_template_id=self.id, # pylint: disable=no-member
**kwargs,
)
# Pop variable values to add them after server creation.
# This is needed to ensure that access rules are applied properly.
variable_values = server_values.pop("variable_value_ids")
# Prepare context for server creation
context = self.env.context.copy()
# SSH setting may be added after server creation.
context.update({"skip_ssh_settings_check": True})
# We need to remove default_server_template_id to avoid it being used
# in variable values.
context.pop("default_server_template_id", None)
# Create server
server = (
self.env["cx.tower.server"] # pylint: disable=context-overridden # new need a new clean context
.sudo()
.with_context(context)
.create(server_values)
.sudo()
)
# Add variable values
if variable_values:
server.with_context(context).write({"variable_value_ids": variable_values}) # pylint: disable=context-overridden # new need a new clean context
# Create server logs
logs = server.server_log_ids.filtered(lambda rec: rec.log_type == "file")
for log in logs.sudo():
log.file_id = log.file_template_id.create_file(
server=server, if_file_exists="skip"
).id
flight_plan = server.server_template_id.flight_plan_id
if flight_plan:
server.run_flight_plan(flight_plan)
return server
def _get_post_create_fields(self):
"""
Add fields that should be populated after server template creation
"""
res = super()._get_post_create_fields()
return res + ["variable_value_ids", "server_log_ids"]
def _get_fields_tower_server(self):
"""
Return field name list to read from template and create new server
"""
return [
"ssh_username",
"ssh_password",
"ssh_key_id",
"ssh_auth_mode",
"use_sudo",
"color",
"os_id",
"plan_delete_id",
"tag_ids",
"variable_value_ids",
"server_log_ids",
"shortcut_ids",
"scheduled_task_ids",
]
def _prepare_server_values(self, pick_all_template_variables=True, **kwargs):
"""
Prepare the server values to create a new server based on
the current template. It reads all fields from the template, copies them,
and processes One2many fields to create new related records. Magic fields
like 'id', concurrency fields, and audit fields are excluded from the copied
data.
Args:
pick_all_template_variables (bool): This parameter ensures that the server
being created considers existing variables from the template.
If enabled, the template variables will also be included in the server
variables. The default value is True.
**kwargs: Additional values to update in the final server record.
Returns:
list: A list of dictionaries representing the values for the new server
records.
"""
model_fields = self._fields
field_o2m_type = fields.One2many
# define the magic fields that should not be copied
# (including ID and concurrency fields)
MAGIC_FIELDS = models.MAGIC_COLUMNS + [self.CONCURRENCY_CHECK_FIELD]
# read all values required to create a new server from the template
values = self.read(self._get_fields_tower_server(), load=False)[0]
# prepare server config values from kwargs
server_config_values = self._parse_server_config_values(kwargs)
template = self.browse(values["id"])
# Process each field in the template
for field in values.keys():
if isinstance(model_fields[field], field_o2m_type):
# get related records for One2many field
related_records = getattr(template, field)
new_records = []
# for each related record, read its data and prepare it for copying
for record in related_records:
record_data = {
k: v
for k, v in record.read(load=False)[0].items()
if k not in MAGIC_FIELDS
}
# set the inverse field (link back to the template)
# to False to unlink from the original template
record_data[model_fields[field].inverse_name] = False
new_records.append((0, 0, record_data))
values[field] = new_records
# Handle configuration variables if provided.
configuration_variables = kwargs.pop("configuration_variables", None)
configuration_variable_options = kwargs.pop(
"configuration_variable_options", {}
)
if configuration_variables:
# Validate required variables
self._validate_required_variables(configuration_variables)
# Search for existing variable options.
option_references = list(configuration_variable_options.values())
existing_options = option_references and self.env[
"cx.tower.variable.option"
].search([("reference", "in", option_references)])
missing_options = list(
set(option_references)
- {option.reference for option in existing_options}
)
if missing_options:
# Map variable references to their corresponding
# invalid option references.
missing_options_to_variables = {
var_ref: opt_ref
for var_ref, opt_ref in configuration_variable_options.items()
if opt_ref in missing_options
}
# Generate a detailed error message for invalid variable options.
detailed_message = "\n".join(
_(
"Variable reference '%(var_ref)s' has an invalid "
"option reference '%(opt_ref)s'.",
var_ref=var_ref,
opt_ref=opt_ref,
)
for var_ref, opt_ref in missing_options_to_variables.items()
)
raise ValidationError(
_(
"Some variable options are invalid:\n%(detailed_message)s",
detailed_message=detailed_message,
)
)
# Map variable options to their IDs.
configuration_variable_options_dict = {
option.variable_id.id: option for option in existing_options
}
variable_obj = self.env["cx.tower.variable"]
variable_references = list(configuration_variables.keys())
# Search for existing variables or create new ones if missing.
exist_variables = variable_obj.search(
[("reference", "in", variable_references)]
)
missing_references = list(
set(variable_references)
- {variable.reference for variable in exist_variables}
)
variable_vals_list = [
{"name": reference} for reference in missing_references
]
new_variables = variable_obj.create(variable_vals_list)
all_variables = exist_variables | new_variables
# Build a dictionary {variable: variable_value}.
configuration_variable_dict = {
variable: configuration_variables[variable.reference]
for variable in all_variables
}
server_variable_vals_list = []
for variable, variable_value in configuration_variable_dict.items():
variable_option = configuration_variable_options_dict.get(variable.id)
server_variable_vals_list.append(
(
0,
0,
{
"variable_id": variable.id,
"value_char": variable_option
and variable_option.value_char
or variable_value,
"option_id": variable_option and variable_option.id,
},
)
)
if pick_all_template_variables:
# update or add variable values
existing_variable_values = values.get("variable_value_ids", [])
variable_id_to_index = {
cmd[2]["variable_id"]: idx
for idx, cmd in enumerate(existing_variable_values)
if cmd[0] == 0 and "variable_id" in cmd[2]
}
# Update exist variable options
for exist_variable_id, index in variable_id_to_index.items():
option = configuration_variable_options_dict.get(exist_variable_id)
if not option:
continue
existing_variable_values[index][2].update(
{
"option_id": option.id,
"value_char": option.value_char,
}
)
# Prepare new command values for server variables
for new_command in server_variable_vals_list:
variable_id = new_command[2]["variable_id"]
if variable_id in variable_id_to_index:
idx = variable_id_to_index[variable_id]
# update exist command
existing_variable_values[idx] = new_command
else:
# add new command
existing_variable_values.append(new_command)
values["variable_value_ids"] = existing_variable_values
else:
values["variable_value_ids"] = server_variable_vals_list
# remove the `id` field to ensure a new record is created
# instead of updating the existing one
del values["id"]
# update the values with additional arguments from kwargs
values.update(kwargs)
# update server configs
values.update(server_config_values)
# Add current user as user/manager to the newly created server
values.update(
{
"user_ids": [(6, 0, self._default_user_ids())],
"manager_ids": [(6, 0, self._default_manager_ids())],
}
)
return values
def _parse_server_config_values(self, config_values):
"""
Prepares server configuration values.
Args:
config_values (dict): A dictionary containing server configuration values.
Keys and their expected values:
- partner (res.partner, optional): The partner this server
belongs to.
- ipv4 (str, optional): IPv4 address. Defaults to None.
- ipv6 (str, optional): IPv6 address. Must be provided if IPv4 is
not specified. Defaults to None.
- ssh_key (str, optional): Reference to an SSH private key record.
Defaults to None.
Returns:
dict: A dictionary containing parsed server configuration values with the
following keys:
- partner_id (int, optional): ID of the partner.
- ssh_key_id (int, optional): ID of the associated SSH key.
- ip_v4_address (str, optional): Parsed IPv4 address.
- ip_v6_address (str, optional): Parsed IPv6 address.
"""
values = {}
# This field is always populated from Server Template and
# cannot be altered with function params.
config_values.pop("plan_delete_id", None)
partner = config_values.pop("partner", None)
if partner:
values["partner_id"] = partner.id
ssh_key_reference = config_values.pop("ssh_key", None)
if ssh_key_reference:
ssh_key = self.env["cx.tower.key"].get_by_reference(ssh_key_reference)
if ssh_key:
values["ssh_key_id"] = ssh_key.id
ipv4 = config_values.pop("ipv4", None)
if ipv4:
values["ip_v4_address"] = ipv4
ipv6 = config_values.pop("ipv6", None)
if ipv6:
values["ip_v6_address"] = ipv6
return values
def _validate_required_variables(self, configuration_variables):
"""
Validate that all required variables are present, not empty,
and that no required variable is entirely missing from the configuration.
Args:
configuration_variables (dict): A dictionary of variable references
and their values.
Raises:
ValidationError: If all required variables are
missing from the configuration,
or if any required variable is empty or missing.
"""
required_variables = self.variable_value_ids.filtered("required")
if not required_variables:
return
required_refs = [var.variable_reference for var in required_variables]
config_refs = list(configuration_variables.keys())
missing_variables = [ref for ref in required_refs if ref not in config_refs]
empty_variables = [
ref
for ref in required_refs
if ref in config_refs and not configuration_variables[ref]
]
if not (missing_variables or empty_variables):
return
error_parts = [
_("Please resolve the following issues with configuration variables:")
]
if missing_variables:
error_parts.append(
_(
" - Missing variables: %(variables)s",
variables=", ".join(missing_variables),
)
)
if empty_variables:
error_parts.append(
_(
" - Empty values for variables: %(variables)s",
variables=", ".join(empty_variables),
)
)
raise ValidationError("\n".join(error_parts))
def _get_dependent_model_relation_fields(self):
"""Check cx.tower.reference.mixin for the function documentation"""
res = super()._get_dependent_model_relation_fields()
return res + ["variable_value_ids"]

View File

@@ -0,0 +1,100 @@
# Copyright (C) 2024 Cetmix OÜ
# License OPL-1 (https://apps.odoocdn.com/loempia/static/examples/LICENSE).
from odoo import _, fields, models
class CxTowerShortcut(models.Model):
"""
Cetmix Tower Shortcut.
Used to run commands or flight plans with a single click.
"""
_name = "cx.tower.shortcut"
_inherit = ["cx.tower.access.mixin", "cx.tower.reference.mixin"]
_description = "Cetmix Tower Shortcut"
_order = "sequence, name"
active = fields.Boolean(default=True)
sequence = fields.Integer(default=10)
server_ids = fields.Many2many(
string="Servers",
comodel_name="cx.tower.server",
relation="cx_tower_server_shortcut_rel",
column1="shortcut_id",
column2="server_id",
)
server_template_ids = fields.Many2many(
string="Server Templates",
comodel_name="cx.tower.server.template",
relation="cx_tower_server_template_shortcut_rel",
column1="shortcut_id",
column2="server_template_id",
)
action = fields.Selection(
selection=[("command", "Command"), ("plan", "Flight Plan")], required=True
)
command_id = fields.Many2one(comodel_name="cx.tower.command")
use_sudo = fields.Boolean(
help="Run command using 'sudo'",
)
plan_id = fields.Many2one(string="Flight Plan", comodel_name="cx.tower.plan")
note = fields.Text()
def run(self, server=None):
"""Runs related shortcut action
Args:
server (cx.tower.server): Server to run the shortcut.
"""
self.ensure_one()
# Try to obtain server from context if not provided as an argument
if server is None:
server_id = self.env.context.get("server_id")
# Just return, no exceptions for now
if not server_id:
return
server = self.env["cx.tower.server"].browse(server_id)
# Just return, no exceptions for now
if not server:
return
# Use the first server record if several are passed
if len(server) > 1:
server = server[0]
if self.action == "command" and self.command_id:
server.run_command(self.sudo().command_id, sudo=self.use_sudo)
elif self.action == "plan" and self.plan_id:
server.run_flight_plan(self.sudo().plan_id)
# Notify
return self._notify_on_run(server)
def _notify_on_run(self, server):
"""Send notification when plan is triggered.
Override to implement custom notifications.
Args:
server (cx.tower.server()): Server action was triggered for
Returns:
`ir.action.client`: Web client notification.
"""
self.ensure_one()
notification = {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("%(shr)s triggered", shr=self.name),
"message": _(
"Check %(t)s log for result",
t="flight plan" if self.action == "plan" else "command",
),
"sticky": False,
},
}
return notification

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