Compare commits
430 Commits
cetmix_tow
...
web_notify
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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
@@ -0,0 +1,4 @@
|
||||
# pylint: disable=E8103
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
3462
addons/cetmix_tower_server/i18n/de.po
Normal file
3449
addons/cetmix_tower_server/i18n/fi.po
Normal file
3591
addons/cetmix_tower_server/i18n/hr.po
Normal file
5356
addons/cetmix_tower_server/i18n/it.po
Normal file
@@ -0,0 +1,21 @@
|
||||
import logging
|
||||
|
||||
from odoo import SUPERUSER_ID, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Generate references for files.
|
||||
"""
|
||||
|
||||
_logger.info("Starting reference generation for files.")
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
model_obj = env["cx.tower.file"]
|
||||
records_without_reference = model_obj.search([("reference", "=", False)])
|
||||
for record in records_without_reference:
|
||||
record_reference = record._generate_or_fix_reference(record.name)
|
||||
record.write({"reference": record_reference})
|
||||
_logger.info(f"Generated reference for file {record.name}: {record_reference}")
|
||||
_logger.info("Reference generation for files completed.")
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
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
@@ -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
@@ -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
|
||||
91
addons/cetmix_tower_server/models/cx_tower_tag.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# Copyright (C) 2022 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class CxTowerTag(models.Model):
|
||||
"""
|
||||
Cetmix Tower Tag.
|
||||
Tags are used to group servers, commands, flight plans, etc.
|
||||
"""
|
||||
|
||||
_name = "cx.tower.tag"
|
||||
_inherit = [
|
||||
"cx.tower.reference.mixin",
|
||||
]
|
||||
_description = "Cetmix Tower Tag"
|
||||
_order = "name"
|
||||
|
||||
color = fields.Integer(help="For better visualization in views")
|
||||
|
||||
# --- Relations
|
||||
server_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.server",
|
||||
relation="cx_tower_server_tag_rel",
|
||||
column1="tag_id",
|
||||
column2="server_id",
|
||||
string="Servers",
|
||||
)
|
||||
command_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.command",
|
||||
relation="cx_tower_command_tag_rel",
|
||||
column1="tag_id",
|
||||
column2="command_id",
|
||||
string="Commands",
|
||||
)
|
||||
plan_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.plan",
|
||||
relation="cx_tower_plan_tag_rel",
|
||||
column1="tag_id",
|
||||
column2="plan_id",
|
||||
string="Plans",
|
||||
)
|
||||
server_template_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.server.template",
|
||||
relation="cx_tower_server_template_tag_rel",
|
||||
column1="tag_id",
|
||||
column2="server_template_id",
|
||||
string="Server Templates",
|
||||
)
|
||||
file_template_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.file.template",
|
||||
relation="cx_tower_file_template_tag_rel",
|
||||
column1="tag_id",
|
||||
column2="file_template_id",
|
||||
string="File Templates",
|
||||
)
|
||||
|
||||
def unlink(self):
|
||||
"""
|
||||
Prevent deletion of tags that are in use
|
||||
unless user is root or using sudo.
|
||||
"""
|
||||
if not self.env.is_superuser() and not self.env.user.has_group(
|
||||
"cetmix_tower_server.group_root"
|
||||
):
|
||||
self._check_tags_can_be_deleted()
|
||||
return super().unlink()
|
||||
|
||||
def _check_tags_can_be_deleted(self):
|
||||
"""Check if tags can be deleted.
|
||||
|
||||
Raises:
|
||||
ValidationError: If tag is in use
|
||||
"""
|
||||
|
||||
for tag in self:
|
||||
if (
|
||||
tag.server_ids
|
||||
or tag.command_ids
|
||||
or tag.plan_ids
|
||||
or tag.server_template_ids
|
||||
or tag.file_template_ids
|
||||
):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"Cannot delete tag '%(tag_name)s' because"
|
||||
" it is used in related records.",
|
||||
tag_name=tag.name,
|
||||
)
|
||||
)
|
||||
116
addons/cetmix_tower_server/models/cx_tower_tag_mixin.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class CxTowerTagMixin(models.AbstractModel):
|
||||
"""
|
||||
Cetmix Tower Tag Mixin.
|
||||
Used to add tag functionality to models.
|
||||
"""
|
||||
|
||||
_name = "cx.tower.tag.mixin"
|
||||
_description = "Cetmix Tower Tag Mixin"
|
||||
|
||||
tag_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.tag",
|
||||
string="Tags",
|
||||
)
|
||||
|
||||
def add_tags(self, tag_names):
|
||||
"""Add tags to the record
|
||||
|
||||
Args:
|
||||
tag_names (list of Char or Char): List of tag names to add
|
||||
or single tag name
|
||||
"""
|
||||
# Single tag name is given, convert to list
|
||||
if isinstance(tag_names, str):
|
||||
tag_names = [tag_names]
|
||||
# Invalid type is given, return True
|
||||
elif not isinstance(tag_names, list):
|
||||
return True
|
||||
|
||||
tags = self.env["cx.tower.tag"].search([("name", "in", tag_names)])
|
||||
if tags:
|
||||
self.write({"tag_ids": [(4, tag.id) for tag in tags]})
|
||||
return True
|
||||
|
||||
def remove_tags(self, tag_names):
|
||||
"""Remove tags from the record
|
||||
|
||||
Args:
|
||||
tag_names (list of Char or Char): List of tag names to remove
|
||||
or single tag name.
|
||||
"""
|
||||
# Single tag name is given, convert to list
|
||||
if isinstance(tag_names, str):
|
||||
tag_names = [tag_names]
|
||||
# Invalid type is given, return True
|
||||
elif not isinstance(tag_names, list):
|
||||
return True
|
||||
|
||||
tags = self.env["cx.tower.tag"].search([("name", "in", tag_names)])
|
||||
if tags:
|
||||
self.write({"tag_ids": [(3, tag.id) for tag in tags]})
|
||||
return True
|
||||
|
||||
def has_tags(self, tag_name, search_all=False):
|
||||
"""Get all records from the recordset that have any of the given tags
|
||||
|
||||
Args:
|
||||
tag_name (Char or List of Char): Tag name or list of tag names to check
|
||||
search_all (bool): If True, search all records in the model
|
||||
"""
|
||||
|
||||
# Empty recordset is returned as is
|
||||
if not self and not search_all:
|
||||
return self
|
||||
|
||||
# Check argument type
|
||||
if isinstance(tag_name, str):
|
||||
single_tag = True
|
||||
elif isinstance(tag_name, list):
|
||||
single_tag = False
|
||||
else:
|
||||
return self.browse()
|
||||
|
||||
if search_all:
|
||||
if single_tag:
|
||||
domain = [("tag_ids.name", "=", tag_name)]
|
||||
else:
|
||||
domain = [("tag_ids.name", "in", tag_name)]
|
||||
return self.env[self._name].search(domain)
|
||||
|
||||
if single_tag:
|
||||
return self.filtered(
|
||||
lambda record: tag_name in record.tag_ids.mapped("name")
|
||||
)
|
||||
return self.filtered(
|
||||
lambda record: set(tag_name) & set(record.tag_ids.mapped("name"))
|
||||
)
|
||||
|
||||
def has_all_tags(self, tag_names, search_all=False):
|
||||
"""Get all records from the recordset that have all of the given tags
|
||||
|
||||
Args:
|
||||
tag_names (list of Char): List of tag names to check
|
||||
search_all (bool): If True, search all records in the model
|
||||
"""
|
||||
# No value or invalid type is given, return empty recordset
|
||||
if not tag_names or not isinstance(tag_names, list):
|
||||
return self.browse()
|
||||
|
||||
# Empty recordset is returned as is
|
||||
if not self and not search_all:
|
||||
return self
|
||||
|
||||
if search_all:
|
||||
records = self.env[self._name].search([("tag_ids.name", "in", tag_names)])
|
||||
else:
|
||||
records = self
|
||||
|
||||
tag_names_set = set(tag_names)
|
||||
return records.filtered(
|
||||
lambda record: tag_names_set.issubset(record.tag_ids.mapped("name"))
|
||||
)
|
||||
209
addons/cetmix_tower_server/models/cx_tower_template_mixin.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from jinja2 import Environment, Template, meta
|
||||
from jinja2.exceptions import TemplateSyntaxError
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class CxTowerTemplateMixin(models.AbstractModel):
|
||||
"""Used to implement template rendering functions.
|
||||
Inherit in your model in case you want to render variable values in it.
|
||||
"""
|
||||
|
||||
_name = "cx.tower.template.mixin"
|
||||
_description = "Cetmix Tower template rendering mixin"
|
||||
|
||||
code = fields.Text(help="This field will be rendered using variables")
|
||||
variable_ids = fields.Many2many(
|
||||
string="Variables",
|
||||
comodel_name="cx.tower.variable",
|
||||
compute="_compute_variable_ids",
|
||||
store=True,
|
||||
copy=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_depends_fields(cls):
|
||||
"""
|
||||
Define dependent fields for the `variable_ids` computation.
|
||||
|
||||
This method should be overridden in inheriting models to provide
|
||||
a list of fields that influence the computation of `variable_ids`.
|
||||
These fields are used in the `@api.depends` decorator to trigger
|
||||
recomputation when their values change.
|
||||
|
||||
Returns:
|
||||
list: A list of field names (str) that are dependencies for
|
||||
the `variable_ids` computation. Default is an empty list.
|
||||
|
||||
Example:
|
||||
In a subclass, override as follows:
|
||||
>>> @classmethod
|
||||
>>> def _get_depends_fields(cls):
|
||||
>>> return ["code", "path"]
|
||||
"""
|
||||
return []
|
||||
|
||||
@api.depends(lambda self: self._get_depends_fields())
|
||||
def _compute_variable_ids(self):
|
||||
"""
|
||||
Compute the values of the `variable_ids`
|
||||
field based on model-specific dependencies.
|
||||
|
||||
This method retrieves the dependent fields using `_get_depends_fields`
|
||||
and dynamically calculates the values of `variable_ids` using the
|
||||
`_prepare_variable_commands` method.
|
||||
|
||||
If no dependent fields or relation parameters are defined, the field
|
||||
is reset to an empty list.
|
||||
|
||||
Example:
|
||||
If dependent fields include `code` and `path`, and the model-specific
|
||||
logic links them to variables, this method will update the `variable_ids`
|
||||
field accordingly.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the field metadata is incorrectly defined or
|
||||
missing required attributes.
|
||||
|
||||
Returns:
|
||||
None: The field `variable_ids` is updated in-place for each record.
|
||||
"""
|
||||
depends_fields = self._get_depends_fields()
|
||||
|
||||
for record in self:
|
||||
if depends_fields:
|
||||
record.variable_ids = record._prepare_variable_commands(depends_fields)
|
||||
else:
|
||||
record.variable_ids = [(5, 0, 0)]
|
||||
|
||||
def render_code(self, pythonic_mode=False, **kwargs):
|
||||
"""Render record 'code' field using variables from kwargs
|
||||
Call to render recordset of the inheriting models
|
||||
Args:
|
||||
pythonic_mode (Bool): If True, all variables in kwargs are converted to
|
||||
strings and wrapped in double quotes.
|
||||
Default is False.
|
||||
**kwargs (dict): {variable: value, ...}
|
||||
Returns:
|
||||
dict {record_id: rendered_code, ...}
|
||||
"""
|
||||
return {
|
||||
rec.id: self.render_code_custom(rec.code, pythonic_mode, **kwargs)
|
||||
for rec in self
|
||||
}
|
||||
|
||||
def render_code_custom(self, code, pythonic_mode=False, **kwargs):
|
||||
"""
|
||||
Render custom code using variables from kwargs
|
||||
|
||||
This method renders a template string (code) using the variables provided
|
||||
in kwargs. If pythonic_mode is enabled, all variables are automatically
|
||||
converted to strings and enclosed in double quotes before rendering.
|
||||
|
||||
Args:
|
||||
code (Text): code to render (eg 'some {{ custom }} text')
|
||||
pythonic_mode (Bool): If True, all variables in kwargs are converted to
|
||||
strings and wrapped in double quotes.
|
||||
Default is False.
|
||||
**kwargs (dict): {variable: value, ...}
|
||||
Returns:
|
||||
rendered_code (text): The resulting string after rendering the template with
|
||||
the provided variables.
|
||||
"""
|
||||
try:
|
||||
if pythonic_mode:
|
||||
kwargs = {
|
||||
key: self._make_value_pythonic(value)
|
||||
for key, value in kwargs.items()
|
||||
}
|
||||
return Template(code, trim_blocks=True).render(kwargs)
|
||||
except Exception as e:
|
||||
raise UserError(str(e)) from e
|
||||
|
||||
def get_variables(self):
|
||||
"""Get the list of variables for templates
|
||||
Call to get variables for recordset of the inheriting models
|
||||
|
||||
Returns:
|
||||
dict {'record_id': {variables}...}
|
||||
NB: 'record_id' is String
|
||||
"""
|
||||
res = {}
|
||||
for rec in self:
|
||||
res[str(rec.id)] = self.get_variables_from_code(rec.code)
|
||||
return res
|
||||
|
||||
def get_variables_from_code(self, code):
|
||||
"""Get the list of variables for templates
|
||||
Call to get variables from custom code string
|
||||
|
||||
Args:
|
||||
code (Text) custom code (eg 'Custom {{ var }} {{ var2 }} ...')
|
||||
Returns:
|
||||
variables (List) variables (eg ['var','var2',..])
|
||||
"""
|
||||
env = Environment()
|
||||
try:
|
||||
ast = env.parse(code)
|
||||
undeclared_variables = meta.find_undeclared_variables(ast)
|
||||
return list(undeclared_variables) if undeclared_variables else []
|
||||
except TemplateSyntaxError as e:
|
||||
raise ValidationError(_("Variable syntax error: %s", e)) from e
|
||||
|
||||
def _prepare_variable_commands(self, field_names, force_record=None):
|
||||
"""
|
||||
Prepares commands to set variable references from the given fields.
|
||||
|
||||
Args:
|
||||
field_names (list): List of field names to extract variable references from.
|
||||
force_record (record, optional): A record to use instead of the current one.
|
||||
|
||||
Returns:
|
||||
list: An Odoo command to assign or clear variable references.
|
||||
"""
|
||||
record = force_record or self
|
||||
record.ensure_one()
|
||||
|
||||
all_references = set()
|
||||
for field_name in field_names:
|
||||
value = getattr(record, field_name, None)
|
||||
if value:
|
||||
all_references.update(self.get_variables_from_code(value))
|
||||
|
||||
if all_references:
|
||||
variables = self.env["cx.tower.variable"].search(
|
||||
[("reference", "in", list(all_references))]
|
||||
)
|
||||
command = [(6, 0, variables.ids)]
|
||||
else:
|
||||
command = [(5, 0, 0)]
|
||||
|
||||
return command
|
||||
|
||||
def _make_value_pythonic(self, value):
|
||||
"""Prepares value for use in 'pythonic' mode
|
||||
by enclosing strings into double quotes
|
||||
|
||||
Args:
|
||||
value (Char): value to process
|
||||
|
||||
Returns:
|
||||
Char: processed value
|
||||
"""
|
||||
|
||||
# Nothing to do here
|
||||
if isinstance(value, bool) or value is None:
|
||||
result = value
|
||||
|
||||
# Handle nested dicts such as system variables
|
||||
elif isinstance(value, dict):
|
||||
result = {}
|
||||
for key, val in value.items():
|
||||
result.update({key: self._make_value_pythonic(val)})
|
||||
else:
|
||||
# Enclose in double quotes
|
||||
result = f'"{value}"'
|
||||
return result
|
||||
368
addons/cetmix_tower_server/models/cx_tower_variable.py
Normal file
@@ -0,0 +1,368 @@
|
||||
# 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.tools.safe_eval import wrap_module
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
re = wrap_module(
|
||||
__import__("re"),
|
||||
[
|
||||
"match",
|
||||
"fullmatch",
|
||||
"search",
|
||||
"sub",
|
||||
"subn",
|
||||
"split",
|
||||
"findall",
|
||||
"finditer",
|
||||
"compile",
|
||||
"template",
|
||||
"escape",
|
||||
"error",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TowerVariable(models.Model):
|
||||
"""Variables"""
|
||||
|
||||
_name = "cx.tower.variable"
|
||||
_description = "Cetmix Tower Variable"
|
||||
_inherit = [
|
||||
"cx.tower.reference.mixin",
|
||||
"cx.tower.access.mixin",
|
||||
"cx.tower.tag.mixin",
|
||||
]
|
||||
|
||||
_order = "name"
|
||||
|
||||
DEFAULT_VALIDATION_MESSAGE = _("Invalid value!")
|
||||
|
||||
value_ids = fields.One2many(
|
||||
string="Values",
|
||||
comodel_name="cx.tower.variable.value",
|
||||
inverse_name="variable_id",
|
||||
)
|
||||
value_ids_count = fields.Integer(
|
||||
string="Value Count", compute="_compute_variable_counters"
|
||||
)
|
||||
option_ids = fields.One2many(
|
||||
comodel_name="cx.tower.variable.option",
|
||||
inverse_name="variable_id",
|
||||
string="Options",
|
||||
auto_join=True,
|
||||
)
|
||||
variable_type = fields.Selection(
|
||||
selection=[("s", "String"), ("o", "Options")],
|
||||
default="s",
|
||||
required=True,
|
||||
string="Type",
|
||||
)
|
||||
applied_expression = fields.Text(
|
||||
help="Python expression to apply to the variable value. \n"
|
||||
"You can use general python sting functions and 're' module "
|
||||
"for regex operations. "
|
||||
"Use 'value' variable to refer to the variable value, use 'result'"
|
||||
" to assign the final result that will be used as a variable value.\n"
|
||||
"Eg 'result = value.lower().replace(' ', '_')'",
|
||||
)
|
||||
validation_pattern = fields.Char(
|
||||
help="Regex pattern to validate the variable values using the "
|
||||
"'re.match' function. Eg. ^[a-z0-9]+$ \n"
|
||||
"If empty, the variable values will not be validated.",
|
||||
)
|
||||
validation_message = fields.Char(
|
||||
translate=True,
|
||||
help="Message to display when the variable value is invalid. \n"
|
||||
"First line will be added automatically: "
|
||||
"`Variable:<variable_name>, Value: <value>`\n"
|
||||
"Eg: `Variable: Customer Name, Value: Test\nInvalid value!`\n"
|
||||
"If empty, the default message will be used.",
|
||||
)
|
||||
note = fields.Text(
|
||||
help="Additional notes about the variable. \n"
|
||||
"This field will be displayed in the variable form.",
|
||||
)
|
||||
|
||||
# --- Link to records where the variable is used
|
||||
command_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.command",
|
||||
relation="cx_tower_command_variable_rel",
|
||||
column1="variable_id",
|
||||
column2="command_id",
|
||||
copy=False,
|
||||
)
|
||||
command_ids_count = fields.Integer(
|
||||
string="Command Count", compute="_compute_variable_counters"
|
||||
)
|
||||
plan_line_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.plan.line",
|
||||
relation="cx_tower_plan_line_variable_rel",
|
||||
column1="variable_id",
|
||||
column2="plan_line_id",
|
||||
copy=False,
|
||||
)
|
||||
plan_line_ids_count = fields.Integer(
|
||||
string="Plan Line Count", compute="_compute_variable_counters"
|
||||
)
|
||||
file_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.file",
|
||||
relation="cx_tower_file_variable_rel",
|
||||
column1="variable_id",
|
||||
column2="file_id",
|
||||
copy=False,
|
||||
)
|
||||
file_ids_count = fields.Integer(
|
||||
string="File Count", compute="_compute_variable_counters"
|
||||
)
|
||||
file_template_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.file.template",
|
||||
relation="cx_tower_file_template_variable_rel",
|
||||
column1="variable_id",
|
||||
column2="file_template_id",
|
||||
copy=False,
|
||||
)
|
||||
file_template_ids_count = fields.Integer(
|
||||
string="File Template Count", compute="_compute_variable_counters"
|
||||
)
|
||||
variable_value_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.variable.value",
|
||||
relation="cx_tower_variable_value_variable_rel",
|
||||
column1="variable_id",
|
||||
column2="variable_value_id",
|
||||
copy=False,
|
||||
)
|
||||
variable_value_ids_count = fields.Integer(
|
||||
string="Variable Value Count", compute="_compute_variable_counters"
|
||||
)
|
||||
|
||||
_sql_constraints = [("name_uniq", "unique (name)", "Variable names must be unique")]
|
||||
|
||||
def _compute_variable_counters(self):
|
||||
"""Count number of variable values for the variable"""
|
||||
for rec in self:
|
||||
rec.update(
|
||||
{
|
||||
"variable_value_ids_count": len(rec.variable_value_ids),
|
||||
"command_ids_count": len(rec.command_ids),
|
||||
"plan_line_ids_count": len(rec.plan_line_ids),
|
||||
"file_ids_count": len(rec.file_ids),
|
||||
"file_template_ids_count": len(rec.file_template_ids),
|
||||
"value_ids_count": len(rec.value_ids),
|
||||
}
|
||||
)
|
||||
|
||||
def action_open_values(self):
|
||||
self.ensure_one()
|
||||
context = self.env.context.copy()
|
||||
context.update(
|
||||
{
|
||||
"default_variable_id": self.id,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Variable Values"),
|
||||
"res_model": "cx.tower.variable.value",
|
||||
"views": [[False, "tree"]],
|
||||
"target": "current",
|
||||
"context": context,
|
||||
"domain": [("variable_id", "=", self.id)],
|
||||
}
|
||||
|
||||
def action_open_commands(self):
|
||||
"""Open the commands where the variable is used"""
|
||||
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
"cetmix_tower_server.action_cx_tower_command"
|
||||
)
|
||||
action.update(
|
||||
{
|
||||
"domain": [("variable_ids", "in", self.ids)],
|
||||
}
|
||||
)
|
||||
return action
|
||||
|
||||
def action_open_plan_lines(self):
|
||||
"""Open the plan lines where the variable is used"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Plan Lines"),
|
||||
"res_model": "cx.tower.plan.line",
|
||||
"views": [
|
||||
[False, "tree"],
|
||||
[
|
||||
self.env.ref("cetmix_tower_server.cx_tower_plan_line_view_form").id,
|
||||
"form",
|
||||
],
|
||||
],
|
||||
"target": "current",
|
||||
"domain": [("variable_ids", "in", self.ids)],
|
||||
}
|
||||
|
||||
def action_open_files(self):
|
||||
"""Open the files where the variable is used"""
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
"cetmix_tower_server.cx_tower_file_action"
|
||||
)
|
||||
action.update(
|
||||
{
|
||||
"domain": [("variable_ids", "in", self.ids)],
|
||||
}
|
||||
)
|
||||
return action
|
||||
|
||||
def action_open_file_templates(self):
|
||||
"""Open the file templates where the variable is used"""
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
"cetmix_tower_server.cx_tower_file_template_action"
|
||||
)
|
||||
action.update(
|
||||
{
|
||||
"domain": [("variable_ids", "in", self.ids)],
|
||||
}
|
||||
)
|
||||
return action
|
||||
|
||||
def action_open_variable_values(self):
|
||||
"""Open the variable values where the variable is used"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Variable Values"),
|
||||
"res_model": "cx.tower.variable.value",
|
||||
"views": [[False, "tree"]],
|
||||
"target": "current",
|
||||
"domain": [("variable_ids", "in", self.ids)],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_eval_context(self, value_char=None):
|
||||
"""
|
||||
Evaluation context to pass to safe_eval to evaluate
|
||||
the Python expression used in the `applied_expression` field
|
||||
|
||||
Args:
|
||||
value_char (Char): variable value
|
||||
|
||||
Returns:
|
||||
dict: evaluation context
|
||||
"""
|
||||
return {
|
||||
"re": re,
|
||||
"value": value_char,
|
||||
}
|
||||
|
||||
# Reference rename propagation
|
||||
|
||||
def write(self, vals):
|
||||
"""Override the write method to propagate variable reference updates.
|
||||
|
||||
Records the old reference values, performs the write, and if the reference
|
||||
field has changed, initiates propagation to update related records.
|
||||
"""
|
||||
old_refs = (
|
||||
{rec.id: rec.reference for rec in self} if "reference" in vals else {}
|
||||
)
|
||||
res = super().write(vals)
|
||||
if "reference" in vals:
|
||||
for rec in self:
|
||||
old_ref = old_refs.get(rec.id)
|
||||
if old_ref and old_ref != rec.reference:
|
||||
rec._propagate_reference_change(old_ref, rec.reference)
|
||||
return res
|
||||
|
||||
def _propagate_reference_change(self, old_ref, new_ref):
|
||||
"""Replace all occurrences of an old variable reference with a new one.
|
||||
|
||||
Compiles a pattern matching the old Jinja-style reference, then searches across
|
||||
configured models and fields to substitute any matches, preserving formatting.
|
||||
"""
|
||||
pattern = re.compile(r"(\{\{\s*)" + re.escape(old_ref) + r"(\s*\}\})")
|
||||
|
||||
def _replace(text):
|
||||
"""Helper to replace old_ref with new_ref in the given text."""
|
||||
return pattern.sub(lambda m: f"{m.group(1)}{new_ref}{m.group(2)}", text)
|
||||
|
||||
model_fields_map = self._get_propagation_field_mapping()
|
||||
|
||||
for model_name, field_names in model_fields_map.items():
|
||||
Model = self.env[model_name]
|
||||
|
||||
if model_name == "cx.tower.variable.value":
|
||||
domain = [("variable_id", "=", self.id)]
|
||||
else:
|
||||
domain = [("variable_ids", "in", self.ids)]
|
||||
|
||||
for record in Model.search(domain):
|
||||
vals = {}
|
||||
for field_name in field_names:
|
||||
value = record[field_name]
|
||||
if isinstance(value, str) and old_ref in value:
|
||||
new_value = _replace(value)
|
||||
if new_value != value:
|
||||
vals[field_name] = new_value
|
||||
|
||||
if vals:
|
||||
record.with_context(skip_reference_propagation=True).write(vals)
|
||||
_logger.debug(
|
||||
"Variable reference updated in %s(%s): %s",
|
||||
model_name,
|
||||
record.id,
|
||||
", ".join(vals.keys()),
|
||||
)
|
||||
|
||||
def _get_propagation_field_mapping(self):
|
||||
"""Return the mapping of models to fields for reference change propagation.
|
||||
|
||||
The returned dict maps each model name to a list of field names
|
||||
that may contain variable references requiring updates.
|
||||
"""
|
||||
return {
|
||||
"cx.tower.command": ["code", "path"],
|
||||
"cx.tower.file": ["code", "server_dir", "name"],
|
||||
"cx.tower.file.template": ["code", "server_dir", "file_name"],
|
||||
"cx.tower.variable.value": ["value_char"],
|
||||
"cx.tower.plan.line": ["condition"],
|
||||
}
|
||||
|
||||
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 + ["value_ids"]
|
||||
|
||||
def _validate_value(self, value_char=None):
|
||||
"""
|
||||
Validate the variable value
|
||||
|
||||
Args:
|
||||
value_char (Char): variable value
|
||||
|
||||
Returns:
|
||||
(Boolean, Char): (is_valid, validation_message)
|
||||
"""
|
||||
self.ensure_one()
|
||||
if (
|
||||
not self.validation_pattern
|
||||
or not value_char
|
||||
or re.match(self.validation_pattern, value_char)
|
||||
):
|
||||
return True, None
|
||||
message = self.validation_message or self.DEFAULT_VALIDATION_MESSAGE
|
||||
return (
|
||||
False,
|
||||
_(
|
||||
"Variable: %(var)s, Value: %(val)s\n%(msg)s",
|
||||
msg=message,
|
||||
var=self.name,
|
||||
val=value_char,
|
||||
),
|
||||
)
|
||||
283
addons/cetmix_tower_server/models/cx_tower_variable_mixin.py
Normal file
@@ -0,0 +1,283 @@
|
||||
# Copyright (C) 2022 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TowerVariableMixin(models.AbstractModel):
|
||||
"""Used to implement variables and variable values.
|
||||
Inherit in your model if you want to use variables in it.
|
||||
"""
|
||||
|
||||
_name = "cx.tower.variable.mixin"
|
||||
_description = "Tower Variables mixin"
|
||||
|
||||
variable_value_ids = fields.One2many(
|
||||
string="Variable Values",
|
||||
comodel_name="cx.tower.variable.value",
|
||||
auto_join=True,
|
||||
help="Variable values for selected record",
|
||||
)
|
||||
|
||||
def get_variable_values(self, variable_references, apply_modifiers=True):
|
||||
"""Get variable values for selected records
|
||||
|
||||
Args:
|
||||
variable_references (list of Char): variable names
|
||||
apply_modifiers (bool): apply Python modifiers to the values
|
||||
|
||||
Returns:
|
||||
dict {record_id: {variable_reference: value}}
|
||||
"""
|
||||
res = {}
|
||||
|
||||
# Get global values first
|
||||
if variable_references:
|
||||
global_values = self.get_global_variable_values(variable_references)
|
||||
|
||||
# Get record wise values
|
||||
for rec in self:
|
||||
res_vars = global_values.get(
|
||||
rec.id, {}
|
||||
) # set global values as defaults
|
||||
for variable_reference in variable_references:
|
||||
# Check if this is a system variable
|
||||
system_value = self._get_system_variable_value(variable_reference)
|
||||
if system_value:
|
||||
res_vars.update({variable_reference: system_value})
|
||||
|
||||
# Get regular value
|
||||
else:
|
||||
value = rec.variable_value_ids.filtered(
|
||||
lambda v,
|
||||
variable_reference=variable_reference: v.variable_reference
|
||||
== variable_reference
|
||||
)
|
||||
if value:
|
||||
res_vars.update({variable_reference: value.value_char})
|
||||
|
||||
res.update({rec.id: res_vars})
|
||||
|
||||
# Final render
|
||||
# Render templates in values
|
||||
for variable_values in res.values():
|
||||
self._render_variable_values(variable_values)
|
||||
|
||||
# Apply modifiers
|
||||
if apply_modifiers:
|
||||
self._apply_modifiers(res)
|
||||
return res
|
||||
|
||||
def get_global_variable_values(self, variable_references):
|
||||
"""Get global values for variables.
|
||||
Such values do not belong to any record.
|
||||
|
||||
This function is used by get_variable_values()
|
||||
to compute fallback values.
|
||||
|
||||
Args:
|
||||
variable_references (list of Char): variable names
|
||||
|
||||
Returns:
|
||||
dict {record_id: {variable_reference: value}}
|
||||
"""
|
||||
res = {}
|
||||
|
||||
if variable_references:
|
||||
values = self.env["cx.tower.variable.value"].search(
|
||||
self._compose_variable_global_values_domain(variable_references)
|
||||
)
|
||||
for rec in self:
|
||||
res_vars = {}
|
||||
for variable_reference in variable_references:
|
||||
# Get variable value
|
||||
value = values.filtered(
|
||||
lambda v,
|
||||
variable_reference=variable_reference: v.variable_reference
|
||||
== variable_reference
|
||||
)
|
||||
res_vars.update(
|
||||
{variable_reference: value.value_char if value else None}
|
||||
)
|
||||
res.update({rec.id: res_vars})
|
||||
return res
|
||||
|
||||
def _get_system_variable_value(self, variable_reference):
|
||||
"""Get the value of a system variable. Eg `tower.server.partner_name`
|
||||
|
||||
Args:
|
||||
variable_reference (Char): variable value
|
||||
|
||||
Returns:
|
||||
dict(): populates `tower` variable with with values.
|
||||
{
|
||||
'server': {..server vals..},
|
||||
'tools': {..helper tools vals...}
|
||||
}.
|
||||
"""
|
||||
|
||||
# This works for a single record only!
|
||||
self.ensure_one()
|
||||
|
||||
variable_value = {}
|
||||
if variable_reference == "tower":
|
||||
variable_value.update(
|
||||
{
|
||||
"server": self._parse_system_variable_server(),
|
||||
"tools": self._parse_system_variable_tools(),
|
||||
}
|
||||
)
|
||||
|
||||
return variable_value
|
||||
|
||||
def _parse_system_variable_server(self):
|
||||
"""Parser system variable of `server` type.
|
||||
|
||||
Returns:
|
||||
dict(): `server` values of the `tower` variable.
|
||||
"""
|
||||
# Get current server
|
||||
values = {}
|
||||
server = self._get_current_server()
|
||||
if server:
|
||||
values = {
|
||||
"name": server.name,
|
||||
"reference": server.reference,
|
||||
"username": server.ssh_username,
|
||||
"partner_name": server.partner_id.name if server.partner_id else False,
|
||||
"ipv4": server.ip_v4_address,
|
||||
"ipv6": server.ip_v6_address,
|
||||
"status": server.status,
|
||||
"os": server.os_id.name if server.os_id else False,
|
||||
"url": server.url,
|
||||
}
|
||||
return values
|
||||
|
||||
def _parse_system_variable_tools(self):
|
||||
"""Parser system variable of `tools` type.
|
||||
|
||||
Returns:
|
||||
dict(): `server` values of the `tower` variable.
|
||||
"""
|
||||
today = fields.Date.to_string(fields.Date.today())
|
||||
now = fields.Datetime.to_string(fields.Datetime.now())
|
||||
values = {
|
||||
"uuid": uuid.uuid4(),
|
||||
"today": today,
|
||||
"now": now,
|
||||
"today_underscore": re.sub(r"[-: .\/]", "_", today),
|
||||
"now_underscore": re.sub(r"[-: .\/]", "_", now),
|
||||
}
|
||||
return values
|
||||
|
||||
def _compose_variable_global_values_domain(self, variable_references):
|
||||
"""Compose domain for global variables
|
||||
Args:
|
||||
variable_references (list of Char): variable names
|
||||
|
||||
Returns:
|
||||
domain
|
||||
"""
|
||||
domain = [
|
||||
("is_global", "=", True),
|
||||
("variable_reference", "in", variable_references),
|
||||
]
|
||||
return domain
|
||||
|
||||
def _render_variable_values(self, variable_values):
|
||||
"""Renders variable values using other variable values.
|
||||
For example we have the following values:
|
||||
"server_root": "/opt/server"
|
||||
"server_assets": "{{ server_root }}/assets"
|
||||
|
||||
This function will render the "server_assets" variable:
|
||||
"server_assets": "/opt/server/assets"
|
||||
|
||||
Args:
|
||||
variable_values (dict): values to complete
|
||||
"""
|
||||
self.ensure_one()
|
||||
TemplateMixin = self.env["cx.tower.template.mixin"]
|
||||
for key, var_value in variable_values.items():
|
||||
# Render only if template is found
|
||||
if var_value and "{{ " in var_value:
|
||||
# Get variables used in value
|
||||
value_vars = TemplateMixin.get_variables_from_code(var_value)
|
||||
|
||||
# Render variables used in value
|
||||
res = self.get_variable_values(value_vars, apply_modifiers=True)
|
||||
|
||||
# Render value using variables
|
||||
variable_values[key] = TemplateMixin.render_code_custom(
|
||||
var_value, **res[self.id]
|
||||
)
|
||||
|
||||
def _apply_modifiers(self, variable_values):
|
||||
"""Apply pre-defined Python expression to the dictionary
|
||||
of variable values.
|
||||
|
||||
Args:
|
||||
variable_values (dict): variable values
|
||||
{record_id: {variable_reference: value}}
|
||||
"""
|
||||
variable_obj = self.env["cx.tower.variable"]
|
||||
|
||||
for record_id, values in variable_values.items():
|
||||
for variable_reference, value in values.items():
|
||||
if not value:
|
||||
continue
|
||||
|
||||
# ORM should cache resolved variables
|
||||
variable = variable_obj.get_by_reference(variable_reference)
|
||||
|
||||
# Should never happen.. anyway
|
||||
if not variable:
|
||||
continue
|
||||
|
||||
# Skip if no expression to apply
|
||||
if not variable.applied_expression:
|
||||
continue
|
||||
|
||||
# Evaluate expression
|
||||
eval_context = variable_obj._get_eval_context(value)
|
||||
try:
|
||||
safe_eval(
|
||||
variable.applied_expression,
|
||||
eval_context,
|
||||
mode="exec",
|
||||
nocopy=True,
|
||||
)
|
||||
variable_values[record_id][variable_reference] = eval_context.get(
|
||||
"result"
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Error evaluating applied expression for "
|
||||
"variable %s value %s: %s",
|
||||
variable.name,
|
||||
value,
|
||||
str(e),
|
||||
)
|
||||
|
||||
def _get_current_server(self):
|
||||
"""Get current server record.
|
||||
This is needed to render system variables properly.
|
||||
|
||||
Returns:
|
||||
cx.tower.server(): server record
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if self._name == "cx.tower.server":
|
||||
server = self
|
||||
elif self._name == "cx.tower.variable.value" and self.server_id:
|
||||
server = self.server_id
|
||||
else:
|
||||
server = None
|
||||
return server
|
||||
117
addons/cetmix_tower_server/models/cx_tower_variable_option.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# 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 TowerVariableOption(models.Model):
|
||||
"""
|
||||
Model to manage variable options in the Cetmix Tower.
|
||||
|
||||
The model allows defining options
|
||||
that are linked to tower variables and can be used to
|
||||
manage configurations or settings for those variables.
|
||||
"""
|
||||
|
||||
_name = "cx.tower.variable.option"
|
||||
_description = "Cetmix Tower Variable Options"
|
||||
_inherit = ["cx.tower.reference.mixin", "cx.tower.access.mixin"]
|
||||
_order = "sequence, name"
|
||||
|
||||
access_level = fields.Selection(
|
||||
compute="_compute_access_level",
|
||||
readonly=False,
|
||||
store=True,
|
||||
default=None,
|
||||
)
|
||||
name = fields.Char(required=True)
|
||||
value_char = fields.Char(string="Value", required=True)
|
||||
variable_id = fields.Many2one(
|
||||
comodel_name="cx.tower.variable",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
# Define a SQL constraint to ensure the combination of
|
||||
# 'name' and 'variable_id' is unique
|
||||
_sql_constraints = [
|
||||
(
|
||||
"unique_variable_option",
|
||||
"unique (value_char, variable_id)",
|
||||
"The combination of Value and Variable must be unique.",
|
||||
),
|
||||
(
|
||||
"unique_variable_option_name",
|
||||
"unique (name, variable_id)",
|
||||
"The combination of Name and Variable must be unique.",
|
||||
),
|
||||
]
|
||||
|
||||
@api.depends("variable_id", "variable_id.access_level")
|
||||
def _compute_access_level(self):
|
||||
"""
|
||||
Automatically set the access_level based on Variable access level
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.variable_id:
|
||||
rec.access_level = rec.variable_id.access_level
|
||||
|
||||
@api.constrains("access_level", "variable_id")
|
||||
def _check_access_level_consistency(self):
|
||||
"""
|
||||
Ensure that the access level of the variable value is not lower than
|
||||
the access level of the associated variable.
|
||||
"""
|
||||
access_level_dict = dict(
|
||||
self.fields_get(["access_level"])["access_level"]["selection"]
|
||||
)
|
||||
for rec in self:
|
||||
if not rec.variable_id:
|
||||
continue
|
||||
if not rec.access_level:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"Access level is not defined for '%(option)s'",
|
||||
option=rec.name,
|
||||
)
|
||||
)
|
||||
if rec.access_level < rec.variable_id.access_level:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"The access level for Variable Option '%(value)s' "
|
||||
"cannot be lower than the access level of its "
|
||||
"Variable '%(variable)s'.\n"
|
||||
"Variable Access Level: %(var_level)s\n"
|
||||
"Variable Option Access Level: %(val_level)s",
|
||||
value=rec.name,
|
||||
variable=rec.variable_id.name,
|
||||
var_level=access_level_dict[rec.variable_id.access_level],
|
||||
val_level=access_level_dict[rec.access_level],
|
||||
)
|
||||
)
|
||||
|
||||
# Workaround for the default value not being set
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
variable_obj = self.env["cx.tower.variable"]
|
||||
for vals in vals_list:
|
||||
# Set access level from the variable
|
||||
# if not provided explicitly
|
||||
access_level = vals.get("access_level")
|
||||
if access_level:
|
||||
continue
|
||||
variable_id = vals.get("variable_id")
|
||||
if variable_id:
|
||||
variable = variable_obj.browse(variable_id)
|
||||
vals["access_level"] = variable.access_level
|
||||
return super().create(vals_list)
|
||||
|
||||
def _get_pre_populated_model_data(self):
|
||||
"""
|
||||
Define the model relationships for reference generation.
|
||||
"""
|
||||
res = super()._get_pre_populated_model_data()
|
||||
res.update({"cx.tower.variable.option": ["cx.tower.variable", "variable_id"]})
|
||||
return res
|
||||
541
addons/cetmix_tower_server/models/cx_tower_variable_value.py
Normal file
@@ -0,0 +1,541 @@
|
||||
# Copyright (C) 2022 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import re
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.osv.expression import OR
|
||||
|
||||
|
||||
class TowerVariableValue(models.Model):
|
||||
"""
|
||||
This model is used to store variable values.
|
||||
"""
|
||||
|
||||
_name = "cx.tower.variable.value"
|
||||
_description = "Cetmix Tower Variable Values"
|
||||
_inherit = [
|
||||
"cx.tower.reference.mixin",
|
||||
"cx.tower.access.mixin",
|
||||
]
|
||||
_rec_name = "variable_reference"
|
||||
_order = "sequence, variable_reference"
|
||||
|
||||
sequence = fields.Integer(default=10)
|
||||
access_level = fields.Selection(
|
||||
compute="_compute_access_level",
|
||||
readonly=False,
|
||||
store=True,
|
||||
default=None,
|
||||
)
|
||||
variable_id = fields.Many2one(
|
||||
string="Variable",
|
||||
comodel_name="cx.tower.variable",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
name = fields.Char(related="variable_id.name", readonly=True)
|
||||
variable_reference = fields.Char(
|
||||
string="Variable Reference",
|
||||
related="variable_id.reference",
|
||||
store=True,
|
||||
index=True,
|
||||
)
|
||||
is_global = fields.Boolean(
|
||||
string="Global",
|
||||
compute="_compute_is_global",
|
||||
inverse="_inverse_is_global",
|
||||
store=True,
|
||||
)
|
||||
note = fields.Text(related="variable_id.note", readonly=True)
|
||||
active = fields.Boolean(default=True)
|
||||
variable_type = fields.Selection(
|
||||
related="variable_id.variable_type",
|
||||
readonly=True,
|
||||
)
|
||||
option_id = fields.Many2one(
|
||||
comodel_name="cx.tower.variable.option",
|
||||
ondelete="restrict",
|
||||
domain="[('variable_id', '=', variable_id)]",
|
||||
)
|
||||
value_char = fields.Char(
|
||||
string="Value",
|
||||
compute="_compute_value_char",
|
||||
inverse="_inverse_value_char",
|
||||
store=True,
|
||||
readonly=False,
|
||||
)
|
||||
|
||||
# Direct model relations.
|
||||
# Following functions should be updated when a new m2o field is added:
|
||||
# - `_used_in_models()`
|
||||
# - `_compute_is_global()`: add you field to 'depends'
|
||||
# Define a `unique` constraint for new model too.
|
||||
server_id = fields.Many2one(
|
||||
comodel_name="cx.tower.server", index=True, ondelete="cascade"
|
||||
)
|
||||
plan_line_action_id = fields.Many2one(
|
||||
comodel_name="cx.tower.plan.line.action", index=True, ondelete="cascade"
|
||||
)
|
||||
server_template_id = fields.Many2one(
|
||||
comodel_name="cx.tower.server.template", index=True, ondelete="cascade"
|
||||
)
|
||||
variable_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.variable",
|
||||
relation="cx_tower_variable_value_variable_rel",
|
||||
column1="variable_value_id",
|
||||
column2="variable_id",
|
||||
string="Variables",
|
||||
compute="_compute_variable_ids",
|
||||
store=True,
|
||||
copy=False,
|
||||
)
|
||||
required = fields.Boolean()
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"tower_variable_value_uniq",
|
||||
"unique (variable_id, server_id, server_template_id, "
|
||||
"plan_line_action_id, is_global)",
|
||||
"Variable can be declared only once for the same record!",
|
||||
),
|
||||
(
|
||||
"unique_variable_value_server",
|
||||
"unique (variable_id, server_id)",
|
||||
"A variable value cannot be assigned multiple times to the same server!",
|
||||
),
|
||||
(
|
||||
"unique_variable_value_template",
|
||||
"unique (variable_id, server_template_id)",
|
||||
(
|
||||
"A variable value cannot be assigned multiple"
|
||||
" times to the same server template!"
|
||||
),
|
||||
),
|
||||
(
|
||||
"unique_variable_value_action",
|
||||
"unique (variable_id, plan_line_action_id)",
|
||||
(
|
||||
"A variable value cannot be assigned multiple"
|
||||
" times to the same plan line action!"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
# -- Compute fields --
|
||||
|
||||
@api.depends("variable_id", "variable_id.access_level")
|
||||
def _compute_access_level(self):
|
||||
"""
|
||||
Automatically set the access_level based on Variable access level
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.variable_id:
|
||||
rec.access_level = rec.variable_id.access_level
|
||||
|
||||
@api.depends("server_id", "server_template_id", "plan_line_action_id")
|
||||
def _compute_is_global(self):
|
||||
"""
|
||||
If variable considered `global` when it's not linked to any record.
|
||||
"""
|
||||
for rec in self:
|
||||
rec.is_global = rec._check_is_global()
|
||||
|
||||
@api.depends("option_id", "variable_id.option_ids")
|
||||
def _compute_value_char(self):
|
||||
"""
|
||||
Compute the 'value_char' field, which holds the string representation
|
||||
of the selected option for the variable.
|
||||
"""
|
||||
for rec in self:
|
||||
if not rec.variable_id.option_ids:
|
||||
rec.value_char = rec.value_char or False
|
||||
rec.option_id = False
|
||||
continue
|
||||
if rec.option_id:
|
||||
rec.value_char = rec.option_id.value_char
|
||||
else:
|
||||
rec.value_char = False
|
||||
|
||||
@api.depends("value_char")
|
||||
def _compute_variable_ids(self):
|
||||
"""
|
||||
Compute variable_ids based on value_char field.
|
||||
"""
|
||||
template_mixin_obj = self.env["cx.tower.template.mixin"]
|
||||
for record in self:
|
||||
record.variable_ids = template_mixin_obj._prepare_variable_commands(
|
||||
["value_char"], force_record=record
|
||||
)
|
||||
|
||||
# -- Constraints --
|
||||
|
||||
@api.constrains("access_level", "variable_id")
|
||||
def _check_access_level_consistency(self):
|
||||
"""
|
||||
Ensure that variable value access level is defined.
|
||||
Ensure that the access level of the variable value is not lower than
|
||||
the access level of the associated variable.
|
||||
"""
|
||||
access_level_dict = dict(
|
||||
self.fields_get(["access_level"])["access_level"]["selection"]
|
||||
)
|
||||
for rec in self:
|
||||
if not rec.variable_id:
|
||||
continue
|
||||
if not rec.access_level:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"Access level is not defined for '%(variable)s'",
|
||||
variable=rec.name,
|
||||
)
|
||||
)
|
||||
if rec.access_level < rec.variable_id.access_level:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"The access level for Variable Value '%(value)s' "
|
||||
"cannot be lower than the access level of its "
|
||||
"Variable '%(variable)s'.\n"
|
||||
"Variable Access Level: %(var_level)s\n"
|
||||
"Variable Value Access Level: %(val_level)s",
|
||||
value=rec.value_char,
|
||||
variable=rec.variable_id.name,
|
||||
var_level=access_level_dict[rec.variable_id.access_level],
|
||||
val_level=access_level_dict[rec.access_level],
|
||||
)
|
||||
)
|
||||
|
||||
@api.constrains("is_global", "value_char")
|
||||
def _constraint_global_unique(self):
|
||||
"""Ensure that there is only one global value exist for the same variable
|
||||
|
||||
Hint to devs:
|
||||
`unique nulls not distinct (variable_id,server_id,global_id)`
|
||||
can be used instead in PG 15.0+
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.is_global:
|
||||
val_count = self.search_count(
|
||||
[("variable_id", "=", rec.variable_id.id), ("is_global", "=", True)]
|
||||
)
|
||||
if val_count > 1:
|
||||
# NB: there is a value check in tests for this message.
|
||||
# Update `test_variable_value_toggle_global`
|
||||
# if you modify this message in your code.
|
||||
raise ValidationError(
|
||||
_(
|
||||
"Only one global value can be defined"
|
||||
" for variable '%(var)s'",
|
||||
var=rec.variable_id.name,
|
||||
)
|
||||
)
|
||||
|
||||
@api.constrains("value_char", "option_id")
|
||||
def _check_value_char_and_option_id(self):
|
||||
"""
|
||||
Check if the value_char is valid for the variable.
|
||||
"""
|
||||
for rec in self:
|
||||
if not rec.variable_id:
|
||||
continue
|
||||
valid, message = rec.variable_id._validate_value(rec.value_char)
|
||||
if not valid:
|
||||
raise ValidationError(message)
|
||||
if rec.option_id:
|
||||
if rec.option_id.variable_id != rec.variable_id:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"Option '%(val)s' is not available for variable '%(var)s'",
|
||||
val=rec.value_char,
|
||||
var=rec.variable_id.name,
|
||||
)
|
||||
)
|
||||
|
||||
@api.constrains("server_id", "server_template_id", "plan_line_action_id")
|
||||
def _check_single_assignment(self):
|
||||
"""Ensure that a variable is only assigned to one model at a time."""
|
||||
for record in self:
|
||||
# Check how many of the fields are set
|
||||
count_assigned = (
|
||||
bool(record.server_id)
|
||||
+ bool(record.server_template_id)
|
||||
+ bool(record.plan_line_action_id)
|
||||
)
|
||||
if count_assigned > 1:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"Variable '%(var)s' can only be assigned to one of the models "
|
||||
"at a time: "
|
||||
"Server, Server Template, or Plan Line Action.",
|
||||
var=record.variable_id.name,
|
||||
)
|
||||
)
|
||||
|
||||
# -- Onchange --
|
||||
|
||||
@api.onchange("variable_id")
|
||||
def _onchange_variable_id(self):
|
||||
"""
|
||||
Reset option_id when variable changes or
|
||||
doesn't have options
|
||||
"""
|
||||
for rec in self:
|
||||
rec.update({"option_id": False, "value_char": False})
|
||||
|
||||
@api.onchange("value_char")
|
||||
def _onchange_value_char(self):
|
||||
"""
|
||||
Check value before saving
|
||||
"""
|
||||
if not (self.variable_id and self.value_char):
|
||||
return
|
||||
try:
|
||||
self.variable_id._validate_value(self.value_char)
|
||||
except ValidationError as e:
|
||||
return {"warning": {"title": _("Value is invalid"), "message": str(e)}}
|
||||
|
||||
# -- Inverse --
|
||||
|
||||
def _inverse_is_global(self):
|
||||
"""Triggered when `is_global` is updated"""
|
||||
global_values = self.filtered("is_global")
|
||||
if global_values:
|
||||
values_to_set = {}
|
||||
|
||||
# Set m2o fields related to variable using models to 'False'
|
||||
for related_model_info in self._used_in_models().values():
|
||||
m2o_field = related_model_info[0]
|
||||
values_to_set.update({m2o_field: False})
|
||||
global_values.write(values_to_set)
|
||||
|
||||
# Check if we are trying to remove 'global' from value
|
||||
# that doesn't belong to any record.
|
||||
record_related_values = self - global_values
|
||||
for record in record_related_values:
|
||||
if record._check_is_global():
|
||||
# NB: there is a value check in tests for this message.
|
||||
# Update `test_variable_value_toggle_global` if you modify this message.
|
||||
raise ValidationError(
|
||||
_(
|
||||
"Cannot change 'global' status for "
|
||||
"'%(var)s' with value '%(val)s'."
|
||||
"\nTry to assigns it to a record instead.",
|
||||
var=record.variable_id.name,
|
||||
val=record.value_char,
|
||||
)
|
||||
)
|
||||
|
||||
def _inverse_value_char(self):
|
||||
"""Set option_id based on value_char"""
|
||||
for rec in self:
|
||||
if rec.variable_type == "o" and (
|
||||
not rec.option_id or rec.option_id.value_char != rec.value_char
|
||||
):
|
||||
option = rec.variable_id.option_ids.filtered(
|
||||
lambda x, v=rec.value_char: x.value_char == v
|
||||
)
|
||||
rec.option_id = option and option.id
|
||||
|
||||
# -- Create/write/unlink --
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""
|
||||
Workaround for the default value not being set
|
||||
"""
|
||||
variable_obj = self.env["cx.tower.variable"]
|
||||
for vals in vals_list:
|
||||
# Set access level from the variable
|
||||
# if not provided explicitly
|
||||
access_level = vals.get("access_level")
|
||||
if access_level:
|
||||
continue
|
||||
variable_id = vals.get("variable_id")
|
||||
if variable_id:
|
||||
variable = variable_obj.browse(variable_id)
|
||||
vals["access_level"] = variable.access_level
|
||||
return super().create(vals_list)
|
||||
|
||||
# -- Business logic --
|
||||
|
||||
def get_by_variable_reference(
|
||||
self,
|
||||
variable_reference,
|
||||
server_id=None,
|
||||
server_template_id=None,
|
||||
check_global=True,
|
||||
):
|
||||
"""Get record based on its reference.
|
||||
|
||||
Important: references are case sensitive!
|
||||
|
||||
Args:
|
||||
variable_reference (Char): variable reference
|
||||
server_reference (Int): Server ID
|
||||
server_template_reference (Int): Server template ID
|
||||
|
||||
Returns:
|
||||
Dict: Variable values that match provided reference
|
||||
"""
|
||||
|
||||
domain = [("variable_reference", "=", variable_reference)]
|
||||
# Server or server template specific
|
||||
if server_id:
|
||||
domain.append(("server_id", "=", server_id))
|
||||
elif server_template_id:
|
||||
domain.append(("server_template_id", "=", server_template_id))
|
||||
|
||||
if check_global:
|
||||
domain = OR(
|
||||
[
|
||||
domain,
|
||||
[
|
||||
("variable_reference", "=", variable_reference),
|
||||
("is_global", "=", True),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
search_result = self.search(domain)
|
||||
result = {}
|
||||
if search_result:
|
||||
if server_id:
|
||||
value_char = search_result.filtered("server_id").mapped("value_char")
|
||||
result.update(
|
||||
{"server": value_char and value_char[0] if value_char else None}
|
||||
)
|
||||
if server_template_id:
|
||||
value_char = search_result.filtered("server_template_id").mapped(
|
||||
"value_char"
|
||||
)
|
||||
result.update(
|
||||
{
|
||||
"server_template": value_char and value_char[0]
|
||||
if value_char
|
||||
else None
|
||||
}
|
||||
)
|
||||
if check_global:
|
||||
value_char = search_result.filtered("is_global").mapped("value_char")
|
||||
result.update(
|
||||
{"global": value_char and value_char[0] if value_char else None}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _used_in_models(self):
|
||||
"""Returns information about models which use this mixin.
|
||||
|
||||
Returns:
|
||||
dict(): of the following format:
|
||||
{"model.name": ("m2o_field_name", "model_description")}
|
||||
Eg:
|
||||
{"my.custom.model": ("much_model_id", "Much Model")}
|
||||
"""
|
||||
return {
|
||||
"cx.tower.server": ("server_id", "Server"),
|
||||
"cx.tower.plan.line.action": ("plan_line_action_id", "Action"),
|
||||
"cx.tower.server.template": ("server_template_id", "Server Template"),
|
||||
}
|
||||
|
||||
def _check_is_global(self):
|
||||
"""
|
||||
This is a helper function used to define
|
||||
which variables are considered 'Global'
|
||||
Override it to implement your custom logic.
|
||||
|
||||
Returns:
|
||||
bool: True if global else False
|
||||
"""
|
||||
|
||||
self.ensure_one()
|
||||
is_global = True
|
||||
|
||||
# Get m2o field values for all models that use variables.
|
||||
# If none of them is set such value is considered 'global'.
|
||||
for related_model_info in self._used_in_models().values():
|
||||
m2o_field = related_model_info[0]
|
||||
if self[m2o_field]:
|
||||
is_global = False
|
||||
break
|
||||
return is_global
|
||||
|
||||
def _get_extra_vals_fields(self):
|
||||
"""Check cx.tower.reference.mixin for the function documentation"""
|
||||
|
||||
# Use _used_in_models as a source of truth
|
||||
return [fld_val[0] for fld_val in self._used_in_models().values()]
|
||||
|
||||
def _pre_populate_references(self, model_name, field_name, vals_list):
|
||||
"""
|
||||
Generate model-scoped references for variable values.
|
||||
|
||||
Overrides the mixin method to implement a model-dependent reference pattern.
|
||||
|
||||
Pattern:
|
||||
<variable_reference>_<model_generic_reference>_<linked_model_generic_reference>_<linked_record_reference>
|
||||
Global:
|
||||
<variable_reference>_<model_generic_reference>_global
|
||||
"""
|
||||
# Collect parent variable references
|
||||
parent_record_refs = self._prepare_references(model_name, field_name, vals_list)
|
||||
model_reference = self._get_model_generic_reference()
|
||||
|
||||
# Prepare mappings for linked models defined in _used_in_models
|
||||
used_models = self._used_in_models() or {}
|
||||
# Map m2o field -> model name
|
||||
m2o_to_model = {info[0]: model for model, info in used_models.items()}
|
||||
# Precompute linked model generic refs and record refs
|
||||
linked_generic_by_field = {}
|
||||
linked_refs_by_field = {}
|
||||
for model, (m2o_field, _desc) in used_models.items():
|
||||
linked_generic_by_field[m2o_field] = self.env[
|
||||
model
|
||||
]._get_model_generic_reference()
|
||||
linked_refs_by_field[m2o_field] = self._prepare_references(
|
||||
model, m2o_field, vals_list
|
||||
)
|
||||
|
||||
for vals in vals_list:
|
||||
# Respect explicitly provided references with at least one valid symbol
|
||||
existing_reference = vals.get("reference")
|
||||
if existing_reference and bool(
|
||||
re.search(self.REFERENCE_PRELIMINARY_PATTERN, existing_reference)
|
||||
):
|
||||
continue
|
||||
|
||||
variable_id = vals.get(field_name)
|
||||
variable_reference = parent_record_refs.get(variable_id)
|
||||
if not variable_reference:
|
||||
# Fallback to generic variable reference if parent reference missing
|
||||
variable_reference = self.env[model_name]._get_model_generic_reference()
|
||||
|
||||
# Determine which related model the value is linked to
|
||||
linked_m2o_field = next(
|
||||
(f for f in m2o_to_model.keys() if vals.get(f)), None
|
||||
)
|
||||
|
||||
if linked_m2o_field:
|
||||
linked_model_generic = linked_generic_by_field.get(linked_m2o_field)
|
||||
linked_record_id = vals.get(linked_m2o_field)
|
||||
linked_record_reference = linked_refs_by_field.get(
|
||||
linked_m2o_field, {}
|
||||
).get(linked_record_id)
|
||||
vals["reference"] = (
|
||||
f"{variable_reference}_"
|
||||
f"{model_reference}_"
|
||||
f"{linked_model_generic}_"
|
||||
f"{linked_record_reference}"
|
||||
)
|
||||
else:
|
||||
# Global value (not linked to any record)
|
||||
vals["reference"] = f"{variable_reference}_{model_reference}_global"
|
||||
|
||||
return vals_list
|
||||
|
||||
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.variable.value": ["cx.tower.variable", "variable_id"]})
|
||||
return res
|
||||
52
addons/cetmix_tower_server/models/cx_tower_vault.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from odoo import fields, models
|
||||
|
||||
from odoo.addons.rpc_helper.decorator import disable_rpc
|
||||
|
||||
|
||||
@disable_rpc()
|
||||
class CxTowerVault(models.Model):
|
||||
"""Vault for storing secret data.
|
||||
|
||||
This model is used to store secret data for various resources.
|
||||
|
||||
The data is stored in the database and can be accessed using the
|
||||
`_get_secret_values` method.
|
||||
|
||||
Do not use this model directly, use the `VaultMixin` instead.
|
||||
"""
|
||||
|
||||
_name = "cx.tower.vault"
|
||||
_description = "Cetmix Tower Vault"
|
||||
|
||||
res_model = fields.Char(
|
||||
string="Resource Model",
|
||||
required=True,
|
||||
copy=False,
|
||||
help="Model name of the resource that uses this vault",
|
||||
)
|
||||
res_id = fields.Many2oneReference(
|
||||
string="Resource ID",
|
||||
model_field="res_model",
|
||||
help="ID of the resource that uses this vault",
|
||||
required=True,
|
||||
copy=False,
|
||||
)
|
||||
field_name = fields.Char(
|
||||
required=True,
|
||||
help="Name of the field that contains the secret value",
|
||||
copy=False,
|
||||
)
|
||||
data = fields.Text(
|
||||
string="Secret Data",
|
||||
required=True,
|
||||
copy=False,
|
||||
help="The secret data to be stored in the vault",
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"vault_unique_key",
|
||||
"UNIQUE(res_model, res_id, field_name)",
|
||||
"Each secret (model, record, field) must be unique in the vault.",
|
||||
),
|
||||
]
|
||||
408
addons/cetmix_tower_server/models/cx_tower_vault_mixin.py
Normal file
@@ -0,0 +1,408 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class CxTowerVaultMixin(models.AbstractModel):
|
||||
"""Mixin for vault functionality.
|
||||
|
||||
This mixin provides methods to securely store and retrieve sensitive data
|
||||
in the vault. Inheriting models must define SECRET_FIELDS list with field
|
||||
names that should be stored in the vault.
|
||||
"""
|
||||
|
||||
_name = "cx.tower.vault.mixin"
|
||||
_description = "Cetmix Tower Vault Mixin"
|
||||
|
||||
SECRET_VALUE_PLACEHOLDER = "*****"
|
||||
SECRET_FIELDS = []
|
||||
|
||||
def _read(self, fields): # pylint: disable=missing-return # doesn't return anything
|
||||
"""Substitute fields based on api.
|
||||
|
||||
This method replaces values of secret fields with a placeholder value
|
||||
when they are read from the database.
|
||||
|
||||
Args:
|
||||
fields (list): List of fields to read
|
||||
"""
|
||||
super()._read(fields)
|
||||
|
||||
show_all = not fields
|
||||
secret_fields = (
|
||||
self.SECRET_FIELDS
|
||||
if show_all
|
||||
else [f for f in self.SECRET_FIELDS if f in fields]
|
||||
)
|
||||
|
||||
for record in self:
|
||||
for secret_field in secret_fields:
|
||||
try:
|
||||
record._cache[secret_field] = self.SECRET_VALUE_PLACEHOLDER
|
||||
except Exception: # pylint: disable=except-pass
|
||||
# skip SpecialValue
|
||||
# (e.g. for missing record or access right)
|
||||
pass
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Override create to handle secret values securely.
|
||||
|
||||
Extracts secret fields, stores them in vault, and prevents
|
||||
actual secret values from being saved in the main table.
|
||||
|
||||
Args:
|
||||
vals_list (list): List of dictionaries containing field values
|
||||
for record creation
|
||||
|
||||
Returns:
|
||||
recordset: Created records with secret values stored in vault
|
||||
|
||||
Note:
|
||||
Secret fields are automatically processed and stored securely.
|
||||
The main database table never contains actual secret values.
|
||||
"""
|
||||
|
||||
# Step 1: Extract secret fields and generate temporary IDs
|
||||
secret_vals = self._extract_and_replace_secret_fields(vals_list)
|
||||
|
||||
# Step 2: Create records with batch operation
|
||||
records = super().create(vals_list)
|
||||
|
||||
# Step 3: Update vault records with real IDs
|
||||
if secret_vals:
|
||||
self._process_secret_values_after_creation(records, secret_vals)
|
||||
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
"""Override write to handle secret fields.
|
||||
|
||||
Extracts secret field values from vals dictionary and stores them securely
|
||||
in the vault instead of the main database table. The remaining non-secret
|
||||
fields are processed by the standard write method.
|
||||
|
||||
Args:
|
||||
vals (dict): Dictionary of field values to write to records
|
||||
|
||||
Returns:
|
||||
bool: Result of the parent write operation
|
||||
|
||||
Note:
|
||||
Secret fields defined in SECRET_FIELDS are automatically intercepted
|
||||
and stored in vault. Cache is invalidated for all secret fields when
|
||||
any secret field is modified.
|
||||
"""
|
||||
# Extract secret fields
|
||||
secret_values = {}
|
||||
for secret_field in self.SECRET_FIELDS:
|
||||
if secret_field in vals:
|
||||
secret_values[secret_field] = vals.pop(secret_field)
|
||||
|
||||
res = super().write(vals)
|
||||
|
||||
if secret_values:
|
||||
self._set_secret_values(secret_values)
|
||||
# Invalidate cache for all secret fields
|
||||
self.invalidate_recordset(self.SECRET_FIELDS)
|
||||
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
"""Override unlink to delete vault records.
|
||||
|
||||
Automatically removes all associated vault records after deleting
|
||||
the main records to prevent orphaned secret data in the vault.
|
||||
|
||||
Returns:
|
||||
bool: Result of the parent unlink operation
|
||||
|
||||
Note:
|
||||
Vault cleanup is performed automatically and cannot be bypassed.
|
||||
"""
|
||||
ids = self.ids
|
||||
|
||||
res = super().unlink()
|
||||
|
||||
# Find all vault records for these records
|
||||
vault_records = (
|
||||
self.env["cx.tower.vault"]
|
||||
.sudo()
|
||||
.search([("res_model", "=", self._name), ("res_id", "in", ids)])
|
||||
)
|
||||
|
||||
# Delete vault records
|
||||
if vault_records:
|
||||
vault_records.sudo().unlink()
|
||||
|
||||
return res
|
||||
|
||||
def _get_secret_value(self, field_name):
|
||||
"""Retrieves the actual secret value for a specific field for a single record.
|
||||
|
||||
This method is the only way to get the real secret field value because:
|
||||
- Direct field access (e.g., self.secret_field)
|
||||
returns placeholder due to _read() override
|
||||
- The actual field in the main table is empty/NULL
|
||||
as values are stored in vault
|
||||
|
||||
Args:
|
||||
field_name (str): Name of the secret field to retrieve
|
||||
|
||||
Returns:
|
||||
str or None: The actual secret value, or None if not found or field
|
||||
is not in SECRET_FIELDS
|
||||
|
||||
Note:
|
||||
This method bypasses Odoo's ORM field access to avoid getting
|
||||
placeholder values returned by the overridden _read() method.
|
||||
"""
|
||||
|
||||
self.ensure_one()
|
||||
|
||||
return self._get_secret_values([field_name]).get(self.id, {}).get(field_name)
|
||||
|
||||
def _get_secret_values(self, fields_list=None):
|
||||
"""Retrieve secret values from the vault for specified fields.
|
||||
|
||||
This method fetches secret values stored in the vault for all records
|
||||
in the current recordset and specified fields (or all SECRET_FIELDS).
|
||||
|
||||
Args:
|
||||
fields_list (list, optional): List of field names to retrieve.
|
||||
Defaults to all SECRET_FIELDS.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary mapping record IDs to their secret field values.
|
||||
Structure: {res_id: {field_name: secret_value}}
|
||||
|
||||
Example:
|
||||
{1: {'ssh_password': 'secret123', 'host_key': 'key456'},
|
||||
2: {'ssh_password': 'secret789'}}
|
||||
|
||||
Note:
|
||||
This method searches vault records using standard domain filtering
|
||||
by res_id, and field_name for reliable record matching.
|
||||
If a record has no secret values this record is not included in the result.
|
||||
"""
|
||||
# If no records, return empty dict
|
||||
if not self:
|
||||
return {}
|
||||
|
||||
# Prepare fields to fetch
|
||||
fields_to_fetch = (
|
||||
[f for f in fields_list if f in self.SECRET_FIELDS]
|
||||
if fields_list
|
||||
else self.SECRET_FIELDS
|
||||
)
|
||||
# If no fields to fetch, return empty dict
|
||||
if not fields_to_fetch:
|
||||
return {}
|
||||
|
||||
# Search vault records for all records and all secret fields
|
||||
domain = [
|
||||
("res_model", "=", self._name),
|
||||
("res_id", "in", self.ids),
|
||||
("field_name", "in", fields_to_fetch),
|
||||
]
|
||||
vault_records = (
|
||||
self.env["cx.tower.vault"]
|
||||
.sudo()
|
||||
.search_read(
|
||||
domain,
|
||||
["res_id", "field_name", "data"],
|
||||
)
|
||||
)
|
||||
res = defaultdict(dict)
|
||||
for record in vault_records:
|
||||
res[record["res_id"]][record["field_name"]] = record["data"]
|
||||
|
||||
return dict(res)
|
||||
|
||||
def _set_secret_values(self, vals):
|
||||
"""Store secret values in the vault.
|
||||
|
||||
This method stores sensitive data in the vault for all records in the recordset.
|
||||
It either updates existing vault records or creates new ones for each
|
||||
record-field pair in the vals dictionary.
|
||||
|
||||
This method can be overridden to implement custom storage mechanisms
|
||||
for secret values, such as external key management systems or
|
||||
encryption services.
|
||||
|
||||
Args:
|
||||
vals (dict): Dictionary mapping field names to their secret values
|
||||
to be stored in the vault for all records
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if not vals or not self:
|
||||
return
|
||||
|
||||
# Get all existing vault records in ONE SQL query
|
||||
domain = [
|
||||
("res_model", "=", self._name),
|
||||
("res_id", "in", self.ids),
|
||||
("field_name", "in", list(vals.keys())),
|
||||
]
|
||||
existing_vault_records = self.env["cx.tower.vault"].sudo().search(domain)
|
||||
|
||||
# Prepare data for batch operations
|
||||
vals_to_update_records = defaultdict(lambda: self.env["cx.tower.vault"])
|
||||
records_to_unlink = self.env["cx.tower.vault"]
|
||||
records_to_create = []
|
||||
|
||||
# Index existing records by (res_id, field_name) for O(1) lookups
|
||||
existing_map = {(v.res_id, v.field_name): v for v in existing_vault_records}
|
||||
|
||||
# Only allow known secret fields to be set
|
||||
allowed_fields = set(self.SECRET_FIELDS)
|
||||
|
||||
# Process each record and field combination
|
||||
for record in self:
|
||||
for field, value in vals.items():
|
||||
if field not in allowed_fields:
|
||||
continue
|
||||
# Fast lookup for existing record
|
||||
existing_record = existing_map.get((record.id, field))
|
||||
if existing_record:
|
||||
if value is False or value is None:
|
||||
records_to_unlink |= existing_record
|
||||
else:
|
||||
vals_to_update_records[value] |= existing_record
|
||||
|
||||
else:
|
||||
if value is False or value is None:
|
||||
continue
|
||||
|
||||
records_to_create.append(
|
||||
{
|
||||
"res_model": self._name,
|
||||
"res_id": record.id,
|
||||
"field_name": field,
|
||||
"data": value,
|
||||
}
|
||||
)
|
||||
|
||||
# Batch operations
|
||||
for value, records in vals_to_update_records.items():
|
||||
records.sudo().write({"data": value})
|
||||
|
||||
if records_to_create:
|
||||
self.env["cx.tower.vault"].sudo().create(records_to_create)
|
||||
if records_to_unlink:
|
||||
records_to_unlink.sudo().unlink()
|
||||
|
||||
def _extract_and_replace_secret_fields(self, vals_list):
|
||||
"""Extract secret fields and replace with temporary identifiers.
|
||||
|
||||
Processes value dictionaries for record creation, replacing secret field values
|
||||
with unique temporary identifiers. The actual secret values are mapped to these
|
||||
temporary identifiers for later secure storage in the vault system.
|
||||
|
||||
Args:
|
||||
vals_list (list): List of value dictionaries for record creation.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of temporary identifiers to secret values.
|
||||
Note: vals_list is modified in-place to contain temp identifiers.
|
||||
|
||||
Note:
|
||||
Used during record creation as part of the secure secret storage workflow.
|
||||
"""
|
||||
temp_id_counter = 0
|
||||
secret_vals = {}
|
||||
|
||||
for vals in vals_list:
|
||||
for secret_field in self.SECRET_FIELDS:
|
||||
if (
|
||||
secret_field in vals
|
||||
and vals[secret_field] is not False
|
||||
and vals[secret_field] is not None
|
||||
):
|
||||
temp_id_counter += 1
|
||||
temp_identifier = str(temp_id_counter)
|
||||
secret_vals[temp_identifier] = vals[secret_field]
|
||||
vals[secret_field] = temp_identifier
|
||||
|
||||
return secret_vals
|
||||
|
||||
def _process_secret_values_after_creation(self, records, secret_vals):
|
||||
"""Process secret values after records creation.
|
||||
|
||||
Replaces temporary identifiers with actual secret values in the vault
|
||||
and invalidates cache for affected fields.
|
||||
|
||||
Args:
|
||||
records (recordset): Newly created records with temporary identifiers
|
||||
secret_vals (dict): Mapping of temporary identifiers to secret values
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Note:
|
||||
Called automatically during create() process. Should not be used directly.
|
||||
"""
|
||||
fields_str = ", ".join(self.SECRET_FIELDS)
|
||||
query = f"SELECT id, {fields_str} FROM {self._table} WHERE id in %s"
|
||||
self.env.cr.execute(query, (tuple(records.ids),))
|
||||
records_dict = self.env.cr.dictfetchall()
|
||||
|
||||
for record_dict in records_dict:
|
||||
self._process_single_record_secrets(record_dict, secret_vals)
|
||||
|
||||
records._clear_temp_values()
|
||||
records.invalidate_recordset(self.SECRET_FIELDS)
|
||||
|
||||
def _process_single_record_secrets(self, record_dict, secret_vals):
|
||||
"""Process secrets for a single record.
|
||||
|
||||
Replaces temporary identifiers with actual secret values for one record,
|
||||
clears temporary values from main table and stores secrets in vault.
|
||||
|
||||
Args:
|
||||
record_dict (dict): Dictionary with record data
|
||||
including temporary identifiers
|
||||
secret_vals (dict): Mapping of temporary identifiers to actual secret values
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Note:
|
||||
Internal method used by _process_secret_values_after_creation.
|
||||
"""
|
||||
record_id = record_dict.get("id")
|
||||
vault_vals = {}
|
||||
field_temp_id_pairs = (
|
||||
(field_name, record_dict[field_name]) for field_name in self.SECRET_FIELDS
|
||||
)
|
||||
|
||||
# Collect secret values and fields to clear
|
||||
for field_name, temp_identifier in field_temp_id_pairs:
|
||||
secret_value = secret_vals.get(temp_identifier)
|
||||
if secret_value:
|
||||
vault_vals[field_name] = secret_value
|
||||
|
||||
# Update database and vault if needed
|
||||
if vault_vals:
|
||||
record = self.browse(record_id)
|
||||
record._set_secret_values(vault_vals)
|
||||
|
||||
def _clear_temp_values(self):
|
||||
"""Clear temporary values from main table.
|
||||
|
||||
Sets all SECRET_FIELDS to NULL in the database to remove temporary
|
||||
identifiers after secret values have been stored in vault.
|
||||
Works with multiple records in the recordset.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Note:
|
||||
Internal method used during secret processing workflow.
|
||||
Clears all SECRET_FIELDS for all records in the current recordset.
|
||||
"""
|
||||
set_clause = ", ".join(f"{field} = NULL" for field in self.SECRET_FIELDS)
|
||||
query = f"UPDATE {self._table} SET {set_clause} WHERE id in %s"
|
||||
self.env.cr.execute(query, (tuple(self.ids),))
|
||||
24
addons/cetmix_tower_server/models/ir_actions_server.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from odoo import _, models
|
||||
from odoo.exceptions import AccessError
|
||||
|
||||
|
||||
class IrActionsServer(models.Model):
|
||||
_inherit = "ir.actions.server"
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
We override this method to return more
|
||||
user friendly error messages.
|
||||
"""
|
||||
if self.sudo().model_name == "cx.tower.server":
|
||||
try:
|
||||
res = super().run()
|
||||
return res
|
||||
except AccessError as e:
|
||||
raise AccessError(
|
||||
_(
|
||||
"You need to have 'write' access to all servers "
|
||||
"you want to run this action on."
|
||||
)
|
||||
) from e
|
||||
return super().run()
|
||||
79
addons/cetmix_tower_server/models/res_config_settings.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
"""
|
||||
Inherit res.config.settings to add new settings
|
||||
"""
|
||||
|
||||
_inherit = "res.config.settings"
|
||||
|
||||
cetmix_tower_command_timeout = fields.Integer(
|
||||
string="Command Timeout",
|
||||
config_parameter="cetmix_tower_server.command_timeout",
|
||||
help="Timeout for commands in seconds after which"
|
||||
" the command will be terminated",
|
||||
)
|
||||
cetmix_tower_notification_type_error = fields.Selection(
|
||||
string="Error Notifications",
|
||||
selection=lambda self: self._selection_notifications_type(),
|
||||
config_parameter="cetmix_tower_server.notification_type_error",
|
||||
help="Type of error notifications",
|
||||
)
|
||||
cetmix_tower_notification_type_success = fields.Selection(
|
||||
string="Success Notifications",
|
||||
selection=lambda self: self._selection_notifications_type(),
|
||||
config_parameter="cetmix_tower_server.notification_type_success",
|
||||
help="Type of success notifications",
|
||||
)
|
||||
|
||||
def _selection_notifications_type(self):
|
||||
"""
|
||||
Selection of notifications type
|
||||
"""
|
||||
return [
|
||||
("sticky", _("Sticky")),
|
||||
("non_sticky", _("Non-sticky")),
|
||||
]
|
||||
|
||||
def action_configure_cron_pull_files_from_server(self):
|
||||
"""
|
||||
Configure cron job to pull files from server
|
||||
"""
|
||||
return self._get_cron_job_action(
|
||||
"cetmix_tower_server.ir_cron_auto_pull_files_from_server"
|
||||
)
|
||||
|
||||
def action_configure_zombie_commands_cron(self):
|
||||
"""
|
||||
Configure cron job to check zombie commands
|
||||
"""
|
||||
return self._get_cron_job_action(
|
||||
"cetmix_tower_server.ir_cron_check_zombie_commands"
|
||||
)
|
||||
|
||||
def action_configure_run_scheduled_tasks_cron(self):
|
||||
"""
|
||||
Configure cron job to run scheduled tasks
|
||||
"""
|
||||
return self._get_cron_job_action(
|
||||
"cetmix_tower_server.ir_cron_run_scheduled_tasks"
|
||||
)
|
||||
|
||||
def _get_cron_job_action(self, cron_xml_id):
|
||||
"""
|
||||
Get action to configure cron job
|
||||
"""
|
||||
self.ensure_one()
|
||||
cron_id = self.env.ref(cron_xml_id).id
|
||||
if not cron_id:
|
||||
raise ValidationError(_("Cron job not found"))
|
||||
return {
|
||||
"name": _("Cron Job"),
|
||||
"views": [(False, "form")],
|
||||
"res_model": "ir.cron",
|
||||
"res_id": cron_id,
|
||||
"type": "ir.actions.act_window",
|
||||
"target": "new",
|
||||
}
|
||||
47
addons/cetmix_tower_server/models/res_partner.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# Copyright (C) 2022 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = "res.partner"
|
||||
|
||||
server_ids = fields.One2many(
|
||||
"cx.tower.server",
|
||||
"partner_id",
|
||||
string="Servers",
|
||||
groups="cetmix_tower_server.group_user",
|
||||
)
|
||||
|
||||
server_count = fields.Integer(
|
||||
compute="_compute_server_count",
|
||||
recursive=True,
|
||||
)
|
||||
|
||||
secret_ids = fields.One2many(
|
||||
"cx.tower.key.value",
|
||||
"partner_id",
|
||||
string="Secrets",
|
||||
domain=[("key_id.key_type", "=", "s")],
|
||||
groups="cetmix_tower_server.group_manager",
|
||||
)
|
||||
|
||||
@api.depends("server_ids", "child_ids.server_count")
|
||||
def _compute_server_count(self):
|
||||
for partner in self:
|
||||
own_server_count = len(partner.server_ids)
|
||||
child_server_count = sum(partner.child_ids.mapped("server_count"))
|
||||
partner.server_count = own_server_count + child_server_count
|
||||
|
||||
def action_view_partner_servers(self):
|
||||
"""Open server list filtered by partner and all its descendants."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
"name": "Servers",
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "cx.tower.server",
|
||||
"view_mode": "kanban,tree,form",
|
||||
"domain": [("partner_id", "child_of", self.id)],
|
||||
"context": {"default_partner_id": self.id},
|
||||
}
|
||||
35
addons/cetmix_tower_server/models/tools.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Copyright (C) 2022 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from random import choices
|
||||
|
||||
CHARS = "23456789acefhjkmnprtvwxyz"
|
||||
|
||||
|
||||
def generate_random_id(sections=1, population=4, separator="-"):
|
||||
"""Generates random id
|
||||
eg 'ahj2-jer83'
|
||||
|
||||
Args:
|
||||
sections (int, optional): number of sections. Defaults to 1.
|
||||
population (int, optional): number of symbols per section. Defaults to 4.
|
||||
separator (str, optional): section separator. Defaults to "-".
|
||||
|
||||
Returns:
|
||||
Str: generated id
|
||||
"""
|
||||
if sections < 1 or population < 0:
|
||||
return None
|
||||
|
||||
def get_section():
|
||||
return "".join(choices(CHARS, k=population))
|
||||
|
||||
# Single section
|
||||
if sections == 1:
|
||||
return get_section()
|
||||
|
||||
# Multiple sections
|
||||
result = []
|
||||
for _ in range(sections):
|
||||
result.append(get_section())
|
||||
|
||||
return separator.join(result)
|
||||
3
addons/cetmix_tower_server/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["whool"]
|
||||
build-backend = "whool.buildapi"
|
||||
1
addons/cetmix_tower_server/readme/CONFIGURE.md
Normal file
@@ -0,0 +1 @@
|
||||
Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions.
|
||||
4
addons/cetmix_tower_server/readme/DESCRIPTION.md
Normal file
@@ -0,0 +1,4 @@
|
||||
[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.
|
||||
215
addons/cetmix_tower_server/readme/HISTORY.md
Normal file
@@ -0,0 +1,215 @@
|
||||
## 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
|
||||
1
addons/cetmix_tower_server/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,44 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="ir_module_category_tower" model="ir.module.category">
|
||||
<field name="name">Cetmix Tower</field>
|
||||
<field name="sequence">199</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_module_category_tower_server" model="ir.module.category">
|
||||
<field name="parent_id" ref="ir_module_category_tower" />
|
||||
<field name="name">Access Level</field>
|
||||
</record>
|
||||
|
||||
<record id="group_user" model="res.groups">
|
||||
<field name="name">User</field>
|
||||
<field name="category_id" ref="ir_module_category_tower_server" />
|
||||
<field name="comment">
|
||||
Basic actions for selected servers.
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="group_manager" model="res.groups">
|
||||
<field name="name">Manager</field>
|
||||
<field name="category_id" ref="ir_module_category_tower_server" />
|
||||
<field name="implied_ids" eval="[(4, ref('group_user'))]" />
|
||||
<field name="comment">
|
||||
Create and modify selected servers.
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="group_root" model="res.groups">
|
||||
<field name="name">Root</field>
|
||||
<field name="category_id" ref="ir_module_category_tower_server" />
|
||||
<field name="implied_ids" eval="[(4, ref('group_manager'))]" />
|
||||
<field name="comment">
|
||||
Full control over all servers.
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="rule_cx_tower_command_log_group_user_read" model="ir.rule">
|
||||
<field name="name">Tower command log: user access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_command_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "=", "1"),
|
||||
("create_uid", "=", user.id),
|
||||
("server_id.user_ids", "in", [user.id])
|
||||
]</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_cx_tower_command_log_group_manager_read" model="ir.rule">
|
||||
<field name="name">Tower command log: manager access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_command_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
"&",
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
("server_id.user_ids", "in", [user.id]),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="rule_cx_tower_command_log_group_root_full" model="ir.rule">
|
||||
<field name="name">Tower command log: root access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_command_log" />
|
||||
<field name="domain_force">[(1, "=", 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User: read allowed if access_level == 1 and either command.user_ids or a related server grants access via user_ids -->
|
||||
<record id="rule_cx_tower_command_group_user_read" model="ir.rule">
|
||||
<field name="name">Command: User read</field>
|
||||
<field name="model_id" ref="model_cx_tower_command" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "=", "1"),
|
||||
"|",
|
||||
("user_ids", "in", [user.id]),
|
||||
("server_ids.user_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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: read allowed if access_level <= 2 and command or server grants access -->
|
||||
<record id="rule_cx_tower_command_group_manager_read" model="ir.rule">
|
||||
<field name="name">Command: Manager read</field>
|
||||
<field name="model_id" ref="model_cx_tower_command" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
"|", ("user_ids", "in", [user.id]), ("manager_ids", "in", [user.id]),
|
||||
"|",
|
||||
("server_ids", "=", False),
|
||||
"|",
|
||||
("server_ids.user_ids", "in", [user.id]),
|
||||
("server_ids.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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 allowed if access_level <= 2 and in command.manager_ids -->
|
||||
<record id="rule_cx_tower_command_group_manager_write" model="ir.rule">
|
||||
<field name="name">Command: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_command" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("access_level", "<=", "2"), ("manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<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: unlink allowed if access_level <= 2, creator, and in manager_ids -->
|
||||
<record id="rule_cx_tower_command_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">Command: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_command" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("access_level", "<=", "2"),
|
||||
("create_uid", "=", user.id),
|
||||
("manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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: unrestricted access -->
|
||||
<record id="rule_cx_tower_command_group_root_full" model="ir.rule">
|
||||
<field name="name">Command: Root unrestricted access</field>
|
||||
<field name="model_id" ref="model_cx_tower_command" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User: Read access rule: Allow reading file records when the current user is in the related Server's user_ids -->
|
||||
<record id="rule_cx_tower_file_group_user_read" model="ir.rule">
|
||||
<field name="name">File: User read via related server (user_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_file" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">[('server_id.user_ids', 'in', [user.id])]</field>
|
||||
<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 and Create access rule: Allow update and creation when current user is in related Server's manager_ids -->
|
||||
<record id="rule_cx_tower_file_group_manager_read_write" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>File: Manager write & create via related server (manager_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_file" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[('server_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
<field name="perm_write" eval="1" />
|
||||
<field name="perm_create" eval="1" />
|
||||
<field name="perm_unlink" eval="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager: Unlink access rule: Allow deletion only when the current user is in the related Server's manager_ids and is the record creator -->
|
||||
<record id="rule_cx_tower_file_group_manager_unlink" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>File: Manager unlink via related server (manager_ids) and record creator</field>
|
||||
<field name="model_id" ref="model_cx_tower_file" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[ ('server_id.manager_ids', 'in', [user.id]), ('create_uid', '=', user.id) ]
|
||||
</field>
|
||||
<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: Unrestricted access rule: Allow access to all files -->
|
||||
<record id="rule_cx_tower_file_group_root_full" model="ir.rule">
|
||||
<field name="name">File: Root Unrestricted Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_file" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
|
||||
<!-- Manager: Read access (if the current user is in user_ids or manager_ids) -->
|
||||
<record id="rule_cx_tower_file_template_group_manager_read" model="ir.rule">
|
||||
<field name="name">File: Manager read (user_ids or manager_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_file_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["|", ("user_ids", "in", [user.id]), ("manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<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 and Create access (if the current user is in manager_ids) -->
|
||||
<record id="rule_cx_tower_file_template_group_manager_write" model="ir.rule">
|
||||
<field name="name">File: Manager write & create (manager_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_file_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[('manager_ids', 'in', [user.id])]</field>
|
||||
<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: Unlink access (if the current user is in manager_ids and is the record creator) -->
|
||||
<record id="rule_cx_tower_file_template_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">File: Manager unlink (manager_ids & creator)</field>
|
||||
<field name="model_id" ref="model_cx_tower_file_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[("manager_ids", "in", [user.id]), ("create_uid", "=", user.id)]
|
||||
</field>
|
||||
<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: unlimited access -->
|
||||
<record id="rule_cx_tower_file_template_group_root_full" model="ir.rule">
|
||||
<field name="name">File: Root unrestricted access</field>
|
||||
<field name="model_id" ref="model_cx_tower_file_template" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- Manager Read Rules -->
|
||||
<record id="rule_key_manager_read_users" model="ir.rule">
|
||||
<field name="name">Key: Manager Read Access - Users/Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<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>
|
||||
|
||||
<record id="rule_key_manager_read_secret" model="ir.rule">
|
||||
<field name="name">Key: Manager Read Access - Secret Type</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field name="domain_force">[('key_type', '=', 's')]</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>
|
||||
|
||||
<record id="rule_key_manager_read_ssh" model="ir.rule">
|
||||
<field name="name">Key: Manager Read Access - SSH Key</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field name="domain_force">[('key_type', '=', 'k'), '|',
|
||||
('server_ssh_ids.user_ids', 'in', [user.id]),
|
||||
('server_ssh_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 Rules -->
|
||||
<record id="rule_key_manager_write_managers" model="ir.rule">
|
||||
<field name="name">Key: Manager Write/Create Access - Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<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>
|
||||
|
||||
<record id="rule_key_manager_write_ssh" model="ir.rule">
|
||||
<field name="name">Key: Manager Write/Create Access - SSH Key</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field name="domain_force">['&', ('key_type', '=', 'k'),
|
||||
('server_ssh_ids.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 Rules -->
|
||||
<record id="rule_key_manager_unlink_managers" model="ir.rule">
|
||||
<field name="name">Key: Manager Delete Access - Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<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>
|
||||
|
||||
<record id="rule_key_manager_unlink_ssh" model="ir.rule">
|
||||
<field name="name">Key: Manager Delete Access - SSH Key</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field name="domain_force">[('key_type', '=', 'k'),
|
||||
('server_ssh_ids.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_key_root" model="ir.rule">
|
||||
<field name="name">Key: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_key" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- Manager Read Rules -->
|
||||
<record id="rule_key_value_manager_read_key" model="ir.rule">
|
||||
<field name="name">Key Value: Manager Read Access - Key Users/Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>['|', ('key_id.user_ids', 'in', [user.id]), ('key_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>
|
||||
|
||||
<record id="rule_key_value_manager_read_server" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Key Value: Manager Read Access - Server Users/Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field name="domain_force">[('key_id.key_type', '=', 's'),
|
||||
'|', '|', ('server_id', '=', False),
|
||||
('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 Rules -->
|
||||
<record id="rule_key_value_manager_write_key" model="ir.rule">
|
||||
<field name="name">Key Value: Manager Write/Create Access - Key Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field name="domain_force">[('key_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>
|
||||
|
||||
<record id="rule_key_value_manager_write_server" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Key Value: Manager Write/Create Access - Server Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field name="domain_force">[('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="0" />
|
||||
</record>
|
||||
|
||||
<!-- Manager Delete Rules -->
|
||||
<record id="rule_key_value_manager_unlink_key" model="ir.rule">
|
||||
<field name="name">Key Value: Manager Delete Access - Key Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('key_id.key_type', '=', 's'),('key_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>
|
||||
|
||||
<record id="rule_key_value_manager_unlink_server" model="ir.rule">
|
||||
<field name="name">Key Value: Manager Delete Access - Server Managers</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('key_id.key_type', '=', 's'),('server_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_key_value_root" model="ir.rule">
|
||||
<field name="name">Key Value: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_key_value" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User: read rule for cx.tower.plan_line.action -->
|
||||
<record id="rule_cx_tower_plan_line_action_group_user_read" model="ir.rule">
|
||||
<field name="name">Plan Line Action: User read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "=", "1"),
|
||||
"|",
|
||||
("plan_id.user_ids", "in", [user.id]),
|
||||
("plan_id.server_ids.user_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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: read rule for cx.tower.plan.line.action -->
|
||||
<record id="rule_cx_tower_plan_line_action_group_manager_read" model="ir.rule">
|
||||
<field name="name">Plan Line Action: Manager read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- The domain requires:
|
||||
1. access_level <= "2"
|
||||
2. AND either the plan itself grants access via (user_ids OR manager_ids)
|
||||
OR there are no related servers
|
||||
OR a related server grants access via (server_ids.user_ids OR server_ids.manager_ids)
|
||||
-->
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
"|", ("plan_id.user_ids", "in", [user.id]), ("plan_id.manager_ids", "in", [user.id]),
|
||||
"|",
|
||||
("plan_id.server_ids", "=", False),
|
||||
"|",
|
||||
("plan_id.server_ids.user_ids", "in", [user.id]),
|
||||
("plan_id.server_ids.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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 for cx.tower.plan_line_action -->
|
||||
<record id="rule_cx_tower_plan_line_action_group_manager_write" model="ir.rule">
|
||||
<field name="name">Plan Line Action: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&", ("access_level", "<=", "2"), ("plan_id.manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<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: unlink rule for cx.tower.plan.line.action -->
|
||||
<record id="rule_cx_tower_plan_line_action_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">Plan Line Action: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("access_level", "<=", "2"),
|
||||
("create_uid", "=", user.id),
|
||||
("plan_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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: unrestricted access rule for cx.tower.plan.line.action -->
|
||||
<record id="rule_cx_tower_plan_line_action_group_root_full" model="ir.rule">
|
||||
<field name="name">Plan Line Action: Root unrestricted access</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line_action" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User: read rule for cx.tower.plan.line -->
|
||||
<record id="rule_cx_tower_plan_line_group_user_read" model="ir.rule">
|
||||
<field name="name">Plan Line: User read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "=", "1"),
|
||||
"|",
|
||||
("plan_id.user_ids", "in", [user.id]),
|
||||
("plan_id.server_ids.user_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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: read rule for cx.tower.plan.line -->
|
||||
<record id="rule_cx_tower_plan_line_group_manager_read" model="ir.rule">
|
||||
<field name="name">Plan Line: Manager read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- The domain requires:
|
||||
1. access_level <= "2"
|
||||
2. AND either the plan itself grants access via (user_ids OR manager_ids)
|
||||
OR there are no related servers
|
||||
OR a related server grants access via (server_ids.user_ids OR server_ids.manager_ids)
|
||||
-->
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
"|", ("plan_id.user_ids", "in", [user.id]), ("plan_id.manager_ids", "in", [user.id]),
|
||||
"|",
|
||||
("plan_id.server_ids", "=", False),
|
||||
"|",
|
||||
("plan_id.server_ids.user_ids", "in", [user.id]),
|
||||
("plan_id.server_ids.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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 for cx.tower.plan.line -->
|
||||
<record id="rule_cx_tower_plan_line_group_manager_write" model="ir.rule">
|
||||
<field name="name">Plan Line: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&", ("access_level", "<=", "2"), ("plan_id.manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<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: unlink rule for cx.tower.plan.line -->
|
||||
<record id="rule_cx_tower_plan_line_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">Plan Line: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("access_level", "<=", "2"),
|
||||
("create_uid", "=", user.id),
|
||||
("plan_id.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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: unrestricted access rule for cx.tower.plan.line -->
|
||||
<record id="rule_cx_tower_plan_line_group_root_full" model="ir.rule">
|
||||
<field name="name">Plan Line: Root unrestricted access</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_line" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
|
||||
<record id="rule_cx_tower_plan_log_group_user_read" model="ir.rule">
|
||||
<field name="name">Tower plan log: user access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "=", "1"),
|
||||
("create_uid", "=", user.id),
|
||||
("server_id.user_ids", "in", [user.id])
|
||||
]</field>
|
||||
<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>
|
||||
|
||||
<record id="rule_cx_tower_plan_log_group_manager_read" model="ir.rule">
|
||||
<field name="name">Tower plan log: manager access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
"&",
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
("server_id.user_ids", "in", [user.id]),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<field name="perm_read" eval="1" />
|
||||
</record>
|
||||
|
||||
|
||||
<record id="rule_cx_tower_plan_log_group_root_full" model="ir.rule">
|
||||
<field name="name">Tower plan log: root access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_log" />
|
||||
<field name="domain_force">[(1, "=", 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User: read rule for cx.tower.plan -->
|
||||
<record id="rule_cx_tower_plan_group_user_read" model="ir.rule">
|
||||
<field name="name">Plan: User read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "=", "1"),
|
||||
"|",
|
||||
("user_ids", "in", [user.id]),
|
||||
("server_ids.user_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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: read rule for cx.tower.plan -->
|
||||
<record id="rule_cx_tower_plan_group_manager_read" model="ir.rule">
|
||||
<field name="name">Plan: Manager read</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- The domain requires:
|
||||
1. access_level <= "2"
|
||||
2. AND either the plan itself grants access via (user_ids OR manager_ids)
|
||||
OR there are no related servers
|
||||
OR a related server grants access via (server_ids.user_ids OR server_ids.manager_ids)
|
||||
-->
|
||||
<field name="domain_force">
|
||||
["&",
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
"|", ("user_ids", "in", [user.id]), ("manager_ids", "in", [user.id]),
|
||||
"|",
|
||||
("server_ids", "=", False),
|
||||
"|",
|
||||
("server_ids.user_ids", "in", [user.id]),
|
||||
("server_ids.manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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 for cx.tower.plan -->
|
||||
<record id="rule_cx_tower_plan_group_manager_write" model="ir.rule">
|
||||
<field name="name">Plan: Manager write & create</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
["&", ("access_level", "<=", "2"), ("manager_ids", "in", [user.id])]
|
||||
</field>
|
||||
<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: unlink rule for cx.tower.plan -->
|
||||
<record id="rule_cx_tower_plan_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">Plan: Manager unlink</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
("access_level", "<=", "2"),
|
||||
("create_uid", "=", user.id),
|
||||
("manager_ids", "in", [user.id])
|
||||
]
|
||||
</field>
|
||||
<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: unrestricted access rule for cx.tower.plan -->
|
||||
<record id="rule_cx_tower_plan_group_root_full" model="ir.rule">
|
||||
<field name="name">Plan: Root unrestricted access</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- Manager: Read (see his records by user_ids or via server's user_ids/manager_ids) -->
|
||||
<record id="cx_tower_scheduled_task_cv_rule_manager_read" model="ir.rule">
|
||||
<field name="name">Scheduled Task CV: manager read access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task_cv" />
|
||||
<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" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
'|',
|
||||
('scheduled_task_id.user_ids', 'in', [user.id]),
|
||||
'|',
|
||||
('scheduled_task_id.server_ids.user_ids', 'in', [user.id]),
|
||||
('scheduled_task_id.server_ids.manager_ids', 'in', [user.id])
|
||||
]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager: Create/Write -->
|
||||
<record id="cx_tower_scheduled_task_cv_rule_manager_write" model="ir.rule">
|
||||
<field name="name">Scheduled Task CV: manager write access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task_cv" />
|
||||
<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" />
|
||||
<field name="domain_force">
|
||||
[('scheduled_task_id.manager_ids', 'in', [user.id])]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager: Unlink (only if in manager_ids AND creator) -->
|
||||
<record id="cx_tower_scheduled_task_cv_rule_manager_unlink" model="ir.rule">
|
||||
<field name="name">Scheduled Task CV: manager unlink access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task_cv" />
|
||||
<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" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
('scheduled_task_id.manager_ids', 'in', [user.id]),
|
||||
('create_uid', '=', user.id)
|
||||
]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Root: Full access -->
|
||||
<record id="cx_tower_scheduled_task_cv_rule_root_full" model="ir.rule">
|
||||
<field name="name">Scheduled Task CV: root full access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task_cv" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- Manager: Read (see his records by user_ids or via server's user_ids/manager_ids) -->
|
||||
<record id="cx_tower_scheduled_task_rule_manager_read" model="ir.rule">
|
||||
<field name="name">Scheduled Task: manager read access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task" />
|
||||
<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" />
|
||||
<field name="domain_force">
|
||||
['|',
|
||||
'|',
|
||||
('user_ids', 'in', [user.id]),
|
||||
('manager_ids', 'in', [user.id]),
|
||||
'|',
|
||||
'|',
|
||||
'|',
|
||||
('server_ids.user_ids', 'in', [user.id]),
|
||||
('server_ids.manager_ids', 'in', [user.id]),
|
||||
('server_template_ids.user_ids', 'in', [user.id]),
|
||||
('server_template_ids.manager_ids', 'in', [user.id])
|
||||
]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager: Create/Write -->
|
||||
<record id="cx_tower_scheduled_task_rule_manager_write" model="ir.rule">
|
||||
<field name="name">Scheduled Task: manager write access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task" />
|
||||
<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" />
|
||||
<field name="domain_force">
|
||||
[('manager_ids', 'in', [user.id])]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager: Unlink (only if in manager_ids AND creator) -->
|
||||
<record id="cx_tower_scheduled_task_rule_manager_unlink" model="ir.rule">
|
||||
<field name="name">Scheduled Task: manager unlink access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task" />
|
||||
<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" />
|
||||
<field name="domain_force">
|
||||
[
|
||||
('manager_ids', 'in', [user.id]),
|
||||
('create_uid', '=', user.id)
|
||||
]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Root: Full access -->
|
||||
<record id="cx_tower_scheduled_task_rule_root_full" model="ir.rule">
|
||||
<field name="name">Scheduled Task: root full access</field>
|
||||
<field name="model_id" ref="model_cx_tower_scheduled_task" />
|
||||
<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" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- User access rule -->
|
||||
<record id="rule_cx_tower_server_log_group_user_read" model="ir.rule">
|
||||
<field name="name">Tower server log: user access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "=", "1"),
|
||||
("server_id.user_ids", "in", [user.id])
|
||||
]</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager access rules -->
|
||||
<record id="rule_cx_tower_server_log_group_manager_read" model="ir.rule">
|
||||
<field name="name">Tower server log: manager read access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
"|",
|
||||
("server_id.user_ids", "in", [user.id]),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<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>
|
||||
|
||||
<record id="rule_cx_tower_server_log_group_manager_write" model="ir.rule">
|
||||
<field name="name">Tower server log: manager write access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<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>
|
||||
|
||||
<record id="rule_cx_tower_server_log_group_manager_unlink" model="ir.rule">
|
||||
<field name="name">Tower server log: manager unlink access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
("access_level", "<=", "2"),
|
||||
("create_uid", "=", user.id),
|
||||
("server_id.manager_ids", "in", [user.id])
|
||||
]</field>
|
||||
<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_cx_tower_server_log_group_root_full" model="ir.rule">
|
||||
<field name="name">Tower server log: root access rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_log" />
|
||||
<field name="domain_force">[(1, "=", 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_root'))]" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- User Access -->
|
||||
<record id="rule_cx_tower_server_group_user_read" model="ir.rule">
|
||||
<field name="name">Tower Server: user visibility rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="groups" eval="[(4, ref('group_user'))]" />
|
||||
<!-- allow read if the user is in Users -->
|
||||
<field name="domain_force">[('user_ids', 'in', [user.id])]</field>
|
||||
<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 Access -->
|
||||
<!-- Rule 1: Read access if the current user is a follower or is in manager_ids -->
|
||||
<record id="rule_cx_tower_server_group_manager_read" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Tower Server: Manager Read (if follower or in manager_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- allow read if the user is in Users or Managers -->
|
||||
<field name="domain_force">
|
||||
['|', ('user_ids', 'in', [user.id]),
|
||||
('manager_ids', 'in', [user.id])]
|
||||
</field>
|
||||
<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>
|
||||
|
||||
<!-- Rule 2: Write and Create access if the current user is in manager_ids -->
|
||||
<record id="rule_cx_tower_server_group_manager_write" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Tower Server: Manager Write & Create (if in manager_ids)</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- allow write/create only if the user is in the manager_ids many2many field -->
|
||||
<field name="domain_force">[('manager_ids', 'in', [user.id])]</field>
|
||||
<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>
|
||||
|
||||
<!-- Rule 3: Delete access if the current user is in manager_ids and is the creator -->
|
||||
<record id="rule_cx_tower_server_group_manager_unlink" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Tower Server: Manager Delete (if in manager_ids and creator)</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<!-- allow deletion only if the user is in manager_ids and he is the record creator -->
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)]</field>
|
||||
<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 -->
|
||||
<record id="rule_cx_tower_server_group_root_full" model="ir.rule">
|
||||
<field name="name">Tower Server: root visibility rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_server" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4,ref('group_root'))]" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- Manager Read Rule -->
|
||||
<record id="rule_cx_tower_server_template_manager_read" model="ir.rule">
|
||||
<field name="name">Server Template: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_template" />
|
||||
<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>
|
||||
|
||||
<!-- Manager Write Rule -->
|
||||
<record id="rule_cx_tower_server_template_manager_write" model="ir.rule">
|
||||
<field name="name">Server Template: Manager Write Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_template" />
|
||||
<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_cx_tower_server_template_manager_unlink" model="ir.rule">
|
||||
<field name="name">Server Template: Manager Delete Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_template" />
|
||||
<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="cx_tower_server_template_rule_group_root_access" model="ir.rule">
|
||||
<field name="name">Server Template: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_template" />
|
||||
<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,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- cx.tower.command.run.wizard -->
|
||||
<record id="rule_cx_tower_command_run_wizard_creator_only" model="ir.rule">
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_command_run_wizard" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- cx.tower.plan.run.wizard -->
|
||||
<record id="rule_cx_tower_plan_run_wizard_creator_only" model="ir.rule">
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_plan_run_wizard" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- cx.tower.server.host.key.wizard -->
|
||||
<record id="rule_cx_tower_server_host_key_wizard_creator_only" model="ir.rule">
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_host_key_wizard" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- cx.tower.server.template.create.wizard -->
|
||||
<record
|
||||
id="rule_cx_tower_server_template_create_wizard_creator_only"
|
||||
model="ir.rule"
|
||||
>
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_server_template_create_wizard" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!--
|
||||
Access rule checks for User and Manager:
|
||||
1. Shortcut access
|
||||
2. Access to shortcut servers
|
||||
-->
|
||||
|
||||
<record id="cx_tower_shortcut_rule_group_user_visibility" model="ir.rule">
|
||||
<field name="name">Tower Shortcut: user visibility rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_shortcut" />
|
||||
<field name="groups" eval="[(4, ref('group_user'))]" />
|
||||
<field name="domain_force">[
|
||||
('server_ids.user_ids', 'in', [user.id]),
|
||||
('access_level', '=', '1')
|
||||
]</field>
|
||||
|
||||
</record>
|
||||
|
||||
|
||||
<record id="cx_tower_shortcut_rule_group_manager_visibility" model="ir.rule">
|
||||
<field name="name">Tower shortcut: manager visibility rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_shortcut" />
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_manager'))]" />
|
||||
<field name="domain_force">[
|
||||
('access_level', '<=', '2'),
|
||||
'|', '|', '|',
|
||||
('server_ids.user_ids', 'in', [user.id]),
|
||||
('server_ids.manager_ids', 'in', [user.id]),
|
||||
('server_template_ids.user_ids', 'in', [user.id]),
|
||||
('server_template_ids.manager_ids', 'in', [user.id]),
|
||||
]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="cx_tower_shortcut_rule_group_root_visibility" model="ir.rule">
|
||||
<field name="name">Tower shortcut: root visibility rule</field>
|
||||
<field name="model_id" ref="model_cx_tower_shortcut" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4,ref('cetmix_tower_server.group_root'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- Record Rules -->
|
||||
<record id="rule_cx_tower_tag_user" model="ir.rule">
|
||||
<field name="name">Tower Tag: User can read any record</field>
|
||||
<field name="model_id" ref="model_cx_tower_tag" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_user'))]" />
|
||||
<field name="perm_read" eval="True" />
|
||||
<field name="perm_write" eval="False" />
|
||||
<field name="perm_create" eval="False" />
|
||||
<field name="perm_unlink" eval="False" />
|
||||
</record>
|
||||
|
||||
<record id="rule_cx_tower_tag_manager" model="ir.rule">
|
||||
<field name="name">Tower Tag: Manager can create/edit/delete own records</field>
|
||||
<field name="model_id" ref="model_cx_tower_tag" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_manager'))]" />
|
||||
<field name="perm_read" eval="False" />
|
||||
<field name="perm_write" eval="True" />
|
||||
<field name="perm_create" eval="True" />
|
||||
<field name="perm_unlink" eval="True" />
|
||||
</record>
|
||||
|
||||
<record id="rule_cx_tower_tag_root" model="ir.rule">
|
||||
<field name="name">Tower Tag: Root has full access</field>
|
||||
<field name="model_id" ref="model_cx_tower_tag" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_root'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User Read Rules -->
|
||||
<record id="rule_cx_tower_variable_option_user_read" model="ir.rule">
|
||||
<field name="name">Variable Option: User Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_option" />
|
||||
<field name="domain_force">[('access_level', '=', '1')]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<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 Read Rules -->
|
||||
<record id="rule_cx_tower_variable_option_manager_read" model="ir.rule">
|
||||
<field name="name">Variable Option: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_option" />
|
||||
<field name="domain_force">[('access_level', '<=', '2')]</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 Rules -->
|
||||
<record id="rule_cx_tower_variable_option_manager_write" model="ir.rule">
|
||||
<field name="name">Variable Option: Manager Write/Create/Unlink Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_option" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), ('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="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root Access Rule -->
|
||||
<record id="rule_cx_tower_variable_option_root" model="ir.rule">
|
||||
<field name="name">Variable Option: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_option" />
|
||||
<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,52 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- User Read Rule -->
|
||||
<record id="rule_cx_tower_variable_user_read" model="ir.rule">
|
||||
<field name="name">Variable: User Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable" />
|
||||
<field name="domain_force">[('access_level', '=', '1')]</field>
|
||||
<field name="groups" eval="[(4, ref('cetmix_tower_server.group_user'))]" />
|
||||
<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 Read Rule -->
|
||||
<record id="rule_cx_tower_variable_manager_read" model="ir.rule">
|
||||
<field name="name">Variable: Manager Read Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable" />
|
||||
<field name="domain_force">[('access_level', '<=', '2')]</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_cx_tower_variable_manager_write" model="ir.rule">
|
||||
<field name="name">Variable: Manager Write/Create/Unlink Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), ('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="1" />
|
||||
</record>
|
||||
|
||||
<!-- Root Access Rule -->
|
||||
<record id="rule_cx_tower_variable_root" model="ir.rule">
|
||||
<field name="name">Variable: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable" />
|
||||
<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,257 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- User Read Rules -->
|
||||
<record id="rule_cx_tower_variable_value_user_read_global" model="ir.rule">
|
||||
<field name="name">Variable Value: User Read Global Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '=', '1'), ('is_global', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_user'))]" />
|
||||
<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>
|
||||
|
||||
<record id="rule_cx_tower_variable_value_user_read_server" model="ir.rule">
|
||||
<field name="name">Variable Value: User Read Server Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '=', '1'), ('server_id.user_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('group_user'))]" />
|
||||
<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>
|
||||
|
||||
<record id="rule_cx_tower_variable_value_user_read_plan" model="ir.rule">
|
||||
<field name="name">Variable Value: User Read Plan Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '=', '1'), ('plan_line_action_id.plan_id.user_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('group_user'))]" />
|
||||
<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>
|
||||
|
||||
<record id="rule_cx_tower_variable_value_user_read_plan_server" model="ir.rule">
|
||||
<field name="name">Variable Value: User Read Plan Server Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '=', '1'), ('plan_line_action_id.plan_id.server_ids.user_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('group_user'))]" />
|
||||
<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 Read Rules -->
|
||||
<record id="rule_cx_tower_variable_value_manager_read_global" model="ir.rule">
|
||||
<field name="name">Variable Value: Manager Read Global Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), ('is_global', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('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>
|
||||
|
||||
<record id="rule_cx_tower_variable_value_manager_read_server" model="ir.rule">
|
||||
<field name="name">Variable Value: Manager Read Server Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), '|', ('server_id.user_ids', 'in', [user.id]), ('server_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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>
|
||||
|
||||
<record
|
||||
id="rule_cx_tower_variable_value_manager_read_server_template"
|
||||
model="ir.rule"
|
||||
>
|
||||
<field name="name">Variable Value: Manager Read Server Template Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), '|', ('server_template_id.user_ids', 'in', [user.id]), ('server_template_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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>
|
||||
|
||||
<record id="rule_cx_tower_variable_value_manager_read_plan" model="ir.rule">
|
||||
<field name="name">Variable Value: Manager Read Plan Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), '|', ('plan_line_action_id.plan_id.user_ids', 'in', [user.id]), ('plan_line_action_id.plan_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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>
|
||||
|
||||
<record id="rule_cx_tower_variable_value_manager_read_plan_server" model="ir.rule">
|
||||
<field name="name">Variable Value: Manager Read Plan Server Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), '|', ('plan_line_action_id.plan_id.server_ids.user_ids', 'in', [user.id]), ('plan_line_action_id.plan_id.server_ids.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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 Rules -->
|
||||
<record id="rule_cx_tower_variable_value_manager_write_server" model="ir.rule">
|
||||
<field name="name">Variable Value: Manager Write/Create Server Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), ('server_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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>
|
||||
|
||||
<record
|
||||
id="rule_cx_tower_variable_value_manager_write_server_template"
|
||||
model="ir.rule"
|
||||
>
|
||||
<field
|
||||
name="name"
|
||||
>Variable Value: Manager Write/Create Server Template Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), ('server_template_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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>
|
||||
|
||||
<record id="rule_cx_tower_variable_value_manager_write_plan" model="ir.rule">
|
||||
<field name="name">Variable Value: Manager Write/Create Plan Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), ('plan_line_action_id.plan_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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>
|
||||
|
||||
<record id="rule_cx_tower_variable_value_manager_write_plan_action" model="ir.rule">
|
||||
<field
|
||||
name="name"
|
||||
>Variable Value: Manager Write/Create Plan Server Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), ('plan_line_action_id.plan_id.server_ids.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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 Unlink Rules -->
|
||||
<record id="rule_cx_tower_variable_value_manager_unlink_server" model="ir.rule">
|
||||
<field name="name">Variable Value: Manager Unlink Server Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), ('create_uid', '=', user.id), ('server_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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>
|
||||
|
||||
<record
|
||||
id="rule_cx_tower_variable_value_manager_unlink_server_template"
|
||||
model="ir.rule"
|
||||
>
|
||||
<field name="name">Variable Value: Manager Unlink Server Template Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), ('create_uid', '=', user.id), ('server_template_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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>
|
||||
|
||||
<record id="rule_cx_tower_variable_value_manager_unlink_plan" model="ir.rule">
|
||||
<field name="name">Variable Value: Manager Unlink Plan Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), ('create_uid', '=', user.id), ('plan_line_action_id.plan_id.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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>
|
||||
|
||||
<record
|
||||
id="rule_cx_tower_variable_value_manager_unlink_plan_server"
|
||||
model="ir.rule"
|
||||
>
|
||||
<field name="name">Variable Value: Manager Unlink Plan Server Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('access_level', '<=', '2'), ('create_uid', '=', user.id), ('plan_line_action_id.plan_id.server_ids.manager_ids', 'in', [user.id])]</field>
|
||||
<field name="groups" eval="[(4, ref('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_cx_tower_variable_value_root" model="ir.rule">
|
||||
<field name="name">Variable Value: Root Full Access</field>
|
||||
<field name="model_id" ref="model_cx_tower_variable_value" />
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('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>
|
||||
70
addons/cetmix_tower_server/security/ir.model.access.csv
Normal file
@@ -0,0 +1,70 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_variable_user,Variable->User,model_cx_tower_variable,group_user,1,0,0,0
|
||||
access_variable_manager,Variable->Manager,model_cx_tower_variable,group_manager,1,1,1,0
|
||||
access_variable_root,Variable->Root,model_cx_tower_variable,group_root,1,1,1,1
|
||||
access_variable_value_user,Variable Value->User,model_cx_tower_variable_value,group_user,1,0,0,0
|
||||
access_variable_value_manager,Variable Value->Manager,model_cx_tower_variable_value,group_manager,1,1,1,1
|
||||
access_variable_value_root,Variable Value->Root,model_cx_tower_variable_value,group_root,1,1,1,1
|
||||
access_os_user,OS->User,model_cx_tower_os,group_user,1,0,0,0
|
||||
access_os_root,OS->Root,model_cx_tower_os,group_root,1,1,1,1
|
||||
access_tag_user,Tag->User,model_cx_tower_tag,group_user,1,0,0,0
|
||||
access_tag_manager,Tag->Manager,model_cx_tower_tag,group_manager,1,1,1,1
|
||||
access_tag_root,Tag->Root,model_cx_tower_tag,group_root,1,1,1,1
|
||||
access_server_user,Server->User,model_cx_tower_server,group_user,1,0,0,0
|
||||
access_server_manager,Server->Manager,model_cx_tower_server,group_manager,1,1,1,1
|
||||
access_server_root,Server->Root,model_cx_tower_server,group_root,1,1,1,1
|
||||
access_command_user,Command->User,model_cx_tower_command,group_user,1,0,0,0
|
||||
access_command_manager,Command->Manager,model_cx_tower_command,group_manager,1,1,1,1
|
||||
access_command_root,Command->Root,model_cx_tower_command,group_root,1,1,1,1
|
||||
access_run_command_user,Run Command->User,model_cx_tower_command_run_wizard,group_user,1,1,1,1
|
||||
access_run_command_variable_value_user,Run Command Variable Value->User,model_cx_tower_command_run_wizard_variable_value,group_user,1,1,1,1
|
||||
access_execute_plan_user,Run Plan->User,model_cx_tower_plan_run_wizard,group_user,1,1,1,1
|
||||
access_execute_plan_variable_value_user,Run Plan Variable Value->User,model_cx_tower_plan_run_wizard_variable_value,group_user,1,1,1,1
|
||||
access_key_user,Key->User,model_cx_tower_key,group_user,0,0,0,0
|
||||
access_key_manager,Key->Manager,model_cx_tower_key,group_manager,1,1,1,1
|
||||
access_key_root,Key->Root,model_cx_tower_key,group_root,1,1,1,1
|
||||
access_key_value_manager,Key Value->Manager,model_cx_tower_key_value,group_manager,1,1,1,1
|
||||
access_key_value_root,Key Value->Root,model_cx_tower_key_value,group_root,1,1,1,1
|
||||
access_command_log_user,Command Log->User,model_cx_tower_command_log,group_user,1,0,0,0
|
||||
access_command_log_manager,Command Log->Manager,model_cx_tower_command_log,group_manager,1,0,0,0
|
||||
access_command_log_root,Command Log->Root,model_cx_tower_command_log,group_root,1,0,0,0
|
||||
access_plan_user,Plan->User,model_cx_tower_plan,group_user,1,0,0,0
|
||||
access_plan_manager,Plan->Manager,model_cx_tower_plan,group_manager,1,1,1,1
|
||||
access_plan_root,Plan->Root,model_cx_tower_plan,group_root,1,1,1,1
|
||||
access_plan_line_user,Plan Line->User,model_cx_tower_plan_line,group_user,1,0,0,0
|
||||
access_plan_line_manager,Plan Line->Manager,model_cx_tower_plan_line,group_manager,1,1,1,1
|
||||
access_plan_line_root,Plan Line->Root,model_cx_tower_plan_line,group_root,1,1,1,1
|
||||
access_plan_line_action_user,Plan Line Action->User,model_cx_tower_plan_line_action,group_user,1,0,0,0
|
||||
access_plan_line_action_manager,Plan Line Action->Manager,model_cx_tower_plan_line_action,group_manager,1,1,1,1
|
||||
access_plan_line_action_root,Plan Line Action->Root,model_cx_tower_plan_line_action,group_root,1,1,1,1
|
||||
access_plan_log_user,Plan Log->User,model_cx_tower_plan_log,group_user,1,0,0,0
|
||||
access_plan_log_manager,Plan Log->Manager,model_cx_tower_plan_log,group_manager,1,0,0,0
|
||||
access_plan_log_root,Plan Log->Root,model_cx_tower_plan_log,group_root,1,0,0,0
|
||||
access_file_user,File->User,model_cx_tower_file,group_user,1,0,0,0
|
||||
access_file_manager,File->Manager,model_cx_tower_file,group_manager,1,1,1,1
|
||||
access_file_root,File->Root,model_cx_tower_file,group_root,1,1,1,1
|
||||
access_file_template_manager,File Template->Manager,model_cx_tower_file_template,group_manager,1,1,1,1
|
||||
access_file_template_root,File Template->Root,model_cx_tower_file_template,group_root,1,1,1,1
|
||||
access_server_log_user,Server Log->User,model_cx_tower_server_log,group_user,1,0,0,0
|
||||
access_server_log_manager,Server Log->Manager,model_cx_tower_server_log,group_manager,1,1,1,1
|
||||
access_server_log_root,Server Log->Root,model_cx_tower_server_log,group_root,1,1,1,1
|
||||
access_server_template_manager,Server Template->Manager,model_cx_tower_server_template,group_manager,1,1,1,1
|
||||
access_server_template_root,Server Template->Root,model_cx_tower_server_template,group_root,1,1,1,1
|
||||
access_create_server_from_template_manager,Create Server From Template->Manager,model_cx_tower_server_template_create_wizard,group_manager,1,1,1,1
|
||||
access_create_server_from_template_line_manager,Create Server From Template Line->Manager,model_cx_tower_server_template_create_wizard_line,group_manager,1,1,1,1
|
||||
access_cx_tower_variable_option_user,Variable Option->User,model_cx_tower_variable_option,group_user,1,0,0,0
|
||||
access_cx_tower_variable_option_manager,Variable Option->Manager,model_cx_tower_variable_option,group_manager,1,1,1,1
|
||||
access_cx_tower_variable_option_root,Variable Option->Root,model_cx_tower_variable_option,group_root,1,1,1,1
|
||||
access_cx_tower_vault_no_access,cx.tower.vault no access,model_cx_tower_vault,group_user,0,0,0,0
|
||||
access_cx_tower_server_host_key_wizard_manager,Show Host Key->Manager,model_cx_tower_server_host_key_wizard,group_manager,1,1,1,1
|
||||
access_cx_tower_server_host_key_wizard_root,Show Host Key->Root,model_cx_tower_server_host_key_wizard,group_root,1,1,1,1
|
||||
access_cetmix_tower_user,Cetmix Tower->User,model_cetmix_tower,group_user,1,1,0,0
|
||||
access_shortcut_user,Shortcut->User,model_cx_tower_shortcut,group_user,1,0,0,0
|
||||
access_shortcut_manager,Shortcut->Manager,model_cx_tower_shortcut,group_manager,1,0,0,0
|
||||
access_shortcut_root,Shortcut->Root,model_cx_tower_shortcut,group_root,1,1,1,1
|
||||
access_scheduled_task_user,Scheduled Task->User,model_cx_tower_scheduled_task,group_user,0,0,0,0
|
||||
access_scheduled_task_manager,Scheduled Task->Manager,model_cx_tower_scheduled_task,group_manager,1,1,1,1
|
||||
access_scheduled_task_root,Scheduled Task->Root,model_cx_tower_scheduled_task,group_root,1,1,1,1
|
||||
access_scheduled_task_cv_user,Scheduled Task Custom Variable Value->User,model_cx_tower_scheduled_task_cv,group_user,0,0,0,0
|
||||
access_scheduled_task_cv_manager,Scheduled Task Custom Variable Value->Manager,model_cx_tower_scheduled_task_cv,group_manager,1,1,1,1
|
||||
access_scheduled_task_cv_root,Scheduled Task Custom Variable Value->Root,model_cx_tower_scheduled_task_cv,group_root,1,1,1,1
|
||||
|
1
addons/cetmix_tower_server/ssh/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import ssh
|
||||
382
addons/cetmix_tower_server/ssh/ssh.py
Normal file
@@ -0,0 +1,382 @@
|
||||
# ruff: noqa: UP007
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Union
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
try:
|
||||
from paramiko import (
|
||||
AutoAddPolicy,
|
||||
DSSKey,
|
||||
ECDSAKey,
|
||||
Ed25519Key,
|
||||
MissingHostKeyPolicy,
|
||||
RSAKey,
|
||||
SFTPClient,
|
||||
SSHClient,
|
||||
SSHException,
|
||||
)
|
||||
except ImportError:
|
||||
_logger.error(
|
||||
"Looks like 'paramiko' is not installed, please try to "
|
||||
"install it using 'pip install paramiko'"
|
||||
)
|
||||
AutoAddPolicy = MissingHostKeyPolicy = RSAKey = SSHClient = None
|
||||
|
||||
|
||||
class KeyLoader:
|
||||
"""
|
||||
Utility for loading private SSH key in supported formats.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def load_private_key(ssh_key: str) -> Union[RSAKey, DSSKey, ECDSAKey, Ed25519Key]:
|
||||
"""
|
||||
Load a private SSH key from a string.
|
||||
"""
|
||||
key_file = io.StringIO(ssh_key)
|
||||
for key_class in (RSAKey, DSSKey, ECDSAKey, Ed25519Key):
|
||||
try:
|
||||
key_file.seek(0)
|
||||
return key_class.from_private_key(key_file)
|
||||
except SSHException:
|
||||
_logger.warning(
|
||||
f"KeyLoader: failed to load key through {key_class.__name__}."
|
||||
)
|
||||
_logger.error(
|
||||
"KeyLoader: unable to load private key. "
|
||||
"Unsupported format or invalid SSH key."
|
||||
)
|
||||
raise ValueError("Unsupported format or invalid SSH key.")
|
||||
|
||||
|
||||
class SSHConnection:
|
||||
"""
|
||||
Class for managing SSH connection.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
password: Optional[str] = None,
|
||||
ssh_key: Optional[str] = None,
|
||||
host_key: Optional[str] = None,
|
||||
mode: str = "p", # "p" for password, "k" for key
|
||||
allow_agent: bool = False,
|
||||
timeout: int = 5000,
|
||||
):
|
||||
"""
|
||||
Initialize the SSHConnection instance.
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.ssh_key = ssh_key
|
||||
self.host_key = host_key
|
||||
self.mode = mode
|
||||
self.allow_agent = allow_agent
|
||||
self.timeout = timeout
|
||||
self._ssh_client: Optional[SSHClient] = None
|
||||
|
||||
def connect(self) -> SSHClient:
|
||||
"""
|
||||
Connect to the SSH server.
|
||||
"""
|
||||
if self._ssh_client is not None:
|
||||
return self._ssh_client
|
||||
|
||||
self._ssh_client = SSHClient()
|
||||
self._ssh_client.load_system_host_keys()
|
||||
|
||||
if self.host_key:
|
||||
self._ssh_client.set_missing_host_key_policy(
|
||||
CustomHostKeyPolicy(self.host_key)
|
||||
)
|
||||
else:
|
||||
self._ssh_client.set_missing_host_key_policy(AutoAddPolicy())
|
||||
|
||||
connect_params = {
|
||||
"hostname": self.host,
|
||||
"port": self.port,
|
||||
"username": self.username,
|
||||
"allow_agent": self.allow_agent,
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
|
||||
if self.mode == "p":
|
||||
if not self.password:
|
||||
raise ValueError("For password mode, you need to pass a password.")
|
||||
connect_params["password"] = self.password
|
||||
elif self.mode == "k":
|
||||
if not self.ssh_key:
|
||||
raise ValueError("For key mode, you need to pass an SSH key.")
|
||||
connect_params["pkey"] = KeyLoader.load_private_key(self.ssh_key)
|
||||
else:
|
||||
raise ValueError(f"Unsupported connection mode: {self.mode}")
|
||||
|
||||
self._ssh_client.connect(**connect_params)
|
||||
return self._ssh_client
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""
|
||||
Disconnect the SSH connection.
|
||||
"""
|
||||
if self._ssh_client:
|
||||
_logger.info("SSHConnection: closing SSH connection.")
|
||||
self._ssh_client.close()
|
||||
self._ssh_client = None
|
||||
|
||||
def get_transport(self):
|
||||
"""
|
||||
Get the SSH transport.
|
||||
"""
|
||||
if self._ssh_client is None:
|
||||
self.connect()
|
||||
return self._ssh_client.get_transport()
|
||||
|
||||
|
||||
class CustomHostKeyPolicy(MissingHostKeyPolicy):
|
||||
"""
|
||||
Custom SSH host key policy for validating the server's host key.
|
||||
|
||||
This policy compares the server's host key (in Base64 format) with the expected key.
|
||||
If they do not match, an SSHException is raised to prevent connecting
|
||||
to an untrusted server. If they match, the key is added to the client's host keys.
|
||||
"""
|
||||
|
||||
def __init__(self, expected_host_key: str):
|
||||
"""
|
||||
Initialize the policy with the expected host key.
|
||||
|
||||
Args:
|
||||
expected_host_key (str): The expected host key in Base64 format.
|
||||
"""
|
||||
self.expected_host_key = expected_host_key
|
||||
|
||||
def missing_host_key(self, client, hostname, key):
|
||||
"""
|
||||
Called when the SSH client receives a host key from the server
|
||||
that is not in its known hosts.
|
||||
|
||||
Args:
|
||||
client: The SSH client instance.
|
||||
hostname: The hostname of the server.
|
||||
key: The host key received from the server.
|
||||
|
||||
Raises:
|
||||
SSHException: If the received host key does not match the expected host key.
|
||||
"""
|
||||
received_key = key.get_base64()
|
||||
if received_key != self.expected_host_key:
|
||||
raise SSHException(f"Host key mismatch for {hostname}. ")
|
||||
# If the key matches, add it to the client's known hosts
|
||||
client._host_keys.add(hostname, key.get_name(), key)
|
||||
|
||||
|
||||
class SftpService:
|
||||
"""
|
||||
Service for working with SFTP, using SSH connection.
|
||||
"""
|
||||
|
||||
def __init__(self, connection: SSHConnection):
|
||||
"""
|
||||
Initialize the SftpService instance.
|
||||
"""
|
||||
self.connection = connection
|
||||
self._sftp_client: Optional[SFTPClient] = None
|
||||
|
||||
def get_client(self) -> SFTPClient:
|
||||
"""
|
||||
Get the SFTP client.
|
||||
"""
|
||||
if self._sftp_client is None:
|
||||
transport = self.connection.get_transport()
|
||||
self._sftp_client = SFTPClient.from_transport(transport)
|
||||
return self._sftp_client
|
||||
|
||||
def upload_file(self, file: Union[str, io.BytesIO], remote_path: str) -> None:
|
||||
"""
|
||||
Upload a file to the remote server.
|
||||
"""
|
||||
client = self.get_client()
|
||||
if isinstance(file, io.BytesIO):
|
||||
client.putfo(file, remote_path)
|
||||
elif isinstance(file, str):
|
||||
client.put(file, remote_path)
|
||||
else:
|
||||
raise TypeError(f"File type {type(file).__name__} is not supported.")
|
||||
|
||||
def download_file(self, remote_path: str) -> bytes:
|
||||
"""
|
||||
Download a file from the remote server.
|
||||
"""
|
||||
client = self.get_client()
|
||||
with client.open(remote_path, "rb") as remote_file:
|
||||
return remote_file.read()
|
||||
|
||||
def delete_file(self, remote_path: str) -> None:
|
||||
"""
|
||||
Delete a file from the remote server.
|
||||
"""
|
||||
client = self.get_client()
|
||||
client.remove(remote_path)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""
|
||||
Disconnect the SFTP client.
|
||||
"""
|
||||
if self._sftp_client:
|
||||
_logger.info("SftpService: closing SFTP connection.")
|
||||
self._sftp_client.close()
|
||||
self._sftp_client = None
|
||||
|
||||
|
||||
class CommandExecutor:
|
||||
"""
|
||||
Class for executing commands on a remote server.
|
||||
"""
|
||||
|
||||
def __init__(self, connection: SSHConnection):
|
||||
"""
|
||||
Initialize the CommandExecutor instance.
|
||||
"""
|
||||
self.connection = connection
|
||||
|
||||
def exec_command(
|
||||
self, command: str, sudo: Optional[str] = None
|
||||
) -> tuple[int, list[str], list[str]]:
|
||||
"""
|
||||
Run a command on the remote server.
|
||||
|
||||
Args:
|
||||
command (str): The command to execute.
|
||||
sudo (Optional[str]): Sudo mode.
|
||||
|
||||
Returns:
|
||||
tuple:
|
||||
- exit_status (int)
|
||||
- stdout (list[str])
|
||||
- stderr (list[str])
|
||||
"""
|
||||
ssh_client = self.connection.connect()
|
||||
use_sudo_with_password = sudo == "p" and self.connection.username != "root"
|
||||
|
||||
if use_sudo_with_password and not self.connection.password:
|
||||
return 255, [], ["Sudo password not provided!"]
|
||||
|
||||
try:
|
||||
stdin, stdout, stderr = ssh_client.exec_command(command)
|
||||
if use_sudo_with_password:
|
||||
stdin.write(self.connection.password + "\n")
|
||||
stdin.flush()
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
response = stdout.readlines()
|
||||
error = stderr.readlines()
|
||||
return exit_status, response, error
|
||||
except Exception as e:
|
||||
return 255, [], [str(e)]
|
||||
|
||||
|
||||
class SSHManager:
|
||||
"""
|
||||
Facade for working with SSH connection, SFTP and command execution.
|
||||
"""
|
||||
|
||||
_connection_cache = {}
|
||||
|
||||
def __new__(cls, connection: SSHConnection):
|
||||
"""
|
||||
Create a new SSHManager instance.
|
||||
"""
|
||||
key = (
|
||||
connection.host,
|
||||
connection.port,
|
||||
connection.username,
|
||||
connection.mode,
|
||||
connection.allow_agent,
|
||||
connection.password or "",
|
||||
connection.ssh_key or "",
|
||||
connection.host_key or "",
|
||||
)
|
||||
if key in cls._connection_cache:
|
||||
instance, created_at, cached_timeout = cls._connection_cache[key]
|
||||
# if timeout is changed, update the cached timeout
|
||||
if connection.timeout != cached_timeout:
|
||||
cls.delete_cache(key)
|
||||
else:
|
||||
_logger.info(
|
||||
"Using cached SSH connection for "
|
||||
"host=%s, port=%s, user=%s, mode=%s",
|
||||
connection.host,
|
||||
connection.port,
|
||||
connection.username,
|
||||
connection.mode,
|
||||
)
|
||||
return instance
|
||||
|
||||
_logger.info(
|
||||
"Creating new SSH connection for host=%s, port=%s, user=%s, mode=%s",
|
||||
connection.host,
|
||||
connection.port,
|
||||
connection.username,
|
||||
connection.mode,
|
||||
)
|
||||
instance = super().__new__(cls)
|
||||
cls._connection_cache[key] = (instance, time.time(), connection.timeout)
|
||||
return instance
|
||||
|
||||
def __init__(self, connection: SSHConnection):
|
||||
"""
|
||||
Initialize the SSHManager instance.
|
||||
"""
|
||||
# initialize only once
|
||||
if hasattr(self, "_initialized") and self._initialized:
|
||||
return
|
||||
self.connection = connection
|
||||
self.command_executor = CommandExecutor(connection)
|
||||
self.sftp_service = SftpService(connection)
|
||||
self._initialized = True
|
||||
|
||||
@classmethod
|
||||
def delete_cache(cls, key):
|
||||
"""
|
||||
Delete the cache of SSH connections.
|
||||
"""
|
||||
if key in SSHManager._connection_cache:
|
||||
del SSHManager._connection_cache[key]
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""
|
||||
Disconnect the SSH connection and SFTP client.
|
||||
"""
|
||||
if self.sftp_service._sftp_client is not None:
|
||||
self.sftp_service.disconnect()
|
||||
|
||||
if self.connection._ssh_client is not None:
|
||||
self.connection.disconnect()
|
||||
|
||||
key = (
|
||||
self.connection.host,
|
||||
self.connection.port,
|
||||
self.connection.username,
|
||||
self.connection.mode,
|
||||
self.connection.allow_agent,
|
||||
self.connection.password or "",
|
||||
self.connection.ssh_key or "",
|
||||
self.connection.host_key or "",
|
||||
)
|
||||
self.delete_cache(key)
|
||||
|
||||
@classmethod
|
||||
def get_connection_cache(cls):
|
||||
"""
|
||||
Get the connection cache.
|
||||
"""
|
||||
return cls._connection_cache
|
||||
BIN
addons/cetmix_tower_server/static/description/banner.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
addons/cetmix_tower_server/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 117 KiB |
747
addons/cetmix_tower_server/static/description/index.html
Normal file
@@ -0,0 +1,747 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
|
||||
<title>Cetmix Tower Server</title>
|
||||
<style type="text/css">
|
||||
|
||||
/*
|
||||
:Author: David Goodger (goodger@python.org)
|
||||
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
|
||||
:Copyright: This stylesheet has been placed in the public domain.
|
||||
|
||||
Default cascading style sheet for the HTML output of Docutils.
|
||||
Despite the name, some widely supported CSS2 features are used.
|
||||
|
||||
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
|
||||
customize this style sheet.
|
||||
*/
|
||||
|
||||
/* used to remove borders from tables and images */
|
||||
.borderless, table.borderless td, table.borderless th {
|
||||
border: 0 }
|
||||
|
||||
table.borderless td, table.borderless th {
|
||||
/* Override padding for "table.docutils td" with "! important".
|
||||
The right padding separates the table cells. */
|
||||
padding: 0 0.5em 0 0 ! important }
|
||||
|
||||
.first {
|
||||
/* Override more specific margin styles with "! important". */
|
||||
margin-top: 0 ! important }
|
||||
|
||||
.last, .with-subtitle {
|
||||
margin-bottom: 0 ! important }
|
||||
|
||||
.hidden {
|
||||
display: none }
|
||||
|
||||
.subscript {
|
||||
vertical-align: sub;
|
||||
font-size: smaller }
|
||||
|
||||
.superscript {
|
||||
vertical-align: super;
|
||||
font-size: smaller }
|
||||
|
||||
a.toc-backref {
|
||||
text-decoration: none ;
|
||||
color: black }
|
||||
|
||||
blockquote.epigraph {
|
||||
margin: 2em 5em ; }
|
||||
|
||||
dl.docutils dd {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Uncomment (and remove this text!) to get bold-faced definition list terms
|
||||
dl.docutils dt {
|
||||
font-weight: bold }
|
||||
*/
|
||||
|
||||
div.abstract {
|
||||
margin: 2em 5em }
|
||||
|
||||
div.abstract p.topic-title {
|
||||
font-weight: bold ;
|
||||
text-align: center }
|
||||
|
||||
div.admonition, div.attention, div.caution, div.danger, div.error,
|
||||
div.hint, div.important, div.note, div.tip, div.warning {
|
||||
margin: 2em ;
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.admonition p.admonition-title, div.hint p.admonition-title,
|
||||
div.important p.admonition-title, div.note p.admonition-title,
|
||||
div.tip p.admonition-title {
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
div.attention p.admonition-title, div.caution p.admonition-title,
|
||||
div.danger p.admonition-title, div.error p.admonition-title,
|
||||
div.warning p.admonition-title, .code .error {
|
||||
color: red ;
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
/* Uncomment (and remove this text!) to get reduced vertical space in
|
||||
compound paragraphs.
|
||||
div.compound .compound-first, div.compound .compound-middle {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
div.compound .compound-last, div.compound .compound-middle {
|
||||
margin-top: 0.5em }
|
||||
*/
|
||||
|
||||
div.dedication {
|
||||
margin: 2em 5em ;
|
||||
text-align: center ;
|
||||
font-style: italic }
|
||||
|
||||
div.dedication p.topic-title {
|
||||
font-weight: bold ;
|
||||
font-style: normal }
|
||||
|
||||
div.figure {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
div.footer, div.header {
|
||||
clear: both;
|
||||
font-size: smaller }
|
||||
|
||||
div.line-block {
|
||||
display: block ;
|
||||
margin-top: 1em ;
|
||||
margin-bottom: 1em }
|
||||
|
||||
div.line-block div.line-block {
|
||||
margin-top: 0 ;
|
||||
margin-bottom: 0 ;
|
||||
margin-left: 1.5em }
|
||||
|
||||
div.sidebar {
|
||||
margin: 0 0 0.5em 1em ;
|
||||
border: medium outset ;
|
||||
padding: 1em ;
|
||||
background-color: #ffffee ;
|
||||
width: 40% ;
|
||||
float: right ;
|
||||
clear: right }
|
||||
|
||||
div.sidebar p.rubric {
|
||||
font-family: sans-serif ;
|
||||
font-size: medium }
|
||||
|
||||
div.system-messages {
|
||||
margin: 5em }
|
||||
|
||||
div.system-messages h1 {
|
||||
color: red }
|
||||
|
||||
div.system-message {
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.system-message p.system-message-title {
|
||||
color: red ;
|
||||
font-weight: bold }
|
||||
|
||||
div.topic {
|
||||
margin: 2em }
|
||||
|
||||
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
|
||||
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
|
||||
margin-top: 0.4em }
|
||||
|
||||
h1.title {
|
||||
text-align: center }
|
||||
|
||||
h2.subtitle {
|
||||
text-align: center }
|
||||
|
||||
hr.docutils {
|
||||
width: 75% }
|
||||
|
||||
img.align-left, .figure.align-left, object.align-left, table.align-left {
|
||||
clear: left ;
|
||||
float: left ;
|
||||
margin-right: 1em }
|
||||
|
||||
img.align-right, .figure.align-right, object.align-right, table.align-right {
|
||||
clear: right ;
|
||||
float: right ;
|
||||
margin-left: 1em }
|
||||
|
||||
img.align-center, .figure.align-center, object.align-center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
table.align-center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left }
|
||||
|
||||
.align-center {
|
||||
clear: both ;
|
||||
text-align: center }
|
||||
|
||||
.align-right {
|
||||
text-align: right }
|
||||
|
||||
/* reset inner alignment in figures */
|
||||
div.align-right {
|
||||
text-align: inherit }
|
||||
|
||||
/* div.align-center * { */
|
||||
/* text-align: left } */
|
||||
|
||||
.align-top {
|
||||
vertical-align: top }
|
||||
|
||||
.align-middle {
|
||||
vertical-align: middle }
|
||||
|
||||
.align-bottom {
|
||||
vertical-align: bottom }
|
||||
|
||||
ol.simple, ul.simple {
|
||||
margin-bottom: 1em }
|
||||
|
||||
ol.arabic {
|
||||
list-style: decimal }
|
||||
|
||||
ol.loweralpha {
|
||||
list-style: lower-alpha }
|
||||
|
||||
ol.upperalpha {
|
||||
list-style: upper-alpha }
|
||||
|
||||
ol.lowerroman {
|
||||
list-style: lower-roman }
|
||||
|
||||
ol.upperroman {
|
||||
list-style: upper-roman }
|
||||
|
||||
p.attribution {
|
||||
text-align: right ;
|
||||
margin-left: 50% }
|
||||
|
||||
p.caption {
|
||||
font-style: italic }
|
||||
|
||||
p.credits {
|
||||
font-style: italic ;
|
||||
font-size: smaller }
|
||||
|
||||
p.label {
|
||||
white-space: nowrap }
|
||||
|
||||
p.rubric {
|
||||
font-weight: bold ;
|
||||
font-size: larger ;
|
||||
color: maroon ;
|
||||
text-align: center }
|
||||
|
||||
p.sidebar-title {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold ;
|
||||
font-size: larger }
|
||||
|
||||
p.sidebar-subtitle {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
p.topic-title {
|
||||
font-weight: bold }
|
||||
|
||||
pre.address {
|
||||
margin-bottom: 0 ;
|
||||
margin-top: 0 ;
|
||||
font: inherit }
|
||||
|
||||
pre.literal-block, pre.doctest-block, pre.math, pre.code {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
pre.code .ln { color: gray; } /* line numbers */
|
||||
pre.code, code { background-color: #eeeeee }
|
||||
pre.code .comment, code .comment { color: #5C6576 }
|
||||
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
|
||||
pre.code .literal.string, code .literal.string { color: #0C5404 }
|
||||
pre.code .name.builtin, code .name.builtin { color: #352B84 }
|
||||
pre.code .deleted, code .deleted { background-color: #DEB0A1}
|
||||
pre.code .inserted, code .inserted { background-color: #A3D289}
|
||||
|
||||
span.classifier {
|
||||
font-family: sans-serif ;
|
||||
font-style: oblique }
|
||||
|
||||
span.classifier-delimiter {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
span.interpreted {
|
||||
font-family: sans-serif }
|
||||
|
||||
span.option {
|
||||
white-space: nowrap }
|
||||
|
||||
span.pre {
|
||||
white-space: pre }
|
||||
|
||||
span.problematic, pre.problematic {
|
||||
color: red }
|
||||
|
||||
span.section-subtitle {
|
||||
/* font-size relative to parent (h1..h6 element) */
|
||||
font-size: 80% }
|
||||
|
||||
table.citation {
|
||||
border-left: solid 1px gray;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docinfo {
|
||||
margin: 2em 4em }
|
||||
|
||||
table.docutils {
|
||||
margin-top: 0.5em ;
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
table.footnote {
|
||||
border-left: solid 1px black;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docutils td, table.docutils th,
|
||||
table.docinfo td, table.docinfo th {
|
||||
padding-left: 0.5em ;
|
||||
padding-right: 0.5em ;
|
||||
vertical-align: top }
|
||||
|
||||
table.docutils th.field-name, table.docinfo th.docinfo-name {
|
||||
font-weight: bold ;
|
||||
text-align: left ;
|
||||
white-space: nowrap ;
|
||||
padding-left: 0 }
|
||||
|
||||
/* "booktabs" style (no vertical lines) */
|
||||
table.docutils.booktabs {
|
||||
border: 0px;
|
||||
border-top: 2px solid;
|
||||
border-bottom: 2px solid;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.docutils.booktabs * {
|
||||
border: 0px;
|
||||
}
|
||||
table.docutils.booktabs th {
|
||||
border-bottom: thin solid;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
|
||||
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
|
||||
font-size: 100% }
|
||||
|
||||
ul.auto-toc {
|
||||
list-style-type: none }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document" id="cetmix-tower-server">
|
||||
<h1 class="title">Cetmix Tower Server</h1>
|
||||
|
||||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:426fb7e35a73ec875cf2b484e24211df3f52fc22d9d637f4f3a86bc23ac2e05f
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server"><img alt="cetmix/cetmix-tower" src="https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github" /></a></p>
|
||||
<p><a class="reference external" href="https://cetmix.com/tower">Cetmix Tower</a> offers a streamlined
|
||||
solution for managing remote servers and applications via SSH or API
|
||||
calls directly from <a class="reference external" href="https://odoo.com">Odoo</a>. 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.</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.2.8 (2025-12-22)</a></li>
|
||||
<li><a class="reference internal" href="#section-2" id="toc-entry-5">16.0.2.2.7 (2025-12-16)</a></li>
|
||||
<li><a class="reference internal" href="#section-3" id="toc-entry-6">16.0.2.2.6 (2025-12-11)</a></li>
|
||||
<li><a class="reference internal" href="#section-4" id="toc-entry-7">16.0.2.2.5 (2025-12-10)</a></li>
|
||||
<li><a class="reference internal" href="#section-5" id="toc-entry-8">16.0.2.2.4 (2025-12-10)</a></li>
|
||||
<li><a class="reference internal" href="#section-6" id="toc-entry-9">16.0.2.2.3 (2025-12-03)</a></li>
|
||||
<li><a class="reference internal" href="#section-7" id="toc-entry-10">16.0.2.2.2 (2025-12-03)</a></li>
|
||||
<li><a class="reference internal" href="#section-8" id="toc-entry-11">16.0.2.2.0 (2025-11-12)</a></li>
|
||||
<li><a class="reference internal" href="#section-9" id="toc-entry-12">16.0.2.0.6 (2025-10-27)</a></li>
|
||||
<li><a class="reference internal" href="#section-10" id="toc-entry-13">16.0.2.0.5 (2025-10-16)</a></li>
|
||||
<li><a class="reference internal" href="#section-11" id="toc-entry-14">16.0.2.0.4 (2025-10-13)</a></li>
|
||||
<li><a class="reference internal" href="#section-12" id="toc-entry-15">16.0.2.0.3 (2025-10-13)</a></li>
|
||||
<li><a class="reference internal" href="#section-13" id="toc-entry-16">16.0.2.0.2 (2025-10-08)</a></li>
|
||||
<li><a class="reference internal" href="#section-14" id="toc-entry-17">16.0.2.0.1 (2025-10-08)</a></li>
|
||||
<li><a class="reference internal" href="#section-15" id="toc-entry-18">16.0.2.0.0 (2025-10-07)</a></li>
|
||||
<li><a class="reference internal" href="#section-16" id="toc-entry-19">16.0.1.7.2 (2025-09-18)</a></li>
|
||||
<li><a class="reference internal" href="#section-17" id="toc-entry-20">16.0.1.7.1 (2025-09-10)</a></li>
|
||||
<li><a class="reference internal" href="#section-18" id="toc-entry-21">16.0.1.6.4 (2025-08-18)</a></li>
|
||||
<li><a class="reference internal" href="#section-19" id="toc-entry-22">16.0.1.6.3 (2025-08-13)</a></li>
|
||||
<li><a class="reference internal" href="#section-20" id="toc-entry-23">16.0.1.6.2 (2025-08-05)</a></li>
|
||||
<li><a class="reference internal" href="#section-21" id="toc-entry-24">16.0.1.6.0 (2025-07-30)</a></li>
|
||||
<li><a class="reference internal" href="#section-22" id="toc-entry-25">16.0.1.5.3 (2025-07-29)</a></li>
|
||||
<li><a class="reference internal" href="#section-23" id="toc-entry-26">16.0.1.5.1 (2025-07-25)</a></li>
|
||||
<li><a class="reference internal" href="#section-24" id="toc-entry-27">16.0.1.5.0 (2025-07-22)</a></li>
|
||||
<li><a class="reference internal" href="#section-25" id="toc-entry-28">16.0.1.3.0 (2025-07-17)</a></li>
|
||||
<li><a class="reference internal" href="#section-26" id="toc-entry-29">16.0.1.1.4 (2025-07-07)</a></li>
|
||||
<li><a class="reference internal" href="#section-27" id="toc-entry-30">16.0.1.1.2 (2025-06-25)</a></li>
|
||||
<li><a class="reference internal" href="#section-28" id="toc-entry-31">16.0.1.1.1 (2025-06-21)</a></li>
|
||||
<li><a class="reference internal" href="#section-29" id="toc-entry-32">16.0.1.1.0 (2025-06-20)</a></li>
|
||||
<li><a class="reference internal" href="#section-30" id="toc-entry-33">16.0.1.0.12 (2025-06-06)</a></li>
|
||||
<li><a class="reference internal" href="#section-31" id="toc-entry-34">16.0.1.0.11 (2025-06-06)</a></li>
|
||||
<li><a class="reference internal" href="#section-32" id="toc-entry-35">16.0.1.0.10 (2025-05-24)</a></li>
|
||||
<li><a class="reference internal" href="#section-33" id="toc-entry-36">16.0.1.0.9 (2025-05-23)</a></li>
|
||||
<li><a class="reference internal" href="#section-34" id="toc-entry-37">16.0.1.0.8 (2025-05-21)</a></li>
|
||||
<li><a class="reference internal" href="#section-35" id="toc-entry-38">16.0.1.0.7 (2025-05-16)</a></li>
|
||||
<li><a class="reference internal" href="#section-36" id="toc-entry-39">16.0.1.0.6 (2025-05-16)</a></li>
|
||||
<li><a class="reference internal" href="#section-37" id="toc-entry-40">16.0.1.0.5 (2025-05-09)</a></li>
|
||||
<li><a class="reference internal" href="#section-38" id="toc-entry-41">16.0.1.0.4 (2025-04-30)</a></li>
|
||||
<li><a class="reference internal" href="#section-39" id="toc-entry-42">16.0.1.0.3 (2025-04-22)</a></li>
|
||||
<li><a class="reference internal" href="#section-40" id="toc-entry-43">16.0.1.0.2 (2025-04-22)</a></li>
|
||||
<li><a class="reference internal" href="#section-41" id="toc-entry-44">16.0.1.0.1</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-45">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="toc-entry-46">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="toc-entry-47">Authors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="toc-entry-48">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.2.8 (2025-12-22)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Handle malformed expressions in flight plan line conditions.
|
||||
(5154)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-2">
|
||||
<h2><a class="toc-backref" href="#toc-entry-5">16.0.2.2.7 (2025-12-16)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Support for ANSI formatting in server logs. (5141)</li>
|
||||
<li>Bugfixes: UI/UX fixed and improvements. (5141)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-3">
|
||||
<h2><a class="toc-backref" href="#toc-entry-6">16.0.2.2.6 (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-4">
|
||||
<h2><a class="toc-backref" href="#toc-entry-7">16.0.2.2.5 (2025-12-10)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Custom values in flight plan are lost in a skipped command
|
||||
and are not available after it. (5129)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-5">
|
||||
<h2><a class="toc-backref" href="#toc-entry-8">16.0.2.2.4 (2025-12-10)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Parse empty or missing key values as ‘None’ instead of
|
||||
leaving key reference as is. (5134)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-6">
|
||||
<h2><a class="toc-backref" href="#toc-entry-9">16.0.2.2.3 (2025-12-03)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Save correct error message in log when SSH connection fails.
|
||||
(5109)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-7">
|
||||
<h2><a class="toc-backref" href="#toc-entry-10">16.0.2.2.2 (2025-12-03)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Make variables selectable in scheduled tasks (5105)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-8">
|
||||
<h2><a class="toc-backref" href="#toc-entry-11">16.0.2.2.0 (2025-11-12)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Integrate user notifications into the main module, drop the
|
||||
‘cetmix_tower_notify_backend’ module. (5074)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-9">
|
||||
<h2><a class="toc-backref" href="#toc-entry-12">16.0.2.0.6 (2025-10-27)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Tag mixin and helper commands. (5039)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-10">
|
||||
<h2><a class="toc-backref" href="#toc-entry-13">16.0.2.0.5 (2025-10-16)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Flight plan command exception handling (4930)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-11">
|
||||
<h2><a class="toc-backref" href="#toc-entry-14">16.0.2.0.4 (2025-10-13)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Auto update references for related records (5005)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-12">
|
||||
<h2><a class="toc-backref" href="#toc-entry-15">16.0.2.0.3 (2025-10-13)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Terminate running flight plan manually (3410)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-13">
|
||||
<h2><a class="toc-backref" href="#toc-entry-16">16.0.2.0.2 (2025-10-08)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: UI/UX improvements (4996)</li>
|
||||
<li>Bugfixes: Handle secret values when a record is duplicated using
|
||||
copy() (4996)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-14">
|
||||
<h2><a class="toc-backref" href="#toc-entry-17">16.0.2.0.1 (2025-10-08)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Improve variable value references uniqueness (4961)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-15">
|
||||
<h2><a class="toc-backref" href="#toc-entry-18">16.0.2.0.0 (2025-10-07)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: ‘Cetmix Tower Vault’ - new way of centralized password/key
|
||||
management (4824)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-16">
|
||||
<h2><a class="toc-backref" href="#toc-entry-19">16.0.1.7.2 (2025-09-18)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Set ‘Auto Sync’ in files from file templates (4949)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-17">
|
||||
<h2><a class="toc-backref" href="#toc-entry-20">16.0.1.7.1 (2025-09-10)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Check custom values in flight plan line condition (4922)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-18">
|
||||
<h2><a class="toc-backref" href="#toc-entry-21">16.0.1.6.4 (2025-08-18)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Improve the extendability of the file upload command. (4759)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-19">
|
||||
<h2><a class="toc-backref" href="#toc-entry-22">16.0.1.6.3 (2025-08-13)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Improve access settings for logs (4866)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-20">
|
||||
<h2><a class="toc-backref" href="#toc-entry-23">16.0.1.6.2 (2025-08-05)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Pin paramiko version to “<4” to maintain compatibility with
|
||||
legacy installations (4891)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-21">
|
||||
<h2><a class="toc-backref" href="#toc-entry-24">16.0.1.6.0 (2025-07-30)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Optional behaviour when file uploaded by command already
|
||||
exists on the server. (4740)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-22">
|
||||
<h2><a class="toc-backref" href="#toc-entry-25">16.0.1.5.3 (2025-07-29)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Make file references server dependent to be more unique
|
||||
(4870)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-23">
|
||||
<h2><a class="toc-backref" href="#toc-entry-26">16.0.1.5.1 (2025-07-25)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Select secrets from dropdown list in the code fields (4853)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-24">
|
||||
<h2><a class="toc-backref" href="#toc-entry-27">16.0.1.5.0 (2025-07-22)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Select variables from dropdown list in the code fields
|
||||
(4827)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-25">
|
||||
<h2><a class="toc-backref" href="#toc-entry-28">16.0.1.3.0 (2025-07-17)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Add the tldextract and dnspython libraries. (4737)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-26">
|
||||
<h2><a class="toc-backref" href="#toc-entry-29">16.0.1.1.4 (2025-07-07)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Command log sorting (4816)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-27">
|
||||
<h2><a class="toc-backref" href="#toc-entry-30">16.0.1.1.2 (2025-06-25)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Required variables in servers (4779)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-28">
|
||||
<h2><a class="toc-backref" href="#toc-entry-31">16.0.1.1.1 (2025-06-21)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Command view improvements (4753)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-29">
|
||||
<h2><a class="toc-backref" href="#toc-entry-32">16.0.1.1.0 (2025-06-20)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Run commands and flight plans using scheduled tasks. (4650)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-30">
|
||||
<h2><a class="toc-backref" href="#toc-entry-33">16.0.1.0.12 (2025-06-06)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Improve command and flight plan log management. (4749)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-31">
|
||||
<h2><a class="toc-backref" href="#toc-entry-34">16.0.1.0.11 (2025-06-06)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Host key cannot be retrieved from the UI. (4747)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-32">
|
||||
<h2><a class="toc-backref" href="#toc-entry-35">16.0.1.0.10 (2025-05-24)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Improve command log and flight plan form views (4697)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-33">
|
||||
<h2><a class="toc-backref" href="#toc-entry-36">16.0.1.0.9 (2025-05-23)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Error when rendering a file not attached to a server. (4715)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-34">
|
||||
<h2><a class="toc-backref" href="#toc-entry-37">16.0.1.0.8 (2025-05-21)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: References for secret values. (4696)</li>
|
||||
<li>Features: Make the “Host key” field non-required in the form view to
|
||||
improve the UX. (4699)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-35">
|
||||
<h2><a class="toc-backref" href="#toc-entry-38">16.0.1.0.7 (2025-05-16)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Option to preserve command splitting when using sudo. (4641)</li>
|
||||
<li>Features: Record references for files. (4670)</li>
|
||||
<li>Features: Use <tt class="docutils literal">sudo</tt> parameter to pass sudo mode to command runner
|
||||
instead of using context. (4678)</li>
|
||||
<li>Bugfixes: Incorrect sudo usage in commands run in wizard. Pass ‘No
|
||||
split for sudo’ property to commands run in wizard. (4679)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-36">
|
||||
<h2><a class="toc-backref" href="#toc-entry-39">16.0.1.0.6 (2025-05-16)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Improve the key storage functionality. (4686)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-37">
|
||||
<h2><a class="toc-backref" href="#toc-entry-40">16.0.1.0.5 (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-38">
|
||||
<h2><a class="toc-backref" href="#toc-entry-41">16.0.1.0.4 (2025-04-30)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: UI/UX improvements. (4642)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-39">
|
||||
<h2><a class="toc-backref" href="#toc-entry-42">16.0.1.0.3 (2025-04-22)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Allow to pass custom variable values to commands (4524)</li>
|
||||
<li>Features: Cetmix Tower Odoo Automation model: pass custom variable
|
||||
values to the <tt class="docutils literal">server_run_command</tt> method. (4547)</li>
|
||||
<li>Bugfixes: Random id generation, sudo command parsing, record rule
|
||||
names, spelling errors in descriptions. (4612)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-40">
|
||||
<h2><a class="toc-backref" href="#toc-entry-43">16.0.1.0.2 (2025-04-22)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Refactor secret value handling, fix the new server template
|
||||
creation wizard. (4601)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-41">
|
||||
<h2><a class="toc-backref" href="#toc-entry-44">16.0.1.0.1</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-45">Bug Tracker</a></h1>
|
||||
<p>Bugs are tracked on <a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues">GitHub Issues</a>.
|
||||
In case of trouble, please check there if your issue has already been reported.
|
||||
If you spotted it first, help us to smash it by providing a detailed and welcomed
|
||||
<a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cetmix_tower_server%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-46">Credits</a></h1>
|
||||
<div class="section" id="authors">
|
||||
<h2><a class="toc-backref" href="#toc-entry-47">Authors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Cetmix</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2><a class="toc-backref" href="#toc-entry-48">Maintainers</a></h2>
|
||||
<p>This module is part of the <a class="reference external" href="https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server">cetmix/cetmix-tower</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,507 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import {AceField} from "@web/views/fields/ace/ace_field";
|
||||
import {AutocompletePopup} from "./autocomplete_popup.esm";
|
||||
import {registry} from "@web/core/registry";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import {useState} from "@odoo/owl";
|
||||
|
||||
const POPUP_FALLBACK_WIDTH = 500;
|
||||
const POPUP_FALLBACK_HEIGHT = 300;
|
||||
|
||||
class AceCommandField extends AceField {
|
||||
/**
|
||||
* Initialize the component with required services and properties
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.inputListener = null;
|
||||
this.clickOutsideListener = null;
|
||||
this.inputTimeout = null;
|
||||
this.variables = [];
|
||||
this.secrets = [];
|
||||
|
||||
// Use reactive state for properties that affect rendering
|
||||
this.state = useState({
|
||||
showPopup: false,
|
||||
popupItems: [],
|
||||
popupPosition: {},
|
||||
selectedIndex: 0,
|
||||
// Add popup type to distinguish between variables and secrets
|
||||
popupType: "variables",
|
||||
});
|
||||
|
||||
this.updateSelectedIndex = this.updateSelectedIndex.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load variables from the backend using ORM service
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadVariables() {
|
||||
try {
|
||||
this.variables = await this.orm.searchRead(
|
||||
"cx.tower.variable",
|
||||
[],
|
||||
["name", "reference"]
|
||||
);
|
||||
console.log(`Loaded ${this.variables.length} variables for autocomplete`);
|
||||
} catch (error) {
|
||||
console.error("Failed to load variables for autocomplete:", error);
|
||||
this.variables = [];
|
||||
this.env.services.notification.add(
|
||||
"Failed to load autocomplete variables",
|
||||
{type: "warning"}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load secrets from the backend using ORM service
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadSecrets() {
|
||||
try {
|
||||
this.secrets = await this.orm.searchRead(
|
||||
"cx.tower.key",
|
||||
[["key_type", "=", "s"]],
|
||||
["name", "reference"]
|
||||
);
|
||||
console.log(`Loaded ${this.secrets.length} secrets for autocomplete`);
|
||||
} catch (error) {
|
||||
console.error("Failed to load secrets for autocomplete:", error);
|
||||
this.secrets = [];
|
||||
this.env.services.notification.add("Failed to load autocomplete secrets", {
|
||||
type: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up ACE editor with custom autocompletion
|
||||
*/
|
||||
setupAce() {
|
||||
super.setupAce();
|
||||
|
||||
if (this.aceEditor) {
|
||||
this.setupCustomAutocompletion();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure custom autocompletion commands and keyboard bindings for ACE editor
|
||||
*/
|
||||
setupCustomAutocompletion() {
|
||||
// Remove any existing conflicting commands first
|
||||
this.aceEditor.commands.removeCommand("startAutocomplete");
|
||||
this.aceEditor.commands.removeCommand("expandSnippet");
|
||||
|
||||
// Only add the main autocomplete trigger command
|
||||
this.aceEditor.commands.addCommand({
|
||||
name: "customAutoComplete",
|
||||
bindKey: {win: "Ctrl-Space", mac: null},
|
||||
exec: (editor) => {
|
||||
this.showCustomCompletions(editor);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Set up input listener for {{ and #! triggers
|
||||
this.inputListener = () => {
|
||||
// Clear any existing timeout
|
||||
if (this.inputTimeout) {
|
||||
clearTimeout(this.inputTimeout);
|
||||
}
|
||||
// Use setTimeout to ensure the text is fully processed
|
||||
this.inputTimeout = setTimeout(() => {
|
||||
const cursor = this.aceEditor.getCursorPosition();
|
||||
const session = this.aceEditor.getSession();
|
||||
const line = session.getLine(cursor.row);
|
||||
const textBeforeCursor = line.substring(0, cursor.column);
|
||||
|
||||
// Check for variables trigger {{
|
||||
if (textBeforeCursor.endsWith("{{")) {
|
||||
// Remove {{ symbols from editor
|
||||
const startColumn = Math.max(0, cursor.column - 2);
|
||||
const range = {
|
||||
start: {row: cursor.row, column: startColumn},
|
||||
end: {row: cursor.row, column: cursor.column},
|
||||
};
|
||||
session.replace(range, "");
|
||||
|
||||
// Update cursor position
|
||||
const newCursor = {
|
||||
row: cursor.row,
|
||||
column: startColumn,
|
||||
};
|
||||
this.aceEditor.moveCursorToPosition(newCursor);
|
||||
this.showCustomCompletions(this.aceEditor, "variables");
|
||||
}
|
||||
// Check for secrets trigger !#
|
||||
else if (textBeforeCursor.endsWith("#!")) {
|
||||
// Remove !# symbols from editor
|
||||
const startColumn = Math.max(0, cursor.column - 2);
|
||||
const range = {
|
||||
start: {row: cursor.row, column: startColumn},
|
||||
end: {row: cursor.row, column: cursor.column},
|
||||
};
|
||||
session.replace(range, "");
|
||||
|
||||
// Update cursor position
|
||||
const newCursor = {
|
||||
row: cursor.row,
|
||||
column: startColumn,
|
||||
};
|
||||
this.aceEditor.moveCursorToPosition(newCursor);
|
||||
this.showCustomCompletions(this.aceEditor, "secrets");
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
this.aceEditor.on("input", this.inputListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show custom completions popup with available variables or secrets
|
||||
* @param {Object} editor - ACE editor instance
|
||||
* @param {String} type - Type of completion ('variables' or 'secrets')
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async showCustomCompletions(editor, type = "variables") {
|
||||
const cursor = editor.getCursorPosition();
|
||||
const session = editor.getSession();
|
||||
const line = session.getLine(cursor.row);
|
||||
const textBeforeCursor = line.substring(0, cursor.column);
|
||||
|
||||
let items = [];
|
||||
let triggerLength = 0;
|
||||
|
||||
if (type === "secrets") {
|
||||
// Handle secrets
|
||||
await this.loadSecrets();
|
||||
|
||||
if (!this.secrets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
items = this.secrets;
|
||||
} else {
|
||||
// Handle variables
|
||||
await this.loadVariables();
|
||||
|
||||
if (!this.variables.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
items = this.variables;
|
||||
// Check if we're already in a variable context
|
||||
const isInVariableContext = textBeforeCursor.endsWith("{{");
|
||||
|
||||
if (isInVariableContext) {
|
||||
triggerLength = 2;
|
||||
}
|
||||
}
|
||||
|
||||
const position = this.calculatePopupPosition(editor, cursor);
|
||||
|
||||
// Set popup type in state
|
||||
this.state.popupType = type;
|
||||
|
||||
await this.showAutocompletePopup(items, position, editor, triggerLength, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the optimal position for the autocomplete popup
|
||||
* @param {Object} editor - ACE editor instance
|
||||
* @param {Object} cursor - Cursor position object
|
||||
* @returns {Object} Position object with left and top coordinates
|
||||
*/
|
||||
calculatePopupPosition(editor, cursor) {
|
||||
const renderer = editor.renderer;
|
||||
|
||||
// Calculate cursor position within the editor
|
||||
const cursorPixelPos = renderer.textToScreenCoordinates(
|
||||
cursor.row,
|
||||
cursor.column
|
||||
);
|
||||
|
||||
// Get scroll position
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
|
||||
|
||||
// Calculate the cursor position relative to the viewport
|
||||
const viewportLeft = cursorPixelPos.pageX - scrollLeft;
|
||||
const viewportTop = cursorPixelPos.pageY - scrollTop;
|
||||
|
||||
// Position popup just below the cursor
|
||||
const finalLeft = viewportLeft;
|
||||
const finalTop = viewportTop + renderer.lineHeight;
|
||||
|
||||
// Ensure popup doesn't go outside viewport
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const popup = document.querySelector(".ace-autocomplete-popup");
|
||||
const popupWidth = popup ? popup.offsetWidth : POPUP_FALLBACK_WIDTH;
|
||||
const popupHeight = popup ? popup.offsetHeight : POPUP_FALLBACK_HEIGHT;
|
||||
|
||||
let adjustedLeft = finalLeft;
|
||||
let adjustedTop = finalTop;
|
||||
|
||||
// Adjust if popup would go off-screen horizontally
|
||||
if (finalLeft + popupWidth > viewportWidth) {
|
||||
adjustedLeft = finalLeft - popupWidth;
|
||||
}
|
||||
|
||||
// Adjust if popup would go off-screen vertically
|
||||
if (finalTop + popupHeight > viewportHeight) {
|
||||
adjustedTop = finalTop - popupHeight - renderer.lineHeight;
|
||||
}
|
||||
|
||||
// Make sure popup is not positioned off-screen
|
||||
adjustedLeft = Math.max(0, adjustedLeft);
|
||||
adjustedTop = Math.max(0, adjustedTop);
|
||||
|
||||
return {
|
||||
left: adjustedLeft,
|
||||
top: adjustedTop,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the autocomplete popup with variables or secrets at the specified position
|
||||
* @param {Array} items - Array of available variables or secrets
|
||||
* @param {Object} position - Position object with left and top coordinates
|
||||
* @param {Object} editor - ACE editor instance
|
||||
* @param {Number} triggerLength - Length of trigger text that should be replaced
|
||||
* @param {String} type - Type of completion ('variables' or 'secrets')
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async showAutocompletePopup(
|
||||
items,
|
||||
position,
|
||||
editor,
|
||||
triggerLength,
|
||||
type = "variables"
|
||||
) {
|
||||
this.hideAutocompletePopup();
|
||||
|
||||
this.state.popupItems = items;
|
||||
this.state.popupPosition = position;
|
||||
this.state.showPopup = true;
|
||||
this.state.selectedIndex = 0;
|
||||
this.state.popupType = type;
|
||||
this.currentEditor = editor;
|
||||
this.currentTriggerLength = triggerLength;
|
||||
this.currentType = type;
|
||||
|
||||
// Add click outside listener
|
||||
this.clickOutsideListener = (event) => {
|
||||
// Check if click is outside the popup and ace editor
|
||||
const popupElement = document.querySelector(".ace-autocomplete-popup");
|
||||
const aceElement = this.aceEditor.container;
|
||||
|
||||
if (
|
||||
popupElement &&
|
||||
!popupElement.contains(event.target) &&
|
||||
aceElement &&
|
||||
!aceElement.contains(event.target)
|
||||
) {
|
||||
this.hideAutocompletePopup();
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", this.clickOutsideListener, true);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the autocomplete popup and clean up event listeners
|
||||
*/
|
||||
hideAutocompletePopup() {
|
||||
// Remove click outside listener
|
||||
if (this.clickOutsideListener) {
|
||||
document.removeEventListener("click", this.clickOutsideListener, true);
|
||||
this.clickOutsideListener = null;
|
||||
}
|
||||
|
||||
this.state.showPopup = false;
|
||||
this.state.popupVariables = [];
|
||||
this.currentEditor = null;
|
||||
this.state.selectedIndex = 0;
|
||||
|
||||
// Return focus to the ACE editor
|
||||
if (this.aceEditor) {
|
||||
this.aceEditor.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the selected index in the autocomplete popup
|
||||
* @param {Number} index - New selected index
|
||||
*/
|
||||
updateSelectedIndex(index) {
|
||||
if (this.state) {
|
||||
this.state.selectedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle selection of a command from the autocomplete popup
|
||||
* @param {Object} command - Selected command object
|
||||
* @param {Object} editor - ACE editor instance
|
||||
*/
|
||||
handleCommandSelection(command, editor) {
|
||||
if (!command || !command.reference) {
|
||||
this.hideAutocompletePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = editor.getCursorPosition();
|
||||
const session = editor.getSession();
|
||||
const line = session.getLine(cursor.row);
|
||||
const textBeforeCursor = line.substring(0, cursor.column);
|
||||
|
||||
// Get line length for validation
|
||||
const lineLength = session.getLine(cursor.row).length;
|
||||
const currentType = this.currentType || this.state.popupType;
|
||||
|
||||
let range = null;
|
||||
let insertText = "";
|
||||
|
||||
if (currentType === "secrets") {
|
||||
// Handle secrets insertion
|
||||
// Check if we're inside a secret context (between #!cxtower.secret and !#)
|
||||
const lastSecretStart = textBeforeCursor.lastIndexOf("#!cxtower.secret");
|
||||
const lastSecretEnd = textBeforeCursor.lastIndexOf("!#");
|
||||
|
||||
// Count occurrences of start and end delimiters for more robust validation
|
||||
const startCount = (textBeforeCursor.match(/#!cxtower\.secret/g) || [])
|
||||
.length;
|
||||
const endCount = (textBeforeCursor.match(/!#/g) || []).length;
|
||||
const isInsideSecret =
|
||||
startCount > endCount &&
|
||||
lastSecretStart > lastSecretEnd &&
|
||||
lastSecretStart !== -1;
|
||||
|
||||
if (isInsideSecret) {
|
||||
// We're inside a secret context, replace from after #!cxtower to cursor
|
||||
range = {
|
||||
start: {row: cursor.row, column: lastSecretStart + 16},
|
||||
end: {row: cursor.row, column: cursor.column},
|
||||
};
|
||||
// Clamp range to valid bounds
|
||||
range.start.column = Math.max(
|
||||
0,
|
||||
Math.min(range.start.column, lineLength)
|
||||
);
|
||||
range.end.column = Math.max(
|
||||
range.start.column,
|
||||
Math.min(range.end.column, lineLength)
|
||||
);
|
||||
insertText = `${command.reference}!#`;
|
||||
} else {
|
||||
// We're not in a secret context, insert complete secret
|
||||
const triggerLength = this.currentTriggerLength || 0;
|
||||
range = {
|
||||
start: {row: cursor.row, column: cursor.column - triggerLength},
|
||||
end: {row: cursor.row, column: cursor.column},
|
||||
};
|
||||
// Clamp range to valid bounds
|
||||
range.start.column = Math.max(
|
||||
0,
|
||||
Math.min(range.start.column, lineLength)
|
||||
);
|
||||
range.end.column = Math.max(
|
||||
range.start.column,
|
||||
Math.min(range.end.column, lineLength)
|
||||
);
|
||||
insertText = `#!cxtower.secret.${command.reference}!#`;
|
||||
}
|
||||
} else {
|
||||
// Handle variables insertion (existing logic)
|
||||
const lastOpenBrace = textBeforeCursor.lastIndexOf("{{");
|
||||
const lastCloseBrace = textBeforeCursor.lastIndexOf("}}");
|
||||
const isInsideVariable =
|
||||
lastOpenBrace > lastCloseBrace && lastOpenBrace !== -1;
|
||||
|
||||
if (isInsideVariable) {
|
||||
// We're inside a variable context, replace from after {{ to cursor
|
||||
range = {
|
||||
start: {row: cursor.row, column: lastOpenBrace + 2},
|
||||
end: {row: cursor.row, column: cursor.column},
|
||||
};
|
||||
// Clamp range to valid bounds
|
||||
range.start.column = Math.max(
|
||||
0,
|
||||
Math.min(range.start.column, lineLength)
|
||||
);
|
||||
range.end.column = Math.max(
|
||||
range.start.column,
|
||||
Math.min(range.end.column, lineLength)
|
||||
);
|
||||
insertText = ` ${command.reference} `;
|
||||
} else {
|
||||
// We're not in a variable context, insert complete variable
|
||||
const triggerLength = this.currentTriggerLength || 0;
|
||||
range = {
|
||||
start: {row: cursor.row, column: cursor.column - triggerLength},
|
||||
end: {row: cursor.row, column: cursor.column},
|
||||
};
|
||||
// Clamp range to valid bounds
|
||||
range.start.column = Math.max(
|
||||
0,
|
||||
Math.min(range.start.column, lineLength)
|
||||
);
|
||||
range.end.column = Math.max(
|
||||
range.start.column,
|
||||
Math.min(range.end.column, lineLength)
|
||||
);
|
||||
insertText = `{{ ${command.reference} }}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the text
|
||||
session.replace(range, insertText);
|
||||
|
||||
// Get the updated line length after replacement
|
||||
const updatedLineLength = session.getLine(cursor.row).length;
|
||||
|
||||
// Position cursor after the inserted text
|
||||
const newCursor = {
|
||||
row: cursor.row,
|
||||
column: range.start.column + insertText.length,
|
||||
};
|
||||
|
||||
newCursor.column = Math.max(0, Math.min(newCursor.column, updatedLineLength));
|
||||
|
||||
editor.moveCursorToPosition(newCursor);
|
||||
|
||||
this.hideAutocompletePopup();
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources when component is destroyed
|
||||
*/
|
||||
destroy() {
|
||||
if (this.inputTimeout) {
|
||||
clearTimeout(this.inputTimeout);
|
||||
}
|
||||
if (this.aceEditor && this.inputListener) {
|
||||
this.aceEditor.off("input", this.inputListener);
|
||||
}
|
||||
this.hideAutocompletePopup();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
AceCommandField.template = "cetmix_tower_server.AceCommandField";
|
||||
AceCommandField.components = {
|
||||
AutocompletePopup,
|
||||
};
|
||||
|
||||
registry.category("fields").add("ace_tower", AceCommandField);
|
||||
|
||||
export {AceCommandField};
|
||||
@@ -0,0 +1,44 @@
|
||||
// Custom styles ONLY for AceCommandField - more specific selectors
|
||||
.o_field_widget.o_field_ace_tower {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.o_field_widget[data-field-name] .o_field_ace.ace-command-field {
|
||||
min-height: 200px !important;
|
||||
height: auto !important;
|
||||
width: 100% !important;
|
||||
display: block !important;
|
||||
|
||||
.ace_editor {
|
||||
min-height: 200px !important;
|
||||
height: auto !important;
|
||||
width: 100% !important;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.ace_content {
|
||||
min-height: 200px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.ace_scroller {
|
||||
width: 100% !important;
|
||||
// Remove any scroll restrictions that might affect standard ACE
|
||||
overflow: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom autocomplete popup styles
|
||||
.ace-autocomplete-popup {
|
||||
.ace-autocomplete-item {
|
||||
&:hover {
|
||||
background-color: #e6f3ff !important;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="cetmix_tower_server.AceCommandField" owl="1">
|
||||
<t t-call="web.AceField" />
|
||||
<t t-if="state.showPopup">
|
||||
<AutocompletePopup
|
||||
commands="state.popupItems"
|
||||
position="state.popupPosition"
|
||||
selectedIndex="state.selectedIndex"
|
||||
type="state.popupType"
|
||||
onSelectedIndexChange="updateSelectedIndex"
|
||||
onItemClick="(command) => this.handleCommandSelection(command, this.currentEditor)"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,317 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import {Component, useEffect, useRef, useState} from "@odoo/owl";
|
||||
|
||||
class AutocompletePopup extends Component {
|
||||
/**
|
||||
* Component setup method that initializes refs, state, and effects
|
||||
*/
|
||||
setup() {
|
||||
this.popupRef = useRef("popupRef");
|
||||
this.searchInput = useRef("searchInput");
|
||||
this.itemsContainer = useRef("itemsContainer");
|
||||
|
||||
// State for search functionality
|
||||
this.state = useState({
|
||||
searchTerm: "",
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
this.scrollToSelected();
|
||||
},
|
||||
() => [this.props.selectedIndex]
|
||||
);
|
||||
|
||||
// Auto-focus search input when popup opens
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.searchInput.el) {
|
||||
// Use setTimeout to ensure DOM is ready
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.searchInput.el.focus();
|
||||
}, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
},
|
||||
() => []
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.props.position) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.popupRef.el) {
|
||||
this.popupRef.el.style.left = `${this.props.position.left}px`;
|
||||
this.popupRef.el.style.top = `${this.props.position.top}px`;
|
||||
this.popupRef.el.style.position = "fixed";
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
},
|
||||
() => [this.props.position]
|
||||
);
|
||||
|
||||
// Cleanup effect to clear search timeout
|
||||
useEffect(
|
||||
() => {
|
||||
return () => {
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
};
|
||||
},
|
||||
() => []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates search term from external keyboard input (from editor)
|
||||
* @param {String} char - The character typed or 'Backspace' for deletion
|
||||
*/
|
||||
updateSearchFromEditor(char) {
|
||||
if (char === "Backspace") {
|
||||
this.state.searchTerm = this.state.searchTerm.slice(0, -1);
|
||||
} else if (char.length === 1) {
|
||||
this.state.searchTerm += char;
|
||||
}
|
||||
if (this.props.onSelectedIndexChange) {
|
||||
this.props.onSelectedIndexChange(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters commands based on search term with enhanced search capabilities
|
||||
* @returns {Array} Filtered and sorted array of commands matching the search term
|
||||
*/
|
||||
get filteredCommands() {
|
||||
if (!this.state.searchTerm.trim()) {
|
||||
return this.props.commands;
|
||||
}
|
||||
|
||||
const searchTerm = this.state.searchTerm.toLowerCase();
|
||||
|
||||
// Filter and score commands based on search relevance
|
||||
const scoredCommands = this.props.commands
|
||||
.map((command) => {
|
||||
const name = (command.name || "").toLowerCase();
|
||||
const reference = (command.reference || "").toLowerCase();
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Exact matches get highest priority
|
||||
if (name === searchTerm || reference === searchTerm) {
|
||||
score = 1000;
|
||||
}
|
||||
// Starts with search term gets high priority
|
||||
else if (
|
||||
name.startsWith(searchTerm) ||
|
||||
reference.startsWith(searchTerm)
|
||||
) {
|
||||
score = 100;
|
||||
}
|
||||
// Contains search term gets medium priority
|
||||
else if (name.includes(searchTerm) || reference.includes(searchTerm)) {
|
||||
score = 10;
|
||||
}
|
||||
// No match
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Boost score for name matches over reference matches
|
||||
if (name.includes(searchTerm)) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// Boost score for shorter matches (more relevant)
|
||||
score += Math.max(0, 50 - Math.min(name.length, reference.length));
|
||||
|
||||
return {command, score};
|
||||
})
|
||||
.filter((item) => item !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((item) => item.command);
|
||||
|
||||
return scoredCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounces the search filtering
|
||||
* @param {String} searchTerm - The search term to set
|
||||
*/
|
||||
debouncedSearch(searchTerm) {
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.state.searchTerm = searchTerm;
|
||||
if (this.props.onSelectedIndexChange) {
|
||||
this.props.onSelectedIndexChange(0);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles search input changes
|
||||
* @param {Event} ev - The input event
|
||||
*/
|
||||
onSearchInput(ev) {
|
||||
ev.stopPropagation();
|
||||
this.debouncedSearch(ev.target.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common keyboard navigation logic
|
||||
* @param {KeyboardEvent} ev - The keyboard event
|
||||
*/
|
||||
handleKeyboardNavigation(ev) {
|
||||
if (ev.key === "ArrowDown") {
|
||||
ev.preventDefault();
|
||||
const newIndex = Math.min(
|
||||
(this.props.selectedIndex || 0) + 1,
|
||||
this.filteredCommands.length - 1
|
||||
);
|
||||
if (this.props.onSelectedIndexChange) {
|
||||
this.props.onSelectedIndexChange(newIndex);
|
||||
}
|
||||
this.scrollToSelected();
|
||||
} else if (ev.key === "ArrowUp") {
|
||||
ev.preventDefault();
|
||||
const newIndex = Math.max((this.props.selectedIndex || 0) - 1, 0);
|
||||
if (this.props.onSelectedIndexChange) {
|
||||
this.props.onSelectedIndexChange(newIndex);
|
||||
}
|
||||
this.scrollToSelected();
|
||||
} else if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
const selectedCommand =
|
||||
this.filteredCommands[this.props.selectedIndex || 0];
|
||||
if (selectedCommand) {
|
||||
this.onItemClick(selectedCommand);
|
||||
}
|
||||
} else if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
this.props.onItemClick(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles keydown events on search input
|
||||
* @param {KeyboardEvent} ev - The keyboard event
|
||||
*/
|
||||
onSearchKeyDown(ev) {
|
||||
ev.stopPropagation();
|
||||
this.handleKeyboardNavigation(ev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles focus events on search input
|
||||
* @param {FocusEvent} ev - The focus event
|
||||
*/
|
||||
onSearchFocus(ev) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles blur events on search input
|
||||
* @param {FocusEvent} ev - The blur event
|
||||
*/
|
||||
onSearchBlur(ev) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles click events on search input
|
||||
* @param {MouseEvent} ev - The click event
|
||||
*/
|
||||
onSearchClick(ev) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mousedown events on search input
|
||||
* @param {MouseEvent} ev - The mousedown event
|
||||
*/
|
||||
onSearchMouseDown(ev) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles item click events
|
||||
* @param {Object} command - The selected command object
|
||||
*/
|
||||
onItemClick(command) {
|
||||
this.props.onItemClick(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles close button click events
|
||||
*/
|
||||
onCloseClick() {
|
||||
this.props.onItemClick(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles global keydown events for the popup
|
||||
* @param {KeyboardEvent} ev - The keyboard event
|
||||
*/
|
||||
onKeyDown(ev) {
|
||||
// Handle search input from editor keyboard events
|
||||
if (ev.key.length === 1 && ev.key.match(/[a-zA-Z0-9_]/)) {
|
||||
// Add typed character to search
|
||||
this.updateSearchFromEditor(ev.key);
|
||||
} else if (ev.key === "Backspace") {
|
||||
// Remove last character from search
|
||||
this.updateSearchFromEditor("Backspace");
|
||||
} else {
|
||||
// Use common keyboard navigation logic
|
||||
this.handleKeyboardNavigation(ev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the selected item into view
|
||||
*/
|
||||
scrollToSelected() {
|
||||
const itemsContainer = this.itemsContainer.el;
|
||||
if (
|
||||
itemsContainer &&
|
||||
this.props.selectedIndex !== undefined &&
|
||||
this.props.selectedIndex >= 0 &&
|
||||
this.props.selectedIndex < itemsContainer.children.length
|
||||
) {
|
||||
const selectedItem = itemsContainer.children[this.props.selectedIndex];
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CSS class for autocomplete item based on selection state
|
||||
* @param {Number} index - The item index
|
||||
* @returns {String} CSS class string
|
||||
*/
|
||||
getItemClass(index) {
|
||||
return index === (this.props.selectedIndex || 0)
|
||||
? "ace-autocomplete-item ace-autocomplete-item-selected"
|
||||
: "ace-autocomplete-item";
|
||||
}
|
||||
}
|
||||
|
||||
AutocompletePopup.template = "cetmix_tower_server.AutocompletePopup";
|
||||
AutocompletePopup.props = {
|
||||
commands: {type: Array},
|
||||
onItemClick: {type: Function},
|
||||
position: {type: Object},
|
||||
selectedIndex: {type: Number, optional: true},
|
||||
onSelectedIndexChange: {type: Function, optional: true},
|
||||
type: {type: String, optional: true},
|
||||
};
|
||||
|
||||
export {AutocompletePopup};
|
||||
@@ -0,0 +1,190 @@
|
||||
// Define z-index variable for better management
|
||||
$z-index-autocomplete: 1050; // Above dropdowns but below modals
|
||||
|
||||
.ace-autocomplete-popup {
|
||||
position: absolute; // Keep original positioning for cursor placement
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: $z-index-autocomplete;
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
|
||||
// Mobile adaptations
|
||||
@media (max-width: 768px) {
|
||||
width: 90%;
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.ace-autocomplete-search {
|
||||
padding: 8px;
|
||||
padding-right: 48px; // Add right padding to avoid overlap with close button
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #f8f9fa;
|
||||
|
||||
// Mobile: reduce padding
|
||||
@media (max-width: 768px) {
|
||||
padding: 6px;
|
||||
padding-right: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
.ace-autocomplete-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ace-autocomplete-search-input:focus {
|
||||
border-color: #007cba;
|
||||
box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.1);
|
||||
}
|
||||
|
||||
.ace-autocomplete-items {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Standard scrollbar styling (Firefox 64+) */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #c1c1c1 #f1f1f1;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for webkit browsers */
|
||||
.ace-autocomplete-items::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.ace-autocomplete-items::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.ace-autocomplete-items::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ace-autocomplete-items::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
.ace-autocomplete-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
// Mobile: stack items vertically with reduced padding
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.ace-autocomplete-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.ace-autocomplete-item-selected {
|
||||
background-color: #e6f3ff;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.ace-autocomplete-item-selected:hover {
|
||||
background-color: #cce7ff;
|
||||
}
|
||||
|
||||
.command-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
// Mobile adaptations
|
||||
@media (max-width: 768px) {
|
||||
margin-right: 0;
|
||||
margin-bottom: 2px; // Reduced from 4px
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.command-description {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
flex-shrink: 0;
|
||||
|
||||
// Mobile adaptations
|
||||
@media (max-width: 768px) {
|
||||
font-size: 11px;
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.ace-autocomplete-no-results {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Close button styles
|
||||
.ace-autocomplete-close-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
z-index: 1;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
// Mobile-friendly touch target
|
||||
@media (max-width: 768px) {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
font-size: 24px;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="cetmix_tower_server.AutocompletePopup" owl="1">
|
||||
<div
|
||||
class="ace-autocomplete-popup"
|
||||
t-ref="popupRef"
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
t-att-aria-label="props.type === 'secrets' ? 'Secret search' : 'Variable search'"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="listbox"
|
||||
t-att-aria-owns="props.type === 'secrets' ? 'secrets-list' : 'variables-list'"
|
||||
>
|
||||
<!-- Close button for mobile convenience -->
|
||||
<button
|
||||
class="ace-autocomplete-close-btn"
|
||||
t-on-click="onCloseClick"
|
||||
type="button"
|
||||
title="Close"
|
||||
t-att-aria-label="props.type === 'secrets' ? 'Close secret search' : 'Close variable search'"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<!-- Search input field -->
|
||||
<div class="ace-autocomplete-search">
|
||||
<input
|
||||
type="text"
|
||||
class="ace-autocomplete-search-input"
|
||||
t-att-placeholder="props.type === 'secrets' ? 'Search secrets...' : 'Search variables...'"
|
||||
t-model="state.searchTerm"
|
||||
t-ref="searchInput"
|
||||
t-on-input="onSearchInput"
|
||||
t-on-keydown="onSearchKeyDown"
|
||||
t-on-focus="onSearchFocus"
|
||||
t-on-blur="onSearchBlur"
|
||||
t-att-aria-label="props.type === 'secrets' ? 'Search secrets' : 'Search variables'"
|
||||
t-att-aria-controls="props.type === 'secrets' ? 'secrets-list' : 'variables-list'"
|
||||
aria-autocomplete="list"
|
||||
t-att-aria-activedescendant="`${props.type === 'secrets' ? 'sec' : 'var'}-opt-${props.selectedIndex}`"
|
||||
/>
|
||||
</div>
|
||||
<!-- Items list -->
|
||||
<div
|
||||
class="ace-autocomplete-items"
|
||||
t-ref="itemsContainer"
|
||||
t-att-id="props.type === 'secrets' ? 'secrets-list' : 'variables-list'"
|
||||
role="listbox"
|
||||
>
|
||||
<div
|
||||
t-foreach="filteredCommands"
|
||||
t-as="command"
|
||||
t-key="command.name"
|
||||
t-att-id="`${props.type === 'secrets' ? 'sec' : 'var'}-opt-${command_index}`"
|
||||
t-att-class="getItemClass(command_index)"
|
||||
t-on-click="() => this.onItemClick(command)"
|
||||
role="option"
|
||||
t-att-aria-selected="command_index === props.selectedIndex ? 'true' : 'false'"
|
||||
>
|
||||
<span class="command-name" t-esc="command.name" />
|
||||
<span
|
||||
class="command-description"
|
||||
t-esc="props.type === 'secrets' ? `${command.reference}` : `{{ ${command.reference} }}`"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
t-if="!filteredCommands.length"
|
||||
class="ace-autocomplete-no-results"
|
||||
>
|
||||
<t t-if="props.type === 'secrets'">No secrets found</t>
|
||||
<t t-else="">No variables found</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,33 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {registry} from "@web/core/registry";
|
||||
import {StateSelectionField} from "@web/views/fields/state_selection/state_selection_field";
|
||||
|
||||
import {STATUS_COLORS, STATUS_COLOR_PREFIX} from "../../utils/server_utils.esm";
|
||||
|
||||
export class ServerStatusField extends StateSelectionField {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.colorPrefix = STATUS_COLOR_PREFIX;
|
||||
this.colors = STATUS_COLORS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get options() {
|
||||
return [[false, "Undefined"], ...super.options];
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get showLabel() {
|
||||
return !this.props.hideLabel;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("server_status", ServerStatusField);
|
||||
@@ -0,0 +1,33 @@
|
||||
.o_server_status_bubble {
|
||||
@extend .o_status;
|
||||
|
||||
&.o_color_server_status_bubble_info {
|
||||
background-color: $o-info;
|
||||
}
|
||||
&.o_color_server_status_bubble_success {
|
||||
background-color: $o-success;
|
||||
}
|
||||
&.o_color_server_status_bubble_danger {
|
||||
background-color: $o-danger;
|
||||
}
|
||||
&.o_color_server_status_bubble_warning {
|
||||
background-color: $o-warning;
|
||||
}
|
||||
}
|
||||
.o_field_server_status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
margin: 0px 16px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #e5e5e5;
|
||||
width: fit-content !important;
|
||||
|
||||
.o_status_label {
|
||||
color: #4c4c4c;
|
||||
font-size: 14px;
|
||||
margin-left: 0.5rem !important;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||