Compare commits
565 Commits
cetmix_tow
...
cetmix_tow
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d35b4a377 | |||
| bd4df2fc3e | |||
| 6991e4956c | |||
| 807c474af4 | |||
| 62e3b75c64 | |||
| a875de6ab9 | |||
| adf867464e | |||
| 9ed6dddba1 | |||
| 45bacfa973 | |||
| ebade46d0a | |||
| f63282ef6d | |||
| 9fe857ebd7 | |||
| f37d7240fc | |||
| 6376ea081d | |||
| 1ff139ba75 | |||
| 6d40e0caa6 | |||
| be10c7bdd8 | |||
| 8b0af310fc | |||
| a1145a7773 | |||
| 773a390bed | |||
| 5c61e3dfad | |||
| f7a44ace9e | |||
| c688b17afb | |||
| d7337681f6 | |||
| 44f11fa3ab | |||
| 204c353b16 | |||
| b0e561d572 | |||
| f2423bd49d | |||
| 5520ca5d4f | |||
| e5f4d4483e | |||
| dcc929a326 | |||
| 87828837c6 | |||
| 71cf5380ff | |||
| a592f6cc70 | |||
| 4c8d4f5f7d | |||
| ac1a9b8cdc | |||
| ce13daaa58 | |||
| 20540056fa | |||
| 3f481c75d4 | |||
| 5b59a07033 | |||
| 8b1fb96368 | |||
| 5c8f90ff77 | |||
| 0667f24bd7 | |||
| 54ac099597 | |||
| 7341099882 | |||
| cc78bca1dc | |||
| 734b356286 | |||
| 4dd14c3fa0 | |||
| b29092491b | |||
| 255ec20637 | |||
| b70114419a | |||
| 4e5ceb11fb | |||
| ab4ea51bff | |||
| 02fc2bbc84 | |||
| 7aa2cf424a | |||
| 5e6726ee08 | |||
| f6f43fbca2 | |||
| e161f17642 | |||
| c661356c1f | |||
| 2001a64180 | |||
| 99d1daa1e8 | |||
| fa1a7d42e1 | |||
| 6d90045065 | |||
| 88f656b55c | |||
| a0b28de2bf | |||
| f810819876 | |||
| 739fb53837 | |||
| 6f8ed82b4c | |||
| 9309fb6768 | |||
| 8da7e5a08b | |||
| b15f459f58 | |||
| 0a657d2f43 | |||
| 31fea6f015 | |||
| 601b399d65 | |||
|
|
7cef9f1a32 | ||
| 18dd9c7a1f | |||
| 1c6d6b1dcc | |||
| b3d78f3f06 | |||
| 5d5fbb835e | |||
| f259d7da1b | |||
| 433f68b5a4 | |||
| 3729ee8cd6 | |||
| 261e8aea62 | |||
| a1dd66ec6a | |||
| f579fbc83f | |||
| bd2cfbcc3d | |||
| 9c009dddb5 | |||
| fd94630e79 | |||
| c8274bd0a6 | |||
| 4bea3edbeb | |||
| 3aa73a29a5 | |||
| 5934b7cf4d | |||
| 39f0b6d406 | |||
| 1c1a16a55a | |||
| 991507c29a | |||
| 553f5fa25f | |||
| 8c5ef8bfd2 | |||
| 4e0580a2b4 | |||
| 451e109b7f | |||
| fa79d8c15d | |||
| 55800608ec | |||
| 63e66334af | |||
| 4b7d2f2efc | |||
| a7b02a742a | |||
| 825ad03236 | |||
| 484763b809 | |||
| b0d2c5668c | |||
| 444278accb | |||
| c8b19a8c62 | |||
| 1a3285cdc4 | |||
| cd55fd9f19 | |||
| d75d397e6a | |||
| 4e95aa47de | |||
| 0911b0d951 | |||
| 1ea59d44f0 | |||
| b4fcbfdf2a | |||
| cca99e065a | |||
| ec6e3c8fd2 | |||
| 2c1d9c3ef2 | |||
| 583dd0dd15 | |||
| 66ae014a38 | |||
| b2f175536a | |||
| 6794a1b842 | |||
| 191f857aff | |||
| bf6065aeb7 | |||
| 00e6ff7e78 | |||
| 1f5b011fce | |||
| 61db219e01 | |||
| 771994f944 | |||
| def74bd656 | |||
| 6e4be30e3a | |||
| 96a2eeda3a | |||
| a6209db573 | |||
| bfc350252a | |||
| 64efc9b0b4 | |||
| 8d4ddfb7d2 | |||
| 447b8431e6 | |||
| 007783c1e2 | |||
| 72a4524aed | |||
| 7e37a29bee | |||
| 1f0cf23801 | |||
| 999a996df8 | |||
| 8966de83af | |||
| 403368df7a | |||
| fef59e7a73 | |||
| c2285f865e | |||
| 34d8248b79 | |||
| f64852997f | |||
| fcf45b130e | |||
| fd4665364d | |||
| 91a344cbc2 | |||
| 7b8f5090db | |||
| e2039f54f4 | |||
| 445b34f452 | |||
| c3a4151359 | |||
| c05ba71bcd | |||
| 389a32d760 | |||
| 609ef99c44 | |||
| 71e98f5b3f | |||
| 25052f2e2d | |||
| a5c0f76f89 | |||
| 81d2547e9d | |||
| a0c172c649 | |||
| 8a65785c52 | |||
| 85fff4657e | |||
| 114449be53 | |||
| df1dabb253 | |||
| 65094d2031 | |||
| 9d8a226283 | |||
| 7bff54cb58 | |||
| 4f9f60b121 | |||
| f0cee69a24 | |||
| 0d6e910d3e | |||
| 64f515e11b | |||
| ef22709eb7 | |||
| 65c6df9940 | |||
| cbc12f44b8 | |||
| 45eba87eda | |||
| 510be1ffcb | |||
| 9ceb54d29c | |||
| 942da80b9c | |||
| 3da4cc2dec | |||
| b4572fa6f1 | |||
| 01f5ee1c46 | |||
| 952b235888 | |||
| f98c11412d | |||
| a8e27776d3 | |||
| 6038b70592 | |||
| e259a897fe | |||
| 05027ef13c | |||
| d65b12bc80 | |||
| 905d4a6c04 | |||
| a213ef10a8 | |||
| f2b16e50a7 | |||
| 4d25cf4ade | |||
| 82b2acd792 | |||
| 7522999082 | |||
| f8e694b71a | |||
| 83cbdf54e9 | |||
| 7744f3212d | |||
| b55049d482 | |||
| 54f981fd25 | |||
| 7d753b772a | |||
| cd8e63eb08 | |||
| 29f5780312 | |||
| 6dd6679e9a | |||
| 26c795216e | |||
| 5b40d83c0c | |||
| 22279e8c98 | |||
| 09bc143899 | |||
| d29af3f5ad | |||
| 7441874199 | |||
| 068638b20a | |||
| 5c65820935 | |||
| 748b61b2f6 | |||
| 70d359dd8d | |||
| c4d093c497 | |||
| 39ccc6bde5 | |||
| 8df4722e8b | |||
| fe3a822173 | |||
| 7d9a1eefbb | |||
| c74f5414af | |||
| 98387bc517 | |||
| a6e739601e | |||
| e3b372f3d0 | |||
| 8f8e41943a | |||
| 7af8e80303 | |||
| 9f86d4807c | |||
| 1a082b425c | |||
| 48fcec14c5 | |||
| d54a6b9d08 | |||
| 26e1be3a4f | |||
| 10cd0f3bc1 | |||
| d2ec4529cc | |||
| a1bf9980cb | |||
| 42292618bb | |||
| 07d598c857 | |||
| 757ec36790 | |||
| 7441e29889 | |||
| c48a8ddc63 | |||
| c31ba607e5 | |||
| 97eafd2fcf | |||
| b3e06b7bbd | |||
| ddc65dc558 | |||
| 8dc88a671f | |||
| 928a2661bb | |||
| 7f9278fc8f | |||
| bc99107f8e | |||
| db6cbffd60 | |||
| 55df443de3 | |||
| e28e930732 | |||
| 2ffa038703 | |||
| 5c6a987442 | |||
| 5f26a8f675 | |||
| 0f25bd4d77 | |||
| 41a6368228 | |||
| 20ec0b6fd6 | |||
| 71655a3923 | |||
| 06103e090a | |||
| f78d7b8d35 | |||
| b87a626ee7 | |||
| 5c587f8e7d | |||
| 14645156c6 | |||
| 9af897fa59 | |||
| 6b447e3364 | |||
| 162e2aa3e8 | |||
| 68fa068d8b | |||
| d481df1702 | |||
| 2f6ce319ba | |||
| 8093696ec8 | |||
| 53d1657954 | |||
| 87eae8f9c1 | |||
| 1b5655d1aa | |||
| 01ec5954bb | |||
| 0d853abbc3 | |||
| fada6f30ff | |||
| c10bbc8f8a | |||
| 492d828ca3 | |||
| 343a0700b6 | |||
| c582038d23 | |||
| 4c70b26e1d | |||
| 56b120ae6f | |||
| 3ef03aea6e | |||
| 9897dcfa04 | |||
| 01b7ffd8d3 | |||
| 0ed1b40384 | |||
| 0a1b6e156a | |||
| f09ad65b7a | |||
| 92b30574c7 | |||
| f5eb897143 | |||
| 8ed74a3aed | |||
| 7158e9210f | |||
| 9444f8805a | |||
| 2095fde1f4 | |||
| 922c8a49d5 | |||
| 7acf00fc4d | |||
| 86b416cb47 | |||
| 09ed1d8731 | |||
| 022f0cb891 | |||
| 8e4a3d8d4a | |||
| 97f60c2aa5 | |||
| 7fb3d0a77d | |||
| 82d2d1eff6 | |||
| 1ed5e88c7c | |||
| a1f473b8a3 | |||
| 0ed968a17b | |||
| 1a3e7389fa | |||
| 8199d0022d | |||
| 8eb03de70b | |||
| 1a43c797c3 | |||
| 668ff3da60 | |||
| a3d8b01582 | |||
| 380afede5e | |||
| bf85022852 | |||
| 76f3b5cd0d | |||
| 818c86a758 | |||
| a718da84af | |||
| c7b7860fd6 | |||
| 31da31ec45 | |||
| 2fd5aa0787 | |||
| d47e45ae64 | |||
| 1fea3621f5 | |||
| 6855e3711a | |||
| 26f2040905 | |||
| a52b141017 | |||
| 5d988b1cb8 | |||
| 4fc18d865b | |||
| 262a6e4b84 | |||
| e450738fd7 | |||
| 83ec459ca5 | |||
| 4de853d788 | |||
| ad6cbac1f8 | |||
| 2fcd451339 | |||
| 762547c1f5 | |||
| 25cc185aee | |||
| 0fc6a1d6f3 | |||
| 76991aecae | |||
| f7c03a7122 | |||
| dc0fa2dff7 | |||
| 99043f1c52 | |||
| 73a89f15e6 | |||
| b4a3b13ee0 | |||
| 3d30491875 | |||
| dda64246c5 | |||
| a0d1d19687 | |||
| 66c81b2a91 | |||
| c945b52671 | |||
| 146d71319e | |||
| d6d2136df6 | |||
| 9c9cc898a4 | |||
| be07a3b18d | |||
| 9f312687b1 | |||
| eadb83779e | |||
| e244e8279b | |||
| 47fe5ea7a5 | |||
| 05724afff0 | |||
| 5578fb365a | |||
| 30a3b0dc4e | |||
| 51c5cb3bdb | |||
| 0849ae6161 | |||
| 81ee76ce21 | |||
| dc76af271e | |||
| 5f868f7610 | |||
| cf31963487 | |||
| f1b923ae7f | |||
| 0484142dd5 | |||
| 806c7ce8b8 | |||
| 424742714d | |||
| 87b5247726 | |||
| e225e7b2a2 | |||
| 1877e3c1ae | |||
| 3ea304cb45 | |||
| d49b02938a | |||
| 8db12c649f | |||
| 9c16569b69 | |||
| c2813bc9b3 | |||
| 5111caa738 | |||
| 2f302772e3 | |||
| 0deb721477 | |||
| 178f8e137e | |||
| 0769cb0756 | |||
| 95485e2558 | |||
| ab144b1350 | |||
| 4c6fd5e470 | |||
| 05e045267a | |||
| f5a9261856 | |||
| 764642fbf1 | |||
| 9170934142 | |||
| a0877d3ba4 | |||
| d236c96001 | |||
| 5f76fc4ad5 | |||
| db4e11225b | |||
| 28987afc7d | |||
| eff6288a42 | |||
| af344b5014 | |||
| e97a22516c | |||
| d99c2f23a9 | |||
| 49b0220cc1 | |||
| 8f87c713f3 | |||
| eb2ad30e64 | |||
| 1ebf77f1aa | |||
| 32e517b5ec | |||
| fe243328a0 | |||
| 070314632d | |||
| b9c4a621dc | |||
| 5f9fb1597b | |||
| 62edb14057 | |||
| 2b1c121be9 | |||
| 51efac175a | |||
| 7e737b5877 | |||
| 2beb85437a | |||
| cfdd00e264 | |||
| 034ea5c0bd | |||
| 8621fac655 | |||
| 8a5b68926c | |||
| 22885f7fdd | |||
| e3e51b8367 | |||
| 019224ba4c | |||
| 833346a1a8 | |||
| c501af7d45 | |||
| aa1b8801ce | |||
| b48081c8e2 | |||
| a366d1b52c | |||
| 5cb28ea01a | |||
| 30f1f2df49 | |||
| c416aabc44 | |||
| 17d150a45f | |||
| 6211de488b | |||
| 5fd192356f | |||
| 929448f1ca | |||
| 8733b3cb61 | |||
| c5fa399627 | |||
| 4ce9f94318 | |||
| 281c0167b1 | |||
| 96d4ad7ef7 | |||
| 6ce7c48b2d | |||
| c3bdb2c14d | |||
| 898b423feb | |||
| 7264942e8d | |||
| 3f23cfecf3 | |||
| 23e386b526 | |||
| f0193a9307 | |||
| bbddf942a2 | |||
| f7d3a429a5 | |||
| 89bba86349 | |||
| 0a2076df37 | |||
| 7da0bc5c93 | |||
| 2c7bea7e69 | |||
| cc4bde613b | |||
| a3e7e80ffb | |||
| 3d2174b4e8 | |||
| 436bc160e3 | |||
| 693821eb53 | |||
| 495bb536f1 | |||
| a81ee9d711 | |||
| 13f88ed1ed | |||
| 9c92bd8a2d | |||
| 9815bfd407 | |||
| 42256b6283 | |||
| 4d4b874ee0 | |||
| e741fd3d1c | |||
| 23db6fae45 | |||
| 0cac17c395 | |||
| 9bea4833ca | |||
| d70d24cb7a | |||
| ab129128b3 | |||
| 8c292c2217 | |||
| 0b504afdca | |||
| fa76207199 | |||
| a6d3222ffc | |||
| e7b8c1fc11 | |||
| 226ecfa11e | |||
| 92a34a2292 | |||
| 19b6b2caca | |||
| 448f814aae | |||
| 2537f4e58c | |||
| d0059616aa | |||
| 8c8199abbd | |||
| 64825c8e84 | |||
| 62cd370099 | |||
| 9b528e38fc | |||
| c45450ed87 | |||
| cc53a55c96 | |||
| 9573216bfd | |||
| 2839e110fe | |||
| ba9ce2ad88 | |||
| eb4d7a5477 | |||
| 42d21b989a | |||
| 23cf3ad81b | |||
| cce324dbfb | |||
| 9e1dcd02dd | |||
| bfbe68ff88 | |||
| e07234573c | |||
| 5b2f53b33a | |||
| 14e7468ca7 | |||
| 7df06465a8 | |||
| cb2eb054eb | |||
| 3ef2cc50fe | |||
| d12d454c70 | |||
| 9a17bcd25e | |||
| 73ebe069f6 | |||
| 2755e373fd | |||
| 9e43910cc8 | |||
| 1646ace09b | |||
| c6c6570800 | |||
| 21576ec28f | |||
| a5b60a5d3b | |||
| abcb71d469 | |||
| 4fdf6333f2 | |||
| c18aba668b | |||
| 0a8333e1e2 | |||
| 3d19db5049 | |||
| 62e7767925 | |||
| 070c89e75e | |||
| cf0d897dfa | |||
| 9fc1c6bd65 | |||
| 342e616963 | |||
| a4c6f5c561 | |||
| 811d32c5be | |||
| 135074c040 | |||
| af55099d83 | |||
| 2d9f32fc2f | |||
| d361711043 | |||
| 426c0e0792 | |||
| ae451e5911 | |||
| 31bcb48704 | |||
| 25703173fb | |||
| eab2080115 | |||
| 5f99227e6c | |||
| 4a547632ac | |||
| 8cd9bae8ea | |||
| ddadefa9a6 | |||
| 7276688114 | |||
| 66450d4d02 | |||
| 2d0bda98b1 | |||
| 89943c26eb | |||
| 0ac25c7405 | |||
| b54c955847 | |||
| 857ec4fceb | |||
| 83ff1a0ec5 | |||
| ef85be3808 | |||
| 25b80d98ce | |||
| 1871e1ffe9 | |||
| 4440daa0a4 | |||
| 6e018447b2 | |||
| 5c4949bf5b | |||
| 90cb176847 | |||
| bdf8278b7f | |||
| da1f2fd426 | |||
| 4b1cbbc86b | |||
| 0957e4d55b | |||
| 6509c2136f | |||
| 52877f9b2c | |||
| 6176d27861 | |||
| 37a160148d | |||
| ee1501034b | |||
| 92e62ae21b | |||
| 7a5d6aa254 | |||
| 42a4abb176 | |||
| 440324c078 | |||
| 7e9e92a179 | |||
| ad62d49f3d | |||
| a562808d99 | |||
| 7f6a00a8f7 | |||
| f04db1b076 |
125
addons/cetmix_tower_git/README.rst
Normal file
125
addons/cetmix_tower_git/README.rst
Normal 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.
|
||||||
1
addons/cetmix_tower_git/__init__.py
Normal file
1
addons/cetmix_tower_git/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
40
addons/cetmix_tower_git/__manifest__.py
Normal file
40
addons/cetmix_tower_git/__manifest__.py
Normal 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",
|
||||||
|
],
|
||||||
|
}
|
||||||
166
addons/cetmix_tower_git/demo/demo_data.xml
Normal file
166
addons/cetmix_tower_git/demo/demo_data.xml
Normal 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>
|
||||||
1029
addons/cetmix_tower_git/i18n/cetmix_tower_git.pot
Normal file
1029
addons/cetmix_tower_git/i18n/cetmix_tower_git.pot
Normal file
File diff suppressed because it is too large
Load Diff
595
addons/cetmix_tower_git/i18n/fi.po
Normal file
595
addons/cetmix_tower_git/i18n/fi.po
Normal 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 ""
|
||||||
635
addons/cetmix_tower_git/i18n/hr.po
Normal file
635
addons/cetmix_tower_git/i18n/hr.po
Normal 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 ""
|
||||||
1085
addons/cetmix_tower_git/i18n/it.po
Normal file
1085
addons/cetmix_tower_git/i18n/it.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
15
addons/cetmix_tower_git/models/__init__.py
Normal file
15
addons/cetmix_tower_git/models/__init__.py
Normal 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
|
||||||
35
addons/cetmix_tower_git/models/cetmix_tower.py
Normal file
35
addons/cetmix_tower_git/models/cetmix_tower.py
Normal 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
|
||||||
|
)
|
||||||
37
addons/cetmix_tower_git/models/cx_tower_command.py
Normal file
37
addons/cetmix_tower_git/models/cx_tower_command.py
Normal 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
|
||||||
47
addons/cetmix_tower_git/models/cx_tower_file.py
Normal file
47
addons/cetmix_tower_git/models/cx_tower_file.py
Normal 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,
|
||||||
|
}
|
||||||
|
)
|
||||||
32
addons/cetmix_tower_git/models/cx_tower_file_template.py
Normal file
32
addons/cetmix_tower_git/models/cx_tower_file_template.py
Normal 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
|
||||||
|
)
|
||||||
334
addons/cetmix_tower_git/models/cx_tower_git_project.py
Normal file
334
addons/cetmix_tower_git/models/cx_tower_git_project.py
Normal 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 ""
|
||||||
@@ -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})
|
||||||
177
addons/cetmix_tower_git/models/cx_tower_git_project_rel.py
Normal file
177
addons/cetmix_tower_git/models/cx_tower_git_project_rel.py
Normal 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
|
||||||
415
addons/cetmix_tower_git/models/cx_tower_git_remote.py
Normal file
415
addons/cetmix_tower_git/models/cx_tower_git_remote.py
Normal 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
|
||||||
409
addons/cetmix_tower_git/models/cx_tower_git_repo.py
Normal file
409
addons/cetmix_tower_git/models/cx_tower_git_repo.py
Normal 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
|
||||||
107
addons/cetmix_tower_git/models/cx_tower_git_repo_owner.py
Normal file
107
addons/cetmix_tower_git/models/cx_tower_git_repo_owner.py
Normal 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
|
||||||
189
addons/cetmix_tower_git/models/cx_tower_git_source.py
Normal file
189
addons/cetmix_tower_git/models/cx_tower_git_source.py
Normal 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
|
||||||
32
addons/cetmix_tower_git/models/cx_tower_plan_line.py
Normal file
32
addons/cetmix_tower_git/models/cx_tower_plan_line.py
Normal 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
|
||||||
156
addons/cetmix_tower_git/models/cx_tower_server.py
Normal file
156
addons/cetmix_tower_git/models/cx_tower_server.py
Normal 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
|
||||||
3
addons/cetmix_tower_git/pyproject.toml
Normal file
3
addons/cetmix_tower_git/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["whool"]
|
||||||
|
build-backend = "whool.buildapi"
|
||||||
1
addons/cetmix_tower_git/readme/CONFIGURE.md
Normal file
1
addons/cetmix_tower_git/readme/CONFIGURE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions.
|
||||||
3
addons/cetmix_tower_git/readme/DESCRIPTION.md
Normal file
3
addons/cetmix_tower_git/readme/DESCRIPTION.md
Normal 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.
|
||||||
43
addons/cetmix_tower_git/readme/HISTORY.md
Normal file
43
addons/cetmix_tower_git/readme/HISTORY.md
Normal 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
|
||||||
1
addons/cetmix_tower_git/readme/USAGE.md
Normal file
1
addons/cetmix_tower_git/readme/USAGE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions.
|
||||||
@@ -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">['&',
|
||||||
|
'|',
|
||||||
|
('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>
|
||||||
@@ -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">['&',
|
||||||
|
'|',
|
||||||
|
('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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
15
addons/cetmix_tower_git/security/ir.model.access.csv
Normal file
15
addons/cetmix_tower_git/security/ir.model.access.csv
Normal 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
|
||||||
|
BIN
addons/cetmix_tower_git/static/description/banner.png
Normal file
BIN
addons/cetmix_tower_git/static/description/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
addons/cetmix_tower_git/static/description/icon.png
Normal file
BIN
addons/cetmix_tower_git/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
497
addons/cetmix_tower_git/static/description/index.html
Normal file
497
addons/cetmix_tower_git/static/description/index.html
Normal 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>
|
||||||
7
addons/cetmix_tower_git/tests/__init__.py
Normal file
7
addons/cetmix_tower_git/tests/__init__.py
Normal 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
|
||||||
136
addons/cetmix_tower_git/tests/common.py
Normal file
136
addons/cetmix_tower_git/tests/common.py
Normal 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",
|
||||||
|
}
|
||||||
|
)
|
||||||
390
addons/cetmix_tower_git/tests/test_file_rel.py
Normal file
390
addons/cetmix_tower_git/tests/test_file_rel.py
Normal 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())
|
||||||
308
addons/cetmix_tower_git/tests/test_file_template_rel.py
Normal file
308
addons/cetmix_tower_git/tests/test_file_template_rel.py
Normal 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())
|
||||||
315
addons/cetmix_tower_git/tests/test_project.py
Normal file
315
addons/cetmix_tower_git/tests/test_project.py
Normal 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")
|
||||||
462
addons/cetmix_tower_git/tests/test_remote.py
Normal file
462
addons/cetmix_tower_git/tests/test_remote.py
Normal 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")
|
||||||
84
addons/cetmix_tower_git/tests/test_repo.py
Normal file
84
addons/cetmix_tower_git/tests/test_repo.py
Normal 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",
|
||||||
|
}
|
||||||
|
)
|
||||||
198
addons/cetmix_tower_git/tests/test_server.py
Normal file
198
addons/cetmix_tower_git/tests/test_server.py
Normal 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)
|
||||||
226
addons/cetmix_tower_git/tests/test_source.py
Normal file
226
addons/cetmix_tower_git/tests/test_source.py
Normal 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")
|
||||||
0
addons/cetmix_tower_git/tools/git_aggregator.py
Normal file
0
addons/cetmix_tower_git/tools/git_aggregator.py
Normal 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>
|
||||||
18
addons/cetmix_tower_git/views/cx_tower_file_views.xml
Normal file
18
addons/cetmix_tower_git/views/cx_tower_file_views.xml
Normal 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>
|
||||||
236
addons/cetmix_tower_git/views/cx_tower_git_project_views.xml
Normal file
236
addons/cetmix_tower_git/views/cx_tower_git_project_views.xml
Normal 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 > 0"
|
||||||
|
decoration-warning="remote_count_private != remote_count and remote_count > 0 and remote_count_private > 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>
|
||||||
84
addons/cetmix_tower_git/views/cx_tower_git_remote_views.xml
Normal file
84
addons/cetmix_tower_git/views/cx_tower_git_remote_views.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
163
addons/cetmix_tower_git/views/cx_tower_git_repo_views.xml
Normal file
163
addons/cetmix_tower_git/views/cx_tower_git_repo_views.xml
Normal 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>
|
||||||
86
addons/cetmix_tower_git/views/cx_tower_git_source_views.xml
Normal file
86
addons/cetmix_tower_git/views/cx_tower_git_source_views.xml
Normal 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', '<', 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>
|
||||||
19
addons/cetmix_tower_git/views/cx_tower_plan_line_view.xml
Normal file
19
addons/cetmix_tower_git/views/cx_tower_plan_line_view.xml
Normal 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>
|
||||||
45
addons/cetmix_tower_git/views/cx_tower_server_view.xml
Normal file
45
addons/cetmix_tower_git/views/cx_tower_server_view.xml
Normal 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>
|
||||||
32
addons/cetmix_tower_git/views/menuitems.xml
Normal file
32
addons/cetmix_tower_git/views/menuitems.xml
Normal 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>
|
||||||
318
addons/cetmix_tower_server/README.rst
Normal file
318
addons/cetmix_tower_server/README.rst
Normal 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.
|
||||||
4
addons/cetmix_tower_server/__init__.py
Normal file
4
addons/cetmix_tower_server/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# pylint: disable=E8103
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import wizards
|
||||||
86
addons/cetmix_tower_server/__manifest__.py
Normal file
86
addons/cetmix_tower_server/__manifest__.py
Normal 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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
39
addons/cetmix_tower_server/data/ir_actions_server.xml
Normal file
39
addons/cetmix_tower_server/data/ir_actions_server.xml
Normal 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>
|
||||||
20
addons/cetmix_tower_server/data/ir_config_parameter.xml
Normal file
20
addons/cetmix_tower_server/data/ir_config_parameter.xml
Normal 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>
|
||||||
43
addons/cetmix_tower_server/data/ir_cron.xml
Normal file
43
addons/cetmix_tower_server/data/ir_cron.xml
Normal 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>
|
||||||
3
addons/cetmix_tower_server/data/neutralize.sql
Normal file
3
addons/cetmix_tower_server/data/neutralize.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- deactivate scheduled tasks
|
||||||
|
UPDATE cx_tower_scheduled_task
|
||||||
|
SET active = false;
|
||||||
973
addons/cetmix_tower_server/demo/demo_data.xml
Normal file
973
addons/cetmix_tower_server/demo/demo_data.xml
Normal 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 }} && 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 && 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">></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">>=</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>
|
||||||
5000
addons/cetmix_tower_server/i18n/cetmix_tower_server.pot
Normal file
5000
addons/cetmix_tower_server/i18n/cetmix_tower_server.pot
Normal file
File diff suppressed because it is too large
Load Diff
3462
addons/cetmix_tower_server/i18n/de.po
Normal file
3462
addons/cetmix_tower_server/i18n/de.po
Normal file
File diff suppressed because it is too large
Load Diff
3449
addons/cetmix_tower_server/i18n/fi.po
Normal file
3449
addons/cetmix_tower_server/i18n/fi.po
Normal file
File diff suppressed because it is too large
Load Diff
3591
addons/cetmix_tower_server/i18n/hr.po
Normal file
3591
addons/cetmix_tower_server/i18n/hr.po
Normal file
File diff suppressed because it is too large
Load Diff
5356
addons/cetmix_tower_server/i18n/it.po
Normal file
5356
addons/cetmix_tower_server/i18n/it.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.")
|
||||||
@@ -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.")
|
||||||
37
addons/cetmix_tower_server/models/__init__.py
Normal file
37
addons/cetmix_tower_server/models/__init__.py
Normal 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
|
||||||
300
addons/cetmix_tower_server/models/cetmix_tower.py
Normal file
300
addons/cetmix_tower_server/models/cetmix_tower.py
Normal 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
|
||||||
125
addons/cetmix_tower_server/models/constants.py
Normal file
125
addons/cetmix_tower_server/models/constants.py
Normal 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
|
||||||
|
)
|
||||||
37
addons/cetmix_tower_server/models/cx_tower_access_mixin.py
Normal file
37
addons/cetmix_tower_server/models/cx_tower_access_mixin.py
Normal 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"
|
||||||
@@ -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 []
|
||||||
550
addons/cetmix_tower_server/models/cx_tower_command.py
Normal file
550
addons/cetmix_tower_server/models/cx_tower_command.py
Normal 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
|
||||||
369
addons/cetmix_tower_server/models/cx_tower_command_log.py
Normal file
369
addons/cetmix_tower_server/models/cx_tower_command_log.py
Normal 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
|
||||||
@@ -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})
|
||||||
741
addons/cetmix_tower_server/models/cx_tower_file.py
Normal file
741
addons/cetmix_tower_server/models/cx_tower_file.py
Normal 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
|
||||||
228
addons/cetmix_tower_server/models/cx_tower_file_template.py
Normal file
228
addons/cetmix_tower_server/models/cx_tower_file_template.py
Normal 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
|
||||||
414
addons/cetmix_tower_server/models/cx_tower_key.py
Normal file
414
addons/cetmix_tower_server/models/cx_tower_key.py
Normal 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)
|
||||||
70
addons/cetmix_tower_server/models/cx_tower_key_mixin.py
Normal file
70
addons/cetmix_tower_server/models/cx_tower_key_mixin.py
Normal 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)]
|
||||||
112
addons/cetmix_tower_server/models/cx_tower_key_value.py
Normal file
112
addons/cetmix_tower_server/models/cx_tower_key_value.py
Normal 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)
|
||||||
17
addons/cetmix_tower_server/models/cx_tower_os.py
Normal file
17
addons/cetmix_tower_server/models/cx_tower_os.py
Normal 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")
|
||||||
373
addons/cetmix_tower_server/models/cx_tower_plan.py
Normal file
373
addons/cetmix_tower_server/models/cx_tower_plan.py
Normal 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
|
||||||
301
addons/cetmix_tower_server/models/cx_tower_plan_line.py
Normal file
301
addons/cetmix_tower_server/models/cx_tower_plan_line.py
Normal 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
|
||||||
101
addons/cetmix_tower_server/models/cx_tower_plan_line_action.py
Normal file
101
addons/cetmix_tower_server/models/cx_tower_plan_line_action.py
Normal 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
|
||||||
427
addons/cetmix_tower_server/models/cx_tower_plan_log.py
Normal file
427
addons/cetmix_tower_server/models/cx_tower_plan_log.py
Normal 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
|
||||||
481
addons/cetmix_tower_server/models/cx_tower_reference_mixin.py
Normal file
481
addons/cetmix_tower_server/models/cx_tower_reference_mixin.py
Normal 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
|
||||||
296
addons/cetmix_tower_server/models/cx_tower_scheduled_task.py
Normal file
296
addons/cetmix_tower_server/models/cx_tower_scheduled_task.py
Normal 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
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
1986
addons/cetmix_tower_server/models/cx_tower_server.py
Normal file
1986
addons/cetmix_tower_server/models/cx_tower_server.py
Normal file
File diff suppressed because it is too large
Load Diff
199
addons/cetmix_tower_server/models/cx_tower_server_log.py
Normal file
199
addons/cetmix_tower_server/models/cx_tower_server_log.py
Normal 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
|
||||||
653
addons/cetmix_tower_server/models/cx_tower_server_template.py
Normal file
653
addons/cetmix_tower_server/models/cx_tower_server_template.py
Normal 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"]
|
||||||
100
addons/cetmix_tower_server/models/cx_tower_shortcut.py
Normal file
100
addons/cetmix_tower_server/models/cx_tower_shortcut.py
Normal 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
Reference in New Issue
Block a user