diff --git a/addons/cetmix_tower/README.rst b/addons/cetmix_tower/README.rst deleted file mode 100644 index 3a28d98..0000000 --- a/addons/cetmix_tower/README.rst +++ /dev/null @@ -1,60 +0,0 @@ -============ -Cetmix Tower -============ - -.. - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! This file is generated by oca-gen-addon-readme !! - !! changes will be overwritten. !! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:ec1914ccdcdfd8bfa539b1c131e8b2c41946b9bf978d2a32d13ee89ab655cd65 - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -.. |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 - :alt: cetmix/cetmix-tower - -|badge1| |badge2| |badge3| - -This is a technical module that allows to get `Cetmix -Tower `__ modules from the `Odoo App -Store `__. - -It's designed to install all the `Cetmix -Tower `__ modules at once. - -**Table of contents** - -.. contents:: - :local: - -Bug Tracker -=========== - -Bugs are tracked on `GitHub 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 `_. - -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 `_ project on GitHub. - -You are welcome to contribute. diff --git a/addons/cetmix_tower/__init__.py b/addons/cetmix_tower/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/addons/cetmix_tower/__manifest__.py b/addons/cetmix_tower/__manifest__.py deleted file mode 100644 index e6adceb..0000000 --- a/addons/cetmix_tower/__manifest__.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright Cetmix OU -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -{ - "name": "Cetmix Tower", - "summary": "Odoo SAAS Server Application Management", - "version": "16.0.2.1.0", - "development_status": "Beta", - "category": "Productivity", - "website": "https://tower.cetmix.com", - "live_test_url": "https://tower.cetmix.com/download", - "images": ["static/description/banner.png"], - "author": "Cetmix", - "license": "AGPL-3", - "application": True, - "installable": True, - "depends": [ - "cetmix_tower_server", - "cetmix_tower_server_queue", - "cetmix_tower_git", - "cetmix_tower_webhook", - ], -} diff --git a/addons/cetmix_tower/i18n/cetmix_tower.pot b/addons/cetmix_tower/i18n/cetmix_tower.pot deleted file mode 100644 index 78d58d5..0000000 --- a/addons/cetmix_tower/i18n/cetmix_tower.pot +++ /dev/null @@ -1,13 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"Last-Translator: \n" -"Language-Team: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Plural-Forms: \n" diff --git a/addons/cetmix_tower/pyproject.toml b/addons/cetmix_tower/pyproject.toml deleted file mode 100644 index 4231d0c..0000000 --- a/addons/cetmix_tower/pyproject.toml +++ /dev/null @@ -1,3 +0,0 @@ -[build-system] -requires = ["whool"] -build-backend = "whool.buildapi" diff --git a/addons/cetmix_tower/readme/DESCRIPTION.md b/addons/cetmix_tower/readme/DESCRIPTION.md deleted file mode 100644 index 91f80f2..0000000 --- a/addons/cetmix_tower/readme/DESCRIPTION.md +++ /dev/null @@ -1,3 +0,0 @@ -This is a technical module that allows to get [Cetmix Tower](https://cetmix.com/tower) modules from the [Odoo App Store](https://apps.odoo.com). - -It's designed to install all the [Cetmix Tower](https://cetmix.com/tower) modules at once. diff --git a/addons/cetmix_tower/static/description/banner.png b/addons/cetmix_tower/static/description/banner.png deleted file mode 100644 index 0f3656f..0000000 Binary files a/addons/cetmix_tower/static/description/banner.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/cetmix.svg b/addons/cetmix_tower/static/description/cetmix.svg deleted file mode 100644 index 10c77f4..0000000 --- a/addons/cetmix_tower/static/description/cetmix.svg +++ /dev/null @@ -1,435 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/cetmix_tower/static/description/cetmix_tower_server_kanban.png b/addons/cetmix_tower/static/description/cetmix_tower_server_kanban.png deleted file mode 100644 index e4d66b9..0000000 Binary files a/addons/cetmix_tower/static/description/cetmix_tower_server_kanban.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/commands_1.png b/addons/cetmix_tower/static/description/commands_1.png deleted file mode 100644 index f282a3a..0000000 Binary files a/addons/cetmix_tower/static/description/commands_1.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/commands_2.png b/addons/cetmix_tower/static/description/commands_2.png deleted file mode 100644 index ebb3755..0000000 Binary files a/addons/cetmix_tower/static/description/commands_2.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/connectivity.svg b/addons/cetmix_tower/static/description/connectivity.svg deleted file mode 100644 index f8b5593..0000000 --- a/addons/cetmix_tower/static/description/connectivity.svg +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/cetmix_tower/static/description/cx_logo.svg b/addons/cetmix_tower/static/description/cx_logo.svg deleted file mode 100644 index c7a45e6..0000000 --- a/addons/cetmix_tower/static/description/cx_logo.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - diff --git a/addons/cetmix_tower/static/description/development.svg b/addons/cetmix_tower/static/description/development.svg deleted file mode 100644 index d9d96db..0000000 --- a/addons/cetmix_tower/static/description/development.svg +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/cetmix_tower/static/description/files.svg b/addons/cetmix_tower/static/description/files.svg deleted file mode 100644 index fa3d813..0000000 --- a/addons/cetmix_tower/static/description/files.svg +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/cetmix_tower/static/description/files_1.png b/addons/cetmix_tower/static/description/files_1.png deleted file mode 100644 index b82fba5..0000000 Binary files a/addons/cetmix_tower/static/description/files_1.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/files_2.png b/addons/cetmix_tower/static/description/files_2.png deleted file mode 100644 index ef85bc8..0000000 Binary files a/addons/cetmix_tower/static/description/files_2.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/files_3.png b/addons/cetmix_tower/static/description/files_3.png deleted file mode 100644 index 05d424d..0000000 Binary files a/addons/cetmix_tower/static/description/files_3.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/flight-plan.svg b/addons/cetmix_tower/static/description/flight-plan.svg deleted file mode 100644 index dc7958a..0000000 --- a/addons/cetmix_tower/static/description/flight-plan.svg +++ /dev/null @@ -1,380 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/cetmix_tower/static/description/flight_plan_1.png b/addons/cetmix_tower/static/description/flight_plan_1.png deleted file mode 100644 index 705a9e3..0000000 Binary files a/addons/cetmix_tower/static/description/flight_plan_1.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/flight_plan_2.png b/addons/cetmix_tower/static/description/flight_plan_2.png deleted file mode 100644 index 41ecee1..0000000 Binary files a/addons/cetmix_tower/static/description/flight_plan_2.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/flight_plan_3.png b/addons/cetmix_tower/static/description/flight_plan_3.png deleted file mode 100644 index 9f3ea19..0000000 Binary files a/addons/cetmix_tower/static/description/flight_plan_3.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/git.svg b/addons/cetmix_tower/static/description/git.svg deleted file mode 100644 index 8db04a6..0000000 --- a/addons/cetmix_tower/static/description/git.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/cetmix_tower/static/description/git_1.png b/addons/cetmix_tower/static/description/git_1.png deleted file mode 100644 index 1b74ef2..0000000 Binary files a/addons/cetmix_tower/static/description/git_1.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/git_2.png b/addons/cetmix_tower/static/description/git_2.png deleted file mode 100644 index 7dbc3e1..0000000 Binary files a/addons/cetmix_tower/static/description/git_2.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/icon.png b/addons/cetmix_tower/static/description/icon.png deleted file mode 100644 index 2507f55..0000000 Binary files a/addons/cetmix_tower/static/description/icon.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/index.html b/addons/cetmix_tower/static/description/index.html deleted file mode 100644 index d5ae010..0000000 --- a/addons/cetmix_tower/static/description/index.html +++ /dev/null @@ -1,690 +0,0 @@ -
-
-
-
-
-
-

- | What is Cetmix Tower? -
-

-

- Cetmix Tower is an open-source DevOps framework built on Odoo.

- It empowers you to deploy, manage, and automate applications directly from Odoo whether it's Odoo itself, WordPress, ERPNext or Magento.

- Unlike traditional hosting platforms, Cetmix Tower is not tied to any specific technology. You can use it with Docker, Kubernetes, a bare operating system or whatever your infrastructure requires.

- Fully self-hosted and deeply integrated with the Odoo ecosystem, it gives you complete control over your servers and applications.

- Cetmix Tower is distributed under the AGPL-3 license, ensuring transparency and freedom. -

-
-
- -
-
-
-
- -
-
-
-
-

| Key Differences

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Cetmix TowerOdoo.shOther Odoo HostingGeneric DevOps Tools
Can Deploy Odoo Enterprise
Can Deploy Odoo Community
Can Deploy Other Software besides Odoo
Runs on Your Own ServerSome*
Native Odoo App
Export/Import & Share ConfigurationsSome*
Is Open SourceSome*
-

* depends on the tool

-
-
-

| Why Cetmix Tower?

-
    -
  • Easy to use for non-technical users: Deploy a server or run a multi-step scenario with a single click in the UI.
  • -
  • Power and flexibility for technical users: Leverage powerful features to build complex workflows and automate server and application management tasks.
  • -
  • Not limited to a single technology: Run any software manageable via shell commands or API, whether you're using Docker, Kubernetes, or direct OS commands.
  • -
  • Odoo Integration: Take advantage of the Odoo ecosystem for server management tasks. Integrate with Sales, Accounting, Subscriptions, Helpdesk, or any other Odoo module.
  • -
  • Not limited to Odoo: While optimized for Odoo, Cetmix Tower can manage virtually any software.
  • -
  • Extensibility: Develop your own Odoo modules based on Cetmix Tower to implement custom features.
  • -
  • Self-Hosting: Deploy Cetmix Tower on your own infrastructure for complete control over your server and application management.
  • -
  • Open Source: Cetmix Tower is distributed under the AGPL-3 license, ensuring transparency and freedom.
  • -
-
-
-
-

| Core Features

-
-
-

| Note

-

Cetmix Tower is designed with usability and simplicity in mind, though some features might require a foundational understanding of server management principles

-

- For documentation please check | tower.cetmix.com -

-
-
-
-
-

Server Management

-
    -
  • Variable-based flexible configuration
  • -
  • Create Servers using pre-defined Server Templates
  • -
- -
-
-

Connectivity

-
    -
  • Password and key-based authentication for outgoing SSH connections
  • -
  • Built-in support of the Python requests library for outgoing API calls
  • -
- -
-
-

Commands

-
    -
  • Execute SSH Commands on remote servers
  • -
  • Run Python Commands on the Tower Odoo server
  • -
  • Run Flight Plan from command
  • -
  • Render commands using Variables
  • -
  • Secrets/Keys for private data storage
  • -
- -
-
-

Flight Plans

-
    -
  • Execute multiple Commands in a row
  • -
  • Condition-based flow:
  • -
      -
    • Based on conditions using Python syntax
    • -
    • Based on the previous command exit code
    • -
    -
- -
-
-

Files

-
    -
  • Download Files from a remote server using SFTP
  • -
  • Upload Files to a remote server using SFTP
  • -
  • Support for text and binary file formats
  • -
  • Manage Files using pre-defined File Templates
  • -
- -
-
-

Server Logs

-
    -
  • Fetch Logs from a remote server using Commands
  • -
  • Fetch Logs from a remote server using Files
  • -
- -
- -
-

Import/Export Data in YAML Format

-
    -
  • Share and manage data easily using YAML format
  • -
- -
-
-

Manage Git Projects

-
    -
  • - Manage Git projects directly from Odoo -
  • -
  • - User-friendly interface for controlling git repositories linked to your servers -
  • -
- -
-
-
-
-
-
- -
-

| Warning

-

- The software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. - In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the software. -

-
-
-
-
-   -
-
-
-
-

License

-

Cetmix Tower is distributed under the AGPL-3 license. In case you want to use this software in projects that are not compatible with AGPL-3 license, you should contact us directly.

-
-
-
-
-   -
-
-
-
-

Copyright

-

Cetmix Tower is a trademark of Cetmix. All rights reserved.

-
-
-
-
-   -
-
-
-
-

Support

- -
    -
  • This project is open source. All issues and feature requests should be reported in the GitHub repository
  • -
  • Your contribution is welcome. Please refer to the CONTRIBUTING.md file for more details.
  • -
  • Dedicated support is available on request. Contact us for details at cetmix.com
  • -
- -
- -
-
-
-   -
-
-
-
-
-
- -
-
-

- Cetmix is not just another IT company -

-
- We know how the business works. Our experts have management - experience in heavy machinery, energy sector, logistics, - accounting, public services and many other industries _ -
-
- We are the people of business -
-
-
-
-
-
-
-   -
-
-
-
-
-
-

Solutions for your business

-
Choose an existing one or we can develop a custom one for you
-
-
-

- Check our Apps -

-
-
-
-
-
-
-
-
-

- >9
years
-

-
of Odoo experience -
-
-
-

- > 15 000
downloads
-

-
of our apps from Odoo App Store -
-
-
-

- >100
clients
-

-
- are happy with our services -
-
-
-
-
-
-
-   -
-
-
-
-

- Need support, customization
or interested in collaboration ? -

-
- | cetmix.com -
-
-

- - https://www.linkedin.com/company/cetmix/ -

-

- - https://github.com/cetmix/ -

-

- - https://www.facebook.com//cetmixteam -

-

- - https://twitter.com/cetmix_team -

-
-
-
- - - diff --git a/addons/cetmix_tower/static/description/logs_1.png b/addons/cetmix_tower/static/description/logs_1.png deleted file mode 100644 index 079879f..0000000 Binary files a/addons/cetmix_tower/static/description/logs_1.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/logs_2.png b/addons/cetmix_tower/static/description/logs_2.png deleted file mode 100644 index 22f65c6..0000000 Binary files a/addons/cetmix_tower/static/description/logs_2.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/self-host.svg b/addons/cetmix_tower/static/description/self-host.svg deleted file mode 100644 index 389d08b..0000000 --- a/addons/cetmix_tower/static/description/self-host.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/addons/cetmix_tower/static/description/server-logs.svg b/addons/cetmix_tower/static/description/server-logs.svg deleted file mode 100644 index 8114b55..0000000 --- a/addons/cetmix_tower/static/description/server-logs.svg +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/cetmix_tower/static/description/server_1.png b/addons/cetmix_tower/static/description/server_1.png deleted file mode 100644 index 2631e1d..0000000 Binary files a/addons/cetmix_tower/static/description/server_1.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/server_3.png b/addons/cetmix_tower/static/description/server_3.png deleted file mode 100644 index 645da0c..0000000 Binary files a/addons/cetmix_tower/static/description/server_3.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/server_form_configuration.png b/addons/cetmix_tower/static/description/server_form_configuration.png deleted file mode 100644 index f27cb0c..0000000 Binary files a/addons/cetmix_tower/static/description/server_form_configuration.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/server_template_form.png b/addons/cetmix_tower/static/description/server_template_form.png deleted file mode 100644 index 630703a..0000000 Binary files a/addons/cetmix_tower/static/description/server_template_form.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/tools.svg b/addons/cetmix_tower/static/description/tools.svg deleted file mode 100644 index 1b80e51..0000000 --- a/addons/cetmix_tower/static/description/tools.svg +++ /dev/null @@ -1,159 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/cetmix_tower/static/description/tower.svg b/addons/cetmix_tower/static/description/tower.svg deleted file mode 100644 index f6dce21..0000000 --- a/addons/cetmix_tower/static/description/tower.svg +++ /dev/null @@ -1,8709 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/cetmix_tower/static/description/tower_website.svg b/addons/cetmix_tower/static/description/tower_website.svg deleted file mode 100644 index 7871baf..0000000 --- a/addons/cetmix_tower/static/description/tower_website.svg +++ /dev/null @@ -1,3201 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/cetmix_tower/static/description/yaml_1.png b/addons/cetmix_tower/static/description/yaml_1.png deleted file mode 100644 index 5339b66..0000000 Binary files a/addons/cetmix_tower/static/description/yaml_1.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/yaml_2.png b/addons/cetmix_tower/static/description/yaml_2.png deleted file mode 100644 index b006844..0000000 Binary files a/addons/cetmix_tower/static/description/yaml_2.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/yaml_3.png b/addons/cetmix_tower/static/description/yaml_3.png deleted file mode 100644 index 6fa6c9c..0000000 Binary files a/addons/cetmix_tower/static/description/yaml_3.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/yaml_4.png b/addons/cetmix_tower/static/description/yaml_4.png deleted file mode 100644 index 68140df..0000000 Binary files a/addons/cetmix_tower/static/description/yaml_4.png and /dev/null differ diff --git a/addons/cetmix_tower/static/description/yaml_5.png b/addons/cetmix_tower/static/description/yaml_5.png deleted file mode 100644 index a8187be..0000000 Binary files a/addons/cetmix_tower/static/description/yaml_5.png and /dev/null differ diff --git a/addons/cetmix_tower_git/README.rst b/addons/cetmix_tower_git/README.rst deleted file mode 100644 index 2d256b9..0000000 --- a/addons/cetmix_tower_git/README.rst +++ /dev/null @@ -1,137 +0,0 @@ -================ -Cetmix Tower Git -================ - -.. - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! This file is generated by oca-gen-addon-readme !! - !! changes will be overwritten. !! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:d670bc1d39970be6cf2add2c082166db8a21d7b2df9827fc6ce2f0a260161e6a - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png - :target: https://odoo-community.org/page/development-status - :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png - :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html - :alt: License: AGPL-3 -.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github - :target: https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_git - :alt: cetmix/cetmix-tower - -|badge1| |badge2| |badge3| - -This module implements Git Management functionality for `Cetmix -Tower `__. - -Please refer to the `official -documentation `__ for detailed information. - -**Table of contents** - -.. contents:: - :local: - -Configuration -============= - -Please refer to the `official -documentation `__ for detailed configuration -instructions. - -Usage -===== - -Please refer to the `official -documentation `__ for detailed usage -instructions. - -Changelog -========= - -16.0.2.0.4 (2026-02-14) ------------------------ - -- Bugfixes: Link server to git project only once. (5214) - -16.0.2.0.3 (2026-01-11) ------------------------ - -- Features: Provide git project name using the ``__git_project__`` - custom value when creating a project in flight plan. Improve the UI - and UX of Git Projects. (5197) - -16.0.2.0.1 (2025-12-11) ------------------------ - -- Features: Improve search views, implement the search panel for - selected views. (5139) - -16.0.2.0.0 (2025-10-27) ------------------------ - -- Features: Major refactoring: implement Git repository entity. (4914) - -16.0.1.0.6 (2025-08-18) ------------------------ - -- Features: Link or copy a git project when uploading the linked file - using command (4759) - -16.0.1.0.5 (2025-08-17) ------------------------ - -- Features: Search servers by git reference (4838) - -16.0.1.0.4 (2025-07-29) ------------------------ - -- Features: Export related commands and flight plans together with - server (4849) - -16.0.1.0.3 (2025-05-23) ------------------------ - -- Bugfixes: Duplicated file is created when importing a YAML file with a - git project. (4715) - -16.0.1.0.2 (2025-05-16) ------------------------ - -- Features: Record references for git relations. (4670) - -16.0.1.0.1 (2025-05-09) ------------------------ - -- Bugfixes: Non-critical issues and performance improvements. (4663) - -16.0.1.0.0 ----------- - -Release for Odoo 16.0 - -Bug Tracker -=========== - -Bugs are tracked on `GitHub Issues `_. -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 `_. - -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 `_ project on GitHub. - -You are welcome to contribute. diff --git a/addons/cetmix_tower_git/__init__.py b/addons/cetmix_tower_git/__init__.py deleted file mode 100644 index 0650744..0000000 --- a/addons/cetmix_tower_git/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import models diff --git a/addons/cetmix_tower_git/__manifest__.py b/addons/cetmix_tower_git/__manifest__.py deleted file mode 100644 index c96d5d6..0000000 --- a/addons/cetmix_tower_git/__manifest__.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright Cetmix OU -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -{ - "name": "Cetmix Tower Git", - "summary": "Cetmix Tower Git Management Tools", - "version": "16.0.2.0.4", - "development_status": "Beta", - "category": "Productivity", - "website": "https://tower.cetmix.com", - "author": "Cetmix", - "license": "AGPL-3", - "application": False, - "depends": ["cetmix_tower_yaml"], - "external_dependencies": { - "python": ["giturlparse==0.12.0"], - }, - "data": [ - "security/ir.model.access.csv", - "security/cx_tower_git_project_security.xml", - "security/cx_tower_git_source_security.xml", - "security/cx_tower_git_remote_security.xml", - "security/cx_tower_git_repo_security.xml", - "security/cx_tower_git_repo_owner_security.xml", - "security/cx_tower_git_project_rel_security.xml", - "security/cx_tower_git_project_file_template_rel_security.xml", - "views/cx_tower_git_project_views.xml", - "views/cx_tower_git_source_views.xml", - "views/cx_tower_git_remote_views.xml", - "views/cx_tower_git_repo_views.xml", - "views/cx_tower_git_repo_owner_views.xml", - "views/cx_tower_file_views.xml", - "views/cx_tower_file_template_views.xml", - "views/cx_tower_server_view.xml", - "views/cx_tower_plan_line_view.xml", - "views/menuitems.xml", - ], - "demo": [ - "demo/demo_data.xml", - ], -} diff --git a/addons/cetmix_tower_git/demo/demo_data.xml b/addons/cetmix_tower_git/demo/demo_data.xml deleted file mode 100644 index 9f15679..0000000 --- a/addons/cetmix_tower_git/demo/demo_data.xml +++ /dev/null @@ -1,166 +0,0 @@ - - - - - - Demo Git Project - demo_git_project - This is a demo git project. - - - - https://github.com/cetmix-demo/cetmix-tower-demo.git - - - https://github.com/oca-demo/web-demo.git - - - https://github.com/odoo-demo/enterprise-demo.git - - - - https://gitlab.com/cetmix-demo/cetmix-tower-demo.git - - - - https://bitbucket.com/cetmix-demo/cetmix-tower-demo-enterprise.git - - - - - - Cetmix Tower - cetmix_tower - - - - - - - branch - 14.0 - - - - - pr - 176 - - - - OCA Web - oca_web - - - - - - - branch - 14.0 - - - - Odoo Enterprise (Private) - odoo_enterprise - - - - - - - branch - 19.0 - - - - - Sample Semi Private Gitlab - gitlab_private - - - - - - - branch - main - - - - - pr - 1234 - - - - Sample Private Bitbucket - bitbucket_private - - - - - - - branch - dev - - - - - commit - 1234567890 - - - - - repos.yaml - - tower - text - {{ instance_name }}/config - - - - - - - - git_aggregator - - - - - Demo Git URL - demo_git_url - - - - - Parse Git URL - python_code - -if {{ demo_git_url }}: - parsed_url = giturlparse.parse({{ demo_git_url }}) - repo = parsed_url.repo - owner = parsed_url.owner - host = parsed_url.host - platform = parsed_url.platform - message = "Repo: " + repo + ", Owner: " + owner + ", Host: " + host + ", Platform: " + platform - result={"exit_code": 0, "message": message} -else: - result={"exit_code": -100, "message": "Git URL is not defined!"} - - 1 - - Run Python Code: Check Branch - - diff --git a/addons/cetmix_tower_git/i18n/cetmix_tower_git.pot b/addons/cetmix_tower_git/i18n/cetmix_tower_git.pot deleted file mode 100644 index 7b4a829..0000000 --- a/addons/cetmix_tower_git/i18n/cetmix_tower_git.pot +++ /dev/null @@ -1,1056 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * cetmix_tower_git -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"Last-Translator: \n" -"Language-Team: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Plural-Forms: \n" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0 -#, python-format -msgid "" -"\n" -"# You need to set the following variables in your environment:\n" -"# %(vars)s\n" -"# and run git-aggregator with '--expand-env' parameter.\n" -msgstr "" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0 -#, python-format -msgid "" -"# This file is generated with Cetmix Tower https://cetmix.com/tower\n" -"# It's designed to be used with git-aggregator tool developed by Acsone.\n" -"# Documentation for git-aggregator: https://github.com/acsone/git-aggregator\n" -msgstr "" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0 -#, python-format -msgid "%(name)s (copy)" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "* Sources where all remotes are private" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "* Sources where some remotes are private" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "..to be autogenerated" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"Managers. All users who have \"Manager\" group and are set as \"Managers\" in all related servers.\n" -" This is done to avoid unpredictable consequences when some of the servers are not updated due to access restrictions when a project is updated." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"Users. All users who have \"Manager\" group and are either set in " -"\"Users\" or in \"Managers\" in all related servers." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.constraint,message:cetmix_tower_git.constraint_cx_tower_git_repo_unique_repo_host_owner -msgid "A repository with the same name, host, and owner already exists." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Access" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__active -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__active -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__active -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__active -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_search -msgid "Active" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_search -msgid "Archived" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__auto_sync -msgid "Auto Sync" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__branch -msgid "Branch" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -msgid "Branch/PR/commit number or link" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project_rel__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo_owner__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__reference -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_owner_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -msgid "" -"Can contain English letters, digits and '_'. Leave blank to autogenerate" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_command -msgid "Cetmix Tower Command" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_file -msgid "Cetmix Tower File" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_file_template -msgid "Cetmix Tower File Template" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_plan_line -msgid "Cetmix Tower Flight Plan Line" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project -msgid "Cetmix Tower Git Project" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project_file_template_rel -msgid "Cetmix Tower Git Project relation to File Templates" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project_rel -msgid "Cetmix Tower Git Project relation to Files and Servers" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_remote -msgid "Cetmix Tower Git Remote" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_repo -msgid "Cetmix Tower Git Repository" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_repo_owner -msgid "Cetmix Tower Git Repository Owner" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_source -msgid "Cetmix Tower Git Source" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cetmix_tower -msgid "Cetmix Tower Odoo Automation" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_server -msgid "Cetmix Tower Server" -msgstr "" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_project_file_template_rel.py:0 -#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0 -#, python-format -msgid "Code generator function for '%(project_format)s' format not found." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__commit -msgid "Commit" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_server_view_form -msgid "Configure" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_plan_line__is_make_copy -msgid "" -"Create a copy of the Git Project instead of linking the file to the existing" -" one." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_remote -msgid "Create your first git remote!" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_repo_owner -msgid "Create your first repository owner!" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_repo -msgid "Create your first repository!" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_uid -msgid "Created by" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_date -msgid "Created on" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__secret_id -msgid "Custom secret used for this repository" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo_owner__secret_id -msgid "Custom secret used for this repository owner" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Disabled" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__display_name -msgid "Display Name" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__url -msgid "Displayed in 'https' format, but can be entered in any format" -msgstr "" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_source.py:0 -#, python-format -msgid "Empty Source" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__enabled -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__enabled -msgid "Enable in configuration and exported to files" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__enabled -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__enabled -msgid "Enabled" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Export YAML" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__file_id -msgid "File" -msgstr "" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0 -#, python-format -msgid "File '%(file)s' doesn't belong to server '%(server)s'" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__file_template_id -msgid "File Template" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__file_template_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "File Templates" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.constraint,message:cetmix_tower_git.constraint_cx_tower_git_project_rel_project_server_file_format_uniq -msgid "File is already related to the same project and format" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.constraint,message:cetmix_tower_git.constraint_cx_tower_git_project_file_template_rel_project_server_file_format_uniq -msgid "File template is already related to the same project and format" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__file_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Files" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__project_format -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__project_format -msgid "Format" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__git -msgid "GIT" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__url_git -msgid "GIT URL" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__url_git -msgid "GIT URL of the repository" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "General" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__url -msgid "Generic URL" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Git Aggregator" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir -msgid "Git Aggregator Root Dir" -msgstr "" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "" -"Git Aggregator: Bitbucket does not support fetching PRs. Please use branch instead.\n" -"\n" -"Source: %(src)s\n" -"URL: %(url)s\n" -"Head: %(head)s" -msgstr "" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Git Aggregator: Head number is empty in %(head)s" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__git_project_id -msgid "Git Configuration" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file_template__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__git_project_ids -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_plan_line__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_ids -msgid "Git Project" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__git_project_count -msgid "Git Project Count" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_project_file_template_rel_ids -msgid "Git Project File Template Relations" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_rel_ids -msgid "Git Project Rel" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_rel_ids -msgid "Git Project Relations" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_project_rel_ids -msgid "Git Project Server File Relations" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.actions.act_window,name:cetmix_tower_git.cx_tower_git_project_action -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file_template__git_project_ids -#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_project -#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_project_settings -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_server_view_form -msgid "Git Projects" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.actions.act_window,name:cetmix_tower_git.action_cx_tower_git_remote -msgid "Git Remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir -msgid "" -"Git aggregator root directory where sources will be cloned. Eg '/tmp/git-" -"aggregator' Will use '.' if not set" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"Git aggregator root directory where sources will be cloned. Leave blank to " -"use '.'" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__head -msgid "Git remote head. Link to branch, PR, commit or commit hash." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_remote -msgid "" -"Git remotes represent branches, pull requests, or commits from git " -"repositories." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -msgid "GitProjects" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Group By" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__https -msgid "HTTPS" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes -msgid "Has Partially Private Remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes -msgid "Has Private Remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_search -msgid "Has Servers" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head -msgid "Head" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head_type -msgid "Head Type" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__host -msgid "Host" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__id -msgid "ID" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project_rel__auto_sync -msgid "If enabled file will be synced automatically using cron" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__repo_id -msgid "" -"If selected, the remote URL will be filled from the repo settings based on " -"the remote protocol" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes -msgid "Indicates if the project has any partially private remotes." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes -msgid "Indicates if the project has any private remotes." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__active -msgid "Indicates if the repository is active" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__is_private -msgid "Indicates if the repository is private" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source____last_update -msgid "Last Modified on" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_uid -msgid "Last Updated by" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_date -msgid "Last Updated on" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_plan_line__is_make_copy -msgid "Make a Copy" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__manager_ids -msgid "Managers" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__manager_ids -msgid "Managers who can modify this record" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__name -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Name" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo_owner__name -msgid "Name of the repository owner (e.g., 'cetmix', 'OCA')" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Name. Leave blank to autogenerate" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_search -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Name/Reference" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_search -msgid "No Servers" -msgstr "" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_repo.py:0 -#, python-format -msgid "Not a valid repository URL!" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__note -msgid "Note" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__git_project_count -msgid "Number of projects this repository is used in" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__remote_count -msgid "Number of remotes this repository is used in" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Open File Template" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_server_view_form -msgid "Open Git Project" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Open Server" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Open file template" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Org" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__owner_id -msgid "Owner" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__is_private -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__is_private -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Private" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count_private -msgid "Private Remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -msgid "Projects" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__git_project_ids -msgid "Projects this repository is used in" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url_protocol -msgid "Protocol" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__repo_provider -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__provider -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Provider" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Provider: Other" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Public" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__pr -msgid "Pull/Merge Request" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Put your notes here..." -msgstr "" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_command.py:0 -#, python-format -msgid "" -"Python library for Git URL parsing. Available methods: 'parse', 'validate'." -" Documentation on GitHub." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__reference -msgid "Reference" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "" -"Reference. Can contain English letters, digits and '_'. Leave blank to " -"autogenerate" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__remote_ids -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_ids -msgid "Remote" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__remote_count -msgid "Remote Count" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__remote_ids -msgid "Remotes that use this repository" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Repos" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.actions.act_window,name:cetmix_tower_git.action_cx_tower_git_repo -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__repo_ids -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__repo_ids -#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_repositories -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_owner_view_form -msgid "Repositories" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo_owner__repo_ids -msgid "Repositories owned by this organization/user" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_repo -msgid "" -"Repositories represent git repositories with their metadata and configuration.\n" -" They can be linked to remotes to automatically populate URL information." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__repo_ids -msgid "Repositories used in this project through its sources and remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__repo_id -msgid "Repository" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__repo -msgid "Repository Name" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.actions.act_window,name:cetmix_tower_git.action_cx_tower_git_repo_owner -#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_repository_owners -msgid "Repository Owners" -msgstr "" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Repository URL is not set" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__host -msgid "Repository host (e.g., 'github.com', 'gitlab.com')" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__is_private -msgid "Repository is private" -msgstr "" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Repository is required" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__repo -msgid "Repository name (e.g., 'cetmix-tower', 'odoo')" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__owner_id -msgid "Repository owner (e.g., 'cetmix' or 'OCA')" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_repo_owner -msgid "" -"Repository owners represent organizations or users that own git repositories.\n" -" Examples include \"cetmix\", \"OCA\", etc." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__repo_provider -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__provider -msgid "Repository provider to determine provider-based behaviour" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Root Directory" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__ssh -msgid "SSH" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__url_ssh -msgid "SSH URL" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__url_ssh -msgid "SSH URL of the repository" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__secret_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__secret_id -msgid "Secret" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_plan_line__git_project_id -msgid "Select a git project to be linked to the file and server." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__sequence -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__sequence -msgid "Sequence" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__server_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__server_ids -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__server_id -msgid "Server" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__server_ids -msgid "" -"Servers are added automatically based on the files linked to the project." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__source_id -msgid "Source" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__source_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Sources" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "" -"The top one remote will be used as a merge target.\n" -" You can re-arrange remotes by dragging them or changing their sequence value." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__user_ids -msgid "Users" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__user_ids -msgid "Users who can view this record" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "YAML" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__yaml_code -msgid "Yaml Code" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_plan_line_view_form -msgid "" -"You can also provide a Git Project reference using the __git_project__ variable in the flight plan custom values.
\n" -" Python command code example:\n" -" \n" -"custom_values['__git_project__'] = 'my_git_project'\n" -" \n" -"
\n" -" Important: if defined, this variable value overrides the Git Project selected in the form." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"You can edit these fields at your own risk. However keep in mind that they " -"will be automatically updated each time related servers are added, removed " -"or updated." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "You must be a member of the \"YAML/Export\" group to export data as YAML." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_owner_view_form -msgid "e.g., Cetmix, OCA" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_owner_view_form -msgid "e.g., cetmix, oca" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -msgid "e.g., cetmix-tower, odoo" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -msgid "https, ssh or git formats are accepted" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "managers who can modify this record" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_tree -msgid "select or enter a link" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "users who can view this record" -msgstr "" diff --git a/addons/cetmix_tower_git/i18n/fi.po b/addons/cetmix_tower_git/i18n/fi.po deleted file mode 100644 index 0cd54fa..0000000 --- a/addons/cetmix_tower_git/i18n/fi.po +++ /dev/null @@ -1,595 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * cetmix_tower_git -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: fi\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0 -#, python-format -msgid "" -"\n" -"# You need to set the following variables in your environment:\n" -"# %(vars)s \n" -"# and run git-aggregator with '--expand-env' parameter.\n" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0 -#, python-format -msgid "" -"# This file is generated with Cetmix Tower https://cetmix.com/tower\n" -"# It's designed to be used with git-aggregator tool developed by Acsone.\n" -"# Documentation for git-aggregator: https://github.com/acsone/git-aggregator\n" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "* Sources where all remotes are private" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "* Sources where some remotes are private" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"Managers. All users who have \"Manager\" group and are set as \"Managers\" in all related servers.\n" -" This is done to avoid unpredictable consequences when some of the servers are not updated due to access restrictions when a project is updated." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"Users. All users who have \"Manager\" group and are either set in " -"\"Users\" or in \"Managers\" in all related servers." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Access" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__active -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__active -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__active -msgid "Active" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Archived" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__bitbucket -msgid "Bitbucket" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__branch -msgid "Branch" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -msgid "Branch/PR/commit number or link" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__reference -msgid "" -"Can contain English letters, digits and '_'. Leave blank to autogenerate" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project -msgid "Cetmix Tower Git Configuration" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_remote -msgid "Cetmix Tower Git Remote" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_source -msgid "Cetmix Tower Git Source" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_server -msgid "Cetmix Tower Server" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0 -#, python-format -msgid "Code generator function for '%(project_format)s' format not found." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__commit -msgid "Commit" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_uid -msgid "Created by" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_date -msgid "Created on" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_file -msgid "Cx Tower File" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Disabled" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__display_name -msgid "Display Name" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__enabled -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__enabled -msgid "Enable in configuration and exported to files" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__enabled -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__enabled -msgid "Enabled" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Export YAML" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__file_id -msgid "File" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0 -#, python-format -msgid "File '%(file)s' doesn't belong to server '%(server)s'" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_file.py:0 -#, python-format -msgid "" -"File '%(file)s' is related to multiple projects: %(projects)s \n" -"Please select only one project." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.constraint,message:cetmix_tower_git.constraint_cx_tower_git_project_rel_project_server_file_format_uniq -msgid "File is already related to the same project and format" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__file_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Files" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__project_format -msgid "Format" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir -msgid "Git Aggregator Root Dir" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "" -"Git Aggregator: Bitbucket does not support fetching PRs. Please use branch instead.\n" -"\n" -"Source: %(src)s\n" -"URL: %(url)s\n" -"Head: %(head)s" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Git Aggregator: Head number is empty in %(head)s" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__git_project_id -msgid "Git Configuration" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_ids -msgid "Git Project" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_rel_ids -msgid "Git Project Rel" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_project_rel_ids -msgid "Git Project Server File Relations" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project_rel -msgid "Git Project relation to other model records" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.actions.act_window,name:cetmix_tower_git.cx_tower_git_project_action -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_ids -#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_project -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_server_view_form -msgid "Git Projects" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir -msgid "" -"Git aggregator root directory where sources will be cloned. Eg '/tmp/git-" -"aggregator' Will use '.' if not set" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"Git aggregator root directory where sources will be cloned. Leave blank to " -"use '.'" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__url -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -msgid "" -"Git remote URL. Eg 'https://github.com/cetmix/cetmix-tower.git' or " -"'git@github.com:cetmix/cetmix-tower.git'" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__head -msgid "" -"Git remote head. Link to branch, PR, commit or commit hash. Leave blank to " -"auto-detect" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__github -msgid "GitHub" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__gitlab -msgid "GitLab" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__https -msgid "HTTPS" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes -msgid "Has Partially Private Remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes -msgid "Has Private Remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head -msgid "Head" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head_type -msgid "Head Type" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__id -msgid "ID" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes -msgid "Indicates if the project has any partially private remotes." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes -msgid "Indicates if the project has any private remotes." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__is_private -msgid "Is Private" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server____last_update -msgid "Last Modified on" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_uid -msgid "Last Updated by" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_date -msgid "Last Updated on" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__manager_ids -msgid "Managers" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__manager_ids -msgid "Managers who can modify this record" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__name -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Name" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Not a valid URL. URL must end with '.git'" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Not a valid URL. URL must start with 'https://' or 'git@'" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "" -"Not a valid URL: %(url_msg)s\n" -"URL must contain at least two parts separated by dot." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__other -msgid "Other" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Private" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count_private -msgid "Private Remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__pr -msgid "Pull/Merge Request" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__reference -msgid "Reference" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "" -"Reference. Can contain English letters, digits and '_'. Leave blank to " -"autogenerate" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__repo_provider -msgid "Repository Provider" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__is_private -msgid "Repository is private" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__ssh -msgid "SSH" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__sequence -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__sequence -msgid "Sequence" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__server_id -msgid "Server" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__server_ids -msgid "Servers" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__server_ids -msgid "" -"Servers are added automatically based on the files linked to the project.\n" -"IMPORTANT: This field may contain duplicates because of the relation nature!" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__source_id -msgid "Source" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__source_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Sources" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "" -"The top one remote will be used as a merge target.\n" -" You can re-arrange remotes by dragging them or changing their sequence value." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url -msgid "URL" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url_protocol -msgid "URL Protocol" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "URL is required" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__user_ids -msgid "Users" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__user_ids -msgid "Users who can view this record" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__repo_provider -msgid "" -"Will be tried to be determined from the URL. Please select manually if auto-" -"detection fails." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "YAML" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__yaml_code -msgid "Yaml Code" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"You can edit these fields at your own risk. However keep in mind that they " -"will be automatically updated each time related servers are added, removed " -"or updated." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "You must be a member of the \"YAML/Export\" group to export data as YAML." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "managers who can modify this record" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "users who can view this record" -msgstr "" diff --git a/addons/cetmix_tower_git/i18n/hr.po b/addons/cetmix_tower_git/i18n/hr.po deleted file mode 100644 index 1b2e1c7..0000000 --- a/addons/cetmix_tower_git/i18n/hr.po +++ /dev/null @@ -1,635 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * cetmix_tower_git -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2025-03-06 09:11+0000\n" -"Last-Translator: Bole \n" -"Language-Team: Croatian \n" -"Language: hr\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " -"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -"X-Generator: Weblate 5.10.3-dev\n" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0 -#, python-format -msgid "" -"\n" -"# You need to set the following variables in your environment:\n" -"# %(vars)s \n" -"# and run git-aggregator with '--expand-env' parameter.\n" -msgstr "" -"\n" -"# Potrebno je postaviti sljedeće varijable u vaše okruženje:\n" -"# %(vars)s \n" -"# i pokrenuti git-aggregator sa ' --expand-env' parametrom\n" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0 -#, python-format -msgid "" -"# This file is generated with Cetmix Tower https://cetmix.com/tower\n" -"# It's designed to be used with git-aggregator tool developed by Acsone.\n" -"# Documentation for git-aggregator: https://github.com/acsone/git-aggregator\n" -msgstr "" -"# Ova datotek aje generiran pomoću Cetmix Tower sustava: https://cetmix.com/" -"tower\n" -"# Dizajniran je za korištenje sa git-aggregator alatom razvijenim od Ascone." -"\n" -"# Dokumentacija za git-aggregator : https://github.com/acsone/git-" -"aggregator\n" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "* Sources where all remotes are private" -msgstr "* izvori sa svim udaljenim lokacijama koje su privatne" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "* Sources where some remotes are private" -msgstr "* Izvori u kojima su neke udaljene lokacije privatne" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"Managers. All users who have \"Manager\" group and are set as \"Managers\" in all related servers.\n" -" This is done to avoid unpredictable consequences when some of the servers are not updated due to access restrictions when a project is updated." -msgstr "" -"Manageri. Svi korisnici koji imaju \"Manager\" grupu i postavljeni su " -"kao \"Manageri\" u svim povezanim serverima.\n" -" " -"Ovo je napravljeno kako bi izbjegli nepredviđene posljedice kad neki od " -"servera nisu ažurirani zbog ograničenog pristupa prilikom ažuriranja " -"projekta." - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"Users. All users who have \"Manager\" group and are either set in " -"\"Users\" or in \"Managers\" in all related servers." -msgstr "" -"Korisnici. Svi korisnici koji imaju \"Manager\" grupu i postavljeni " -"su ili kao \"Korisnik\" ili kao \"Manager\" u svim povezanim " -"serverima." - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Access" -msgstr "Pristup" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__active -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__active -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__active -msgid "Active" -msgstr "Aktivno" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Archived" -msgstr "Arhivirano" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__bitbucket -msgid "Bitbucket" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__branch -msgid "Branch" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -msgid "Branch/PR/commit number or link" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__reference -msgid "" -"Can contain English letters, digits and '_'. Leave blank to autogenerate" -msgstr "" -"Može sadržavati slova engleske abecede, brojke i ':'. Ostavite prazno za " -"automatsko generiranje" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project -msgid "Cetmix Tower Git Configuration" -msgstr "Cetmix Tower Git postavke" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_remote -msgid "Cetmix Tower Git Remote" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_source -msgid "Cetmix Tower Git Source" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_server -msgid "Cetmix Tower Server" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0 -#, python-format -msgid "Code generator function for '%(project_format)s' format not found." -msgstr "" -"Funkcija generiranja koda za '%(project_format)s' format nije pronađena." - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__commit -msgid "Commit" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_uid -msgid "Created by" -msgstr "Kreirao" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_date -msgid "Created on" -msgstr "Kreirano" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_file -msgid "Cx Tower File" -msgstr "Cx Tower datoteka" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Disabled" -msgstr "Onemogućen" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__display_name -msgid "Display Name" -msgstr "Naziv" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__enabled -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__enabled -msgid "Enable in configuration and exported to files" -msgstr "Omogućen u postavkama i izvezen u datoteku" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__enabled -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__enabled -msgid "Enabled" -msgstr "Omogućen" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Export YAML" -msgstr "Izvoz YAML" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__file_id -msgid "File" -msgstr "Datoteka" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0 -#, python-format -msgid "File '%(file)s' doesn't belong to server '%(server)s'" -msgstr "Datoteka '%(file)s' ne pripada serveru '%(server)s'" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_file.py:0 -#, python-format -msgid "" -"File '%(file)s' is related to multiple projects: %(projects)s \n" -"Please select only one project." -msgstr "" -"Datoteka '%(file)s' je povezana sa višestrukim projektima: %(projects)s \n" -"Molim odaberite samo jedan projekt." - -#. module: cetmix_tower_git -#: model:ir.model.constraint,message:cetmix_tower_git.constraint_cx_tower_git_project_rel_project_server_file_format_uniq -msgid "File is already related to the same project and format" -msgstr "Datoteka je već povezana sa ovim projektom i ovim formatom" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__file_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Files" -msgstr "Datoteke" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__project_format -msgid "Format" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir -msgid "Git Aggregator Root Dir" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "" -"Git Aggregator: Bitbucket does not support fetching PRs. Please use branch instead.\n" -"\n" -"Source: %(src)s\n" -"URL: %(url)s\n" -"Head: %(head)s" -msgstr "" -"Git Aggregator: BitBucket ne podržava dohvaćanje PRova. Molim koristite " -"branch.\n" -"\n" -"Source: %(src)s\n" -"URL: %(url)s\n" -"Head: %(head)s" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Git Aggregator: Head number is empty in %(head)s" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__git_project_id -msgid "Git Configuration" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_ids -msgid "Git Project" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_rel_ids -msgid "Git Project Rel" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_project_rel_ids -msgid "Git Project Server File Relations" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project_rel -msgid "Git Project relation to other model records" -msgstr "Git projekt povezan sa ostalim zapisima modela" - -#. module: cetmix_tower_git -#: model:ir.actions.act_window,name:cetmix_tower_git.cx_tower_git_project_action -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_ids -#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_project -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_server_view_form -msgid "Git Projects" -msgstr "Git projekti" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir -msgid "" -"Git aggregator root directory where sources will be cloned. Eg '/tmp/git-" -"aggregator' Will use '.' if not set" -msgstr "" -"GitAgregator izvorni direktorij u koji će izvori biti klonirani. Npr. '/tmp/" -"git-aggregator' Ako ništa nije postavljeno koristi se '.'" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"Git aggregator root directory where sources will be cloned. Leave blank to " -"use '.'" -msgstr "" -"GitAgregtor izvorni dirketorij u koji će izvori biti klonirani. Ostavite " -"prazno za korištenje '.'" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__url -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -msgid "" -"Git remote URL. Eg 'https://github.com/cetmix/cetmix-tower.git' or " -"'git@github.com:cetmix/cetmix-tower.git'" -msgstr "" -"Git remote URL. Eg 'https://github.com/cetmix/cetmix-tower.git' ili " -"'git@github.com:cetmix/cetmix-tower.git'" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__head -msgid "" -"Git remote head. Link to branch, PR, commit or commit hash. Leave blank to " -"auto-detect" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__github -msgid "GitHub" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__gitlab -msgid "GitLab" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__https -msgid "HTTPS" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes -msgid "Has Partially Private Remotes" -msgstr "Ima djelomično privatne udaljene izvore" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes -msgid "Has Private Remotes" -msgstr "Ima privatne udaljene izvore" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head -msgid "Head" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head_type -msgid "Head Type" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__id -msgid "ID" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes -msgid "Indicates if the project has any partially private remotes." -msgstr "Indicira ima li projekt djelomično privatnih udaljenih izvora." - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes -msgid "Indicates if the project has any private remotes." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__is_private -msgid "Is Private" -msgstr "Je privatno" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server____last_update -msgid "Last Modified on" -msgstr "Zadnje modificirano" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_uid -msgid "Last Updated by" -msgstr "Zadnji ažurirao" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_date -msgid "Last Updated on" -msgstr "Zadnje ažurirano" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__manager_ids -msgid "Managers" -msgstr "Manageri" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__manager_ids -msgid "Managers who can modify this record" -msgstr "Manageri koji mogu urediti ovaj zapis" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__name -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Name" -msgstr "Naziv" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Not a valid URL. URL must end with '.git'" -msgstr "Nije valjani URL. URL mora završavati sa '.git'" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Not a valid URL. URL must start with 'https://' or 'git@'" -msgstr "Nije valjani URL. URL mora počinjati sa 'https://' ili 'git@'" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "" -"Not a valid URL: %(url_msg)s\n" -"URL must contain at least two parts separated by dot." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__other -msgid "Other" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Private" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count_private -msgid "Private Remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__pr -msgid "Pull/Merge Request" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__reference -msgid "Reference" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "" -"Reference. Can contain English letters, digits and '_'. Leave blank to " -"autogenerate" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Remotes" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__repo_provider -msgid "Repository Provider" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__is_private -msgid "Repository is private" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__ssh -msgid "SSH" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__sequence -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__sequence -msgid "Sequence" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__server_id -msgid "Server" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__server_ids -msgid "Servers" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__server_ids -msgid "" -"Servers are added automatically based on the files linked to the project.\n" -"IMPORTANT: This field may contain duplicates because of the relation nature!" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__source_id -msgid "Source" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__source_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Sources" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "" -"The top one remote will be used as a merge target.\n" -" You can re-arrange remotes by dragging them or changing their sequence value." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url -msgid "URL" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url_protocol -msgid "URL Protocol" -msgstr "" - -#. module: cetmix_tower_git -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "URL is required" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__user_ids -msgid "Users" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__user_ids -msgid "Users who can view this record" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__repo_provider -msgid "" -"Will be tried to be determined from the URL. Please select manually if auto-" -"detection fails." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "YAML" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__yaml_code -msgid "Yaml Code" -msgstr "YAML kod" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"You can edit these fields at your own risk. However keep in mind that they " -"will be automatically updated each time related servers are added, removed " -"or updated." -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "You must be a member of the \"YAML/Export\" group to export data as YAML." -msgstr "Morate bit član \"YAML/Izvoz\" grupe za izvoz podataka u YAML." - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "managers who can modify this record" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "users who can view this record" -msgstr "" diff --git a/addons/cetmix_tower_git/i18n/it.po b/addons/cetmix_tower_git/i18n/it.po deleted file mode 100644 index efa696b..0000000 --- a/addons/cetmix_tower_git/i18n/it.po +++ /dev/null @@ -1,1085 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * cetmix_tower_git -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: \n" -"PO-Revision-Date: 2025-11-11 15:54+0100\n" -"Last-Translator: \n" -"Language-Team: Italian \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Language: it\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Poedit 2.3\n" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0 -#, python-format -msgid "" -"\n" -"# You need to set the following variables in your environment:\n" -"# %(vars)s\n" -"# and run git-aggregator with '--expand-env' parameter.\n" -msgstr "" -"\n" -"# È necessario impostare le seguenti variabili d'ambiente:\n" -"# %(vars)s\n" -"# ed eseguire git-aggregator con il parametro '--expand-env'.\n" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_project.py:0 -#, python-format -msgid "" -"# This file is generated with Cetmix Tower https://cetmix.com/tower\n" -"# It's designed to be used with git-aggregator tool developed by Acsone.\n" -"# Documentation for git-aggregator: https://github.com/acsone/git-aggregator\n" -msgstr "" -"# Questo file è generato con Cetmix Tower https://cetmix.com/tower\n" -"# È progettato per essere usato con il tool git-aggregator sviluppato da Acsone.\n" -"# Documentazione per git-aggregator: https://github.com/acsone/git-aggregator\n" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "* Sources where all remotes are private" -msgstr "* Origini dove tutti i remote sono privati" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "* Sources where some remotes are private" -msgstr "* Origini dove alcuni remote sono privati" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "..to be autogenerated" -msgstr "... da autogenerare" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "" -"Managers. All users who have \"Manager\" group and are set as \"Managers\" in all related servers.\n" -" This is done to avoid unpredictable consequences when some of the servers are not updated due to access restrictions when a project is updated." -msgstr "" -"
Responsabili. Tutti gli utenti che hanno il gruppo \"Responsabili\" e seno impostati a \"Responsabile\" in tutti i server relativi.\n" -" Questo serve ad evitare conseguenze impreviste quando alcuni dei server non sono aggiornati per limitazioni di accesso nell'aggiornamento di un progetto." - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Users. All users who have \"Manager\" group and are either set in \"Users\" or in \"Managers\" in all related servers." -msgstr "Utenti. Tutti gli utenti che hanno il gruppo \"Responsabili\" e sono impostati in \"Utenti\" o \"Responsabili\" in tutti i server correlati." - -#. module: cetmix_tower_git -#: model:ir.model.constraint,message:cetmix_tower_git.constraint_cx_tower_git_repo_unique_repo_host_owner -msgid "A repository with the same name, host, and owner already exists." -msgstr "Esiste già un repository con lo stesso nome server, host e proprietario." - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Access" -msgstr "Accesso" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__active -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__active -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__active -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__active -msgid "Active" -msgstr "Attivo" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Archived" -msgstr "In archivio" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__auto_sync -msgid "Auto Sync" -msgstr "Auto sincronizza" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__branch -msgid "Branch" -msgstr "Branch" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -msgid "Branch/PR/commit number or link" -msgstr "Numero o link branch/PR/commit" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project_rel__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo_owner__reference -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__reference -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_owner_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -msgid "Can contain English letters, digits and '_'. Leave blank to autogenerate" -msgstr "Può contenere lettere inglesi, cifre e '_'. Lasciare vuoto per la generazione automatica" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_command -msgid "Cetmix Tower Command" -msgstr "Comando Cetmix Tower" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_file -msgid "Cetmix Tower File" -msgstr "File Cetmix Tower" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_file_template -msgid "Cetmix Tower File Template" -msgstr "Modello file Cetmix Tower" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_plan_line -msgid "Cetmix Tower Flight Plan Line" -msgstr "Riga piano di volo Cetmix Tower" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project -msgid "Cetmix Tower Git Project" -msgstr "Progetto Git Cetmix Tower" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project_file_template_rel -msgid "Cetmix Tower Git Project relation to File Templates" -msgstr "Relazione progetto Git a modelli file" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_project_rel -msgid "Cetmix Tower Git Project relation to Files and Servers" -msgstr "Relazione progetto Git ad file e server" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_remote -msgid "Cetmix Tower Git Remote" -msgstr "Remote Git Cetmix Tower" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_repo -msgid "Cetmix Tower Git Repository" -msgstr "Repository Git Cetmix Tower" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_repo_owner -msgid "Cetmix Tower Git Repository Owner" -msgstr "Proprietario repository Git Cetmix Tower" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_git_source -msgid "Cetmix Tower Git Source" -msgstr "Origine Git Cetmix Tower" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cetmix_tower -msgid "Cetmix Tower Odoo Automation" -msgstr "Automazione Odoo Cetmix Tower" - -#. module: cetmix_tower_git -#: model:ir.model,name:cetmix_tower_git.model_cx_tower_server -msgid "Cetmix Tower Server" -msgstr "Server Cetmix Tower" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_project_file_template_rel.py:0 -#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0 -#, python-format -msgid "Code generator function for '%(project_format)s' format not found." -msgstr "Funzione generazione codice per il formato '%(project_format)s' non trovata." - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__commit -msgid "Commit" -msgstr "Commit" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_server_view_form -msgid "Configure" -msgstr "Configura" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_plan_line__is_make_copy -msgid "Create a copy of the Git Project instead of linking the file to the existing one." -msgstr "Creare una copia del progetto Git invece di collegare il file ad uno esistente." - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_remote -msgid "Create your first git remote!" -msgstr "Crea il tuo primo remote Git!" - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_repo_owner -msgid "Create your first repository owner!" -msgstr "Crea il tuo primo proprietario di repository!" - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_repo -msgid "Create your first repository!" -msgstr "Crea il tuo primo repository!" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__create_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_uid -msgid "Created by" -msgstr "Creato da" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__create_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__create_date -msgid "Created on" -msgstr "Creato il" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__secret_id -msgid "Custom secret used for this repository" -msgstr "Secreto personalizzato utilizzato per questo repository" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo_owner__secret_id -msgid "Custom secret used for this repository owner" -msgstr "Segreto personalizzato utilizzato per questo proprietario del repository" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Disabled" -msgstr "Disabilitato" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__display_name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__display_name -msgid "Display Name" -msgstr "Nome visualizzato" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__url -msgid "Displayed in 'https' format, but can be entered in any format" -msgstr "Visualizzato in formato 'https', ma può essere inserito in qualsiasi formato" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_source.py:0 -#, python-format -msgid "Empty Source" -msgstr "Origine vuota" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__enabled -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_source__enabled -msgid "Enable in configuration and exported to files" -msgstr "Abilitato in configurazione ed esportato nei file" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__enabled -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__enabled -msgid "Enabled" -msgstr "Abilitato" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Export YAML" -msgstr "Esporta YAML" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__file_id -msgid "File" -msgstr "File" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_project_rel.py:0 -#, python-format -msgid "File '%(file)s' doesn't belong to server '%(server)s'" -msgstr "Il file '%(file)s' non appartiene al server '%(server)s'" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__file_template_id -msgid "File Template" -msgstr "Modello file" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__file_template_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "File Templates" -msgstr "Modelli file" - -#. module: cetmix_tower_git -#: model:ir.model.constraint,message:cetmix_tower_git.constraint_cx_tower_git_project_rel_project_server_file_format_uniq -msgid "File is already related to the same project and format" -msgstr "Il file è già relativo allo stesso progetto e formato" - -#. module: cetmix_tower_git -#: model:ir.model.constraint,message:cetmix_tower_git.constraint_cx_tower_git_project_file_template_rel_project_server_file_format_uniq -msgid "File template is already related to the same project and format" -msgstr "Il file modello è già relativo allo stesso progetto e formato" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__file_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Files" -msgstr "File" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__project_format -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__project_format -msgid "Format" -msgstr "Formato" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__git -msgid "GIT" -msgstr "GIT" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__url_git -msgid "GIT URL" -msgstr "URL GIT" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__url_git -msgid "GIT URL of the repository" -msgstr "URL git del repository" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "General" -msgstr "Generale" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__url -msgid "Generic URL" -msgstr "URL generico" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Git Aggregator" -msgstr "Git Aggregator" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir -msgid "Git Aggregator Root Dir" -msgstr "Directory radice Git Aggregator" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "" -"Git Aggregator: Bitbucket does not support fetching PRs. Please use branch instead.\n" -"\n" -"Source: %(src)s\n" -"URL: %(url)s\n" -"Head: %(head)s" -msgstr "" -"Git Aggregator: Bitbucket non supporta il fetch delle PR. In alternativa usare branch.\n" -"\n" -"Origine: %(src)s\n" -"URL: %(url)s\n" -"Head: %(head)s" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Git Aggregator: Head number is empty in %(head)s" -msgstr "Git Aggregator: il numero Head è vuoto in %(head)s" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__git_project_id -msgid "Git Configuration" -msgstr "Configurazione Git" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file_template__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__git_project_ids -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_plan_line__git_project_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_ids -msgid "Git Project" -msgstr "Progetto Git" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__git_project_count -msgid "Git Project Count" -msgstr "Conteggio progetti Git" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_project_file_template_rel_ids -msgid "Git Project File Template Relations" -msgstr "Relazioni modello file progetto Git" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_server__git_project_rel_ids -msgid "Git Project Rel" -msgstr "Rel progetto Git" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__git_project_rel_ids -msgid "Git Project Relations" -msgstr "Relazioni progetto Git" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__git_project_rel_ids -msgid "Git Project Server File Relations" -msgstr "Relazioni file server progetto Git" - -#. module: cetmix_tower_git -#: model:ir.actions.act_window,name:cetmix_tower_git.cx_tower_git_project_action -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file_template__git_project_ids -#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_project -#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_project_settings -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_server_view_form -msgid "Git Projects" -msgstr "Progetti Git" - -#. module: cetmix_tower_git -#: model:ir.actions.act_window,name:cetmix_tower_git.action_cx_tower_git_remote -msgid "Git Remotes" -msgstr "Remote Git" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__git_aggregator_root_dir -msgid "Git aggregator root directory where sources will be cloned. Eg '/tmp/git-aggregator' Will use '.' if not set" -msgstr "La directory radice di Git Aggregator dove l'origine verrà clonata. Es. '/tmp/git-aggregator'. Verrà usato '.' se non impostata" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Git aggregator root directory where sources will be cloned. Leave blank to use '.'" -msgstr "La directory radice di Git Aggregator dove l'origine verrà clonata. Lasciare vuoto per usare '.'" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__head -msgid "Git remote head. Link to branch, PR, commit or commit hash." -msgstr "Head remoto Git. Link al branch, PR, commit o hash del commit. " - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_remote -msgid "Git remotes represent branches, pull requests, or commits from git repositories." -msgstr "I remote git rappresentano branch, pull reguest o commit dai repository Git." - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -msgid "GitProjects" -msgstr "" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Group By" -msgstr "Raggruppa per" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__https -msgid "HTTPS" -msgstr "HTTPS" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes -msgid "Has Partially Private Remotes" -msgstr "Ha remote parzialmente privati" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes -msgid "Has Private Remotes" -msgstr "Ha remote privati" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head -msgid "Head" -msgstr "Intestazione" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__head_type -msgid "Head Type" -msgstr "Tipo head" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__host -msgid "Host" -msgstr "Host" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__id -msgid "ID" -msgstr "ID" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project_rel__auto_sync -msgid "If enabled file will be synced automatically using cron" -msgstr "Se abilitata il file verrà sincronizzato automaticamente usando il cron" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__repo_id -msgid "If selected, the remote URL will be filled from the repo settings based on the remote protocol" -msgstr "Se selezionata, l'URL del remote verrà compilato dalle impostazione del repository in base al protocollo del remote" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_partially_private_remotes -msgid "Indicates if the project has any partially private remotes." -msgstr "Indica se il progetto ha qualche remote parzialmente privato." - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__has_private_remotes -msgid "Indicates if the project has any private remotes." -msgstr "Indica se il progetto ha qualche remote privato." - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__active -msgid "Indicates if the repository is active" -msgstr "Indica se il repository è attivo." - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__is_private -msgid "Indicates if the repository is private" -msgstr "Indica se il repositoty è privato." - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner____last_update -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source____last_update -msgid "Last Modified on" -msgstr "Ultima modifica il" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__write_uid -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_uid -msgid "Last Updated by" -msgstr "Ultimo aggiornamento di" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__write_date -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__write_date -msgid "Last Updated on" -msgstr "Ultimo aggiornamento il" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_plan_line__is_make_copy -msgid "Make a Copy" -msgstr "Crea una copia" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__manager_ids -msgid "Managers" -msgstr "Responsabili" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__manager_ids -msgid "Managers who can modify this record" -msgstr "Responsabili che possono modificare questo record" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_file_template_rel__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__name -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__name -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Name" -msgstr "Nome" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo_owner__name -msgid "Name of the repository owner (e.g., 'cetmix', 'OCA')" -msgstr "Nome del proprietario del repository (es. 'cetmix', 'OCA')" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Name. Leave blank to autogenerate" -msgstr "Nome. Lasciare vuoto per autogenerarlo" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_repo.py:0 -#, python-format -msgid "Not a valid repository URL!" -msgstr "URL repository non valido!" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__note -msgid "Note" -msgstr "Nota" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__git_project_count -msgid "Number of projects this repository is used in" -msgstr "Numero dei progetti in cui è usato questo repository" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__remote_count -msgid "Number of remotes this repository is used in" -msgstr "Numero dei remote in cui è usato questo repository" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Open File Template" -msgstr "Apri modello file" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_server_view_form -msgid "Open Git Project" -msgstr "Apri progetto Git" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Open Server" -msgstr "Apri server" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Open file template" -msgstr "Apri modello file" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Org" -msgstr "Org" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__owner_id -msgid "Owner" -msgstr "Proprietario" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__is_private -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__is_private -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Private" -msgstr "Privato" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count_private -msgid "Private Remotes" -msgstr "Remote privati" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -msgid "Projects" -msgstr "Progetti" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__git_project_ids -msgid "Projects this repository is used in" -msgstr "Progetto in cui è usato questo repository" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url_protocol -msgid "Protocol" -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__repo_provider -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__provider -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Provider" -msgstr "Provider" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Provider: Other" -msgstr "Provider: altro" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_search -msgid "Public" -msgstr "Pubblico" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__head_type__pr -msgid "Pull/Merge Request" -msgstr "Pull/Merge Request" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Put your notes here..." -msgstr "Inserisci qui le note..." - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_command.py:0 -#, python-format -msgid "Python library for Git URL parsing. Available methods: 'parse', 'validate'. Documentation on GitHub." -msgstr "Libreria Python per il parsing dell'URL Git. Metofi disponibili: 'parse', 'validate'. Documentazione su GitHub." - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__reference -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__reference -msgid "Reference" -msgstr "Riferimento" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Reference. Can contain English letters, digits and '_'. Leave blank to autogenerate" -msgstr "Riferimento. Può contenere lettere inglesi, cifre e '_'. Lasciare vuoto per la generazione automatica" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__remote_ids -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_ids -msgid "Remote" -msgstr "Remote" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__remote_count -msgid "Remote Count" -msgstr "Conteggio remote" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__remote_count -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "Remotes" -msgstr "Remote" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__remote_ids -msgid "Remotes that use this repository" -msgstr "Remote che usano questo repository" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Repos" -msgstr "Repository" - -#. module: cetmix_tower_git -#: model:ir.actions.act_window,name:cetmix_tower_git.action_cx_tower_git_repo -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__repo_ids -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__repo_ids -#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_repositories -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_owner_view_form -msgid "Repositories" -msgstr "Repository" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo_owner__repo_ids -msgid "Repositories owned by this organization/user" -msgstr "Repository di proprietà du eusta organizzazione/utente" - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_repo -msgid "" -"Repositories represent git repositories with their metadata and configuration.\n" -" They can be linked to remotes to automatically populate URL information." -msgstr "" -"I repository rappresentano i reposoitory Git con i loro metadati e configurazione.\n" -" Possono essere collegati a remore per compilare automaticamente le informazioni sull'URL." - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__repo_ids -msgid "Repositories used in this project through its sources and remotes" -msgstr "Repository utilizzati in questo progetto attraverso i suo sorgente e i remote" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__repo_id -msgid "Repository" -msgstr "Repository" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__repo -msgid "Repository Name" -msgstr "Nome repository" - -#. module: cetmix_tower_git -#: model:ir.actions.act_window,name:cetmix_tower_git.action_cx_tower_git_repo_owner -#: model:ir.ui.menu,name:cetmix_tower_git.menu_cx_tower_git_repository_owners -msgid "Repository Owners" -msgstr "Proprietari repository" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Repository URL is not set" -msgstr "L'URL del repository non è impostato" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__host -msgid "Repository host (e.g., 'github.com', 'gitlab.com')" -msgstr "Host repository (es. 'github.com', 'gitlab.com')" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__is_private -msgid "Repository is private" -msgstr "Il repository è privato" - -#. module: cetmix_tower_git -#. odoo-python -#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 -#, python-format -msgid "Repository is required" -msgstr "Il repository è richiesto" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__repo -msgid "Repository name (e.g., 'cetmix-tower', 'odoo')" -msgstr "Nome repository (es. 'cetmix-tower', 'odoo')" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__owner_id -msgid "Repository owner (e.g., 'cetmix' or 'OCA')" -msgstr "Proprietari orepository (es. 'cetmix' or 'OCA')" - -#. module: cetmix_tower_git -#: model_terms:ir.actions.act_window,help:cetmix_tower_git.action_cx_tower_git_repo_owner -msgid "" -"Repository owners represent organizations or users that own git repositories.\n" -" Examples include \"cetmix\", \"OCA\", etc." -msgstr "" -"I proprietari del repository rappresentano organizzazioni o utenti che possiedono i repository Git.\n" -" Esempi includono \"cetmix\", \"OCA\", etc." - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_remote__repo_provider -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__provider -msgid "Repository provider to determine provider-based behaviour" -msgstr "Provider repository per determinare l'aspetto in base al provider" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Root Directory" -msgstr "Cartella radice" - -#. module: cetmix_tower_git -#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__url_protocol__ssh -msgid "SSH" -msgstr "SSH" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__url_ssh -msgid "SSH URL" -msgstr "URL SSH" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_repo__url_ssh -msgid "SSH URL of the repository" -msgstr "URL SSH del repository" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__secret_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__secret_id -msgid "Secret" -msgstr "Segreto" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_plan_line__git_project_id -msgid "Select a git project to be linked to the file and server." -msgstr "Selezionare un progetto Git da collegare al file e al server." - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__sequence -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__sequence -msgid "Sequence" -msgstr "Sequenza" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_file__server_id -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__server_id -msgid "Server" -msgstr "Server" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__server_ids -msgid "Servers" -msgstr "Server" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__server_ids -msgid "" -"Servers are added automatically based on the files linked to the project.\n" -"IMPORTANT: This field may contain duplicates because of the relation nature!" -msgstr "" -"I server vengono aggiunti automaticamente in base ai file collegati al progetto.\n" -"IMPORTANTE: questo campo può contenere duplicati per la natura della relazione!" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__source_id -msgid "Source" -msgstr "Origine" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__source_ids -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "Sources" -msgstr "Origini" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form -msgid "" -"The top one remote will be used as a merge target.\n" -" You can re-arrange remotes by dragging them or changing their sequence value." -msgstr "" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__user_ids -msgid "Users" -msgstr "Utenti" - -#. module: cetmix_tower_git -#: model:ir.model.fields,help:cetmix_tower_git.field_cx_tower_git_project__user_ids -msgid "Users who can view this record" -msgstr "Utenti che possono vedere questo record" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "YAML" -msgstr "YAML" - -#. module: cetmix_tower_git -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_project_rel__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_repo_owner__yaml_code -#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_source__yaml_code -msgid "Yaml Code" -msgstr "Codice YAML" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "You can edit these fields at your own risk. However keep in mind that they will be automatically updated each time related servers are added, removed or updated." -msgstr "Si possono modificare questi campi a proprio rischio e pericolo. Tuttavia, tenere presente che verranno aggiornati automaticamente ogni volta che vengono aggiunti, rimossi o aggiornati server correlati." - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "You must be a member of the \"YAML/Export\" group to export data as YAML." -msgstr "Bisogna appartenere al gruppo \"YAML/Export\" per esportare dati in YAML." - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_owner_view_form -msgid "e.g., Cetmix, OCA" -msgstr "es. Cetmix, OCA" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_owner_view_form -msgid "e.g., cetmix, oca" -msgstr "es. cetmix, oca" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -msgid "e.g., cetmix-tower, odoo" -msgstr "es. cetmix-tower, odoo" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_repo_view_form -msgid "https, ssh or git formats are accepted" -msgstr "Sono accettati formati Git https o ssh" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "managers who can modify this record" -msgstr "responsabili che possono modificare questi record" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_form -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_remote_view_tree -msgid "select or enter a link" -msgstr "selezionare o inserire un link" - -#. module: cetmix_tower_git -#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form -msgid "users who can view this record" -msgstr "utenti che possono vedere questo record" - -#~ msgid "Bitbucket" -#~ msgstr "Bitbucket" - -#~ msgid "" -#~ "Git remote URL. Eg 'https://github.com/cetmix/cetmix-tower.git' or " -#~ "'git@github.com:cetmix/cetmix-tower.git'" -#~ msgstr "" -#~ "URL remote Git. Es. 'https://github.com/cetmix/cetmix-tower.git' o " -#~ "'git@github.com:cetmix/cetmix-tower.git'" - -#~ msgid "GitHub" -#~ msgstr "GitHub" - -#~ msgid "GitLab" -#~ msgstr "GitLab" - -#~ msgid "Is Private" -#~ msgstr "È privato" - -#~ msgid "Not a valid URL. URL must end with '.git'" -#~ msgstr "URL non valido. L'URL deve finire con '.git'" - -#~ msgid "Not a valid URL. URL must start with 'https://', 'git@', or 'git://'" -#~ msgstr "" -#~ "URL non valido. L'URL deve iniziare con 'https://', 'git@', or 'git://'" - -#~ msgid "Open" -#~ msgstr "Aperto" - -#~ msgid "Open server" -#~ msgstr "Apri server" - -#~ msgid "URL" -#~ msgstr "URL" - -#~ msgid "URL is required" -#~ msgstr "È richiesto l'URL" - -#~ msgid "" -#~ "Will be tried to be determined from the URL. Please select manually if " -#~ "auto-detection fails." -#~ msgstr "" -#~ "Si cercherà di determinarlo dall'URL. Selezionarlo manualmente se " -#~ "fallisce l'auto determinazione." - -#~ msgid "" -#~ "File '%(file)s' is related to multiple projects: %(projects)s \n" -#~ "Please select only one project." -#~ msgstr "" -#~ "Il file '%(file)s' è relativo a progetti multipli: %(projects)s \n" -#~ "Selezionare solo un progetto." - -#~ msgid "" -#~ "Not a valid URL: %(url_msg)s\n" -#~ "URL must contain at least two parts separated by dot." -#~ msgstr "" -#~ "URL non valdio: %(url_msg)s\n" -#~ "URL deve contenere almeno due parti separate da un punto." diff --git a/addons/cetmix_tower_git/migrations/16.0.2.0.0/post-migration.py b/addons/cetmix_tower_git/migrations/16.0.2.0.0/post-migration.py deleted file mode 100644 index d5ead46..0000000 --- a/addons/cetmix_tower_git/migrations/16.0.2.0.0/post-migration.py +++ /dev/null @@ -1,82 +0,0 @@ -import logging - -from odoo import SUPERUSER_ID, api - -_logger = logging.getLogger(__name__) - - -def migrate(cr, version): - """ - Convert URLs in remotes to repositories. - Add repo_id to remotes. - """ - - _logger.info( - "Converting URLs in remotes to repositories and adding repo_id to remotes." - ) - env = api.Environment(cr, SUPERUSER_ID, {}) - - # Fetch all remotes using SQL query Group them {"url": [remote_id, remote_id, ...]} - cr.execute( - """ - SELECT url, array_agg(id) as remote_ids - FROM cx_tower_git_remote - GROUP BY url - """ - ) - remote_urls = cr.fetchall() - remote_urls_dict = {url: remote_ids for url, remote_ids in remote_urls} - - # Create repo for each url and add this repo to all remotes - url_count = 0 - remote_obj = env["cx.tower.git.remote"] - repo_obj = env["cx.tower.git.repo"] - for url, remote_ids in remote_urls_dict.items(): - repo_id = repo_obj.name_create(url)[0] - # Check if any of the remotes is private - remotes = remote_obj.browse(remote_ids) - is_private = bool(remotes.filtered(lambda r: r.is_private)) - - # Add repo to remotes - # We are using SQL to avoid post-write triggers - cr.execute( - """ - UPDATE cx_tower_git_remote - SET repo_id = %s - WHERE id = ANY(%s) - """, - (repo_id, remote_ids), - ) - - # Update repo.is_private - # We are using SQL to avoid post-write triggers - if is_private: - cr.execute( - """ - UPDATE cx_tower_git_repo - SET is_private = true - WHERE id = %s - """, - (repo_id,), - ) - - url_count += 1 - - # Compute project_ids for repositories - _logger.info("Computing project_ids for repositories.") - remote_obj.invalidate_model() - repo_obj.invalidate_model() - repo_obj.search([])._compute_git_project_ids() - - # Sanitize all remote heads that contain a slash - # Use the SQL query to avoid post-write triggers - _logger.info("Sanitizing remote heads that contain a slash.") - cr.execute( - """ - UPDATE cx_tower_git_remote - SET head = (regexp_match(head, '[^/]+$'))[1] - WHERE head LIKE '%/%' - """ - ) - - _logger.info("Migration completed. %s unique urls processed", url_count) diff --git a/addons/cetmix_tower_git/models/__init__.py b/addons/cetmix_tower_git/models/__init__.py deleted file mode 100644 index 06e0f96..0000000 --- a/addons/cetmix_tower_git/models/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# cx_tower_git_project_rel must be the first one in the list -# in order to create the relation table properly -from . import cx_tower_git_project_rel -from . import cx_tower_git_project_file_template_rel -from . import cx_tower_file -from . import cx_tower_file_template -from . import cx_tower_git_project -from . import cx_tower_git_remote -from . import cx_tower_git_repo -from . import cx_tower_git_repo_owner -from . import cx_tower_git_source -from . import cx_tower_server -from . import cetmix_tower -from . import cx_tower_plan_line -from . import cx_tower_command diff --git a/addons/cetmix_tower_git/models/cetmix_tower.py b/addons/cetmix_tower_git/models/cetmix_tower.py deleted file mode 100644 index 4a7448f..0000000 --- a/addons/cetmix_tower_git/models/cetmix_tower.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import api, models - - -class CetmixTower(models.AbstractModel): - _inherit = "cetmix.tower" - - @api.model - def servers_by_git_ref(self, repository_url, head=None, head_type=None): - """ - Return servers linked to a given Git repository reference. - - This is a thin shortcut that delegates to - :meth:`cx.tower.server.get_servers_by_git_ref`. - - Parameters - ---------- - repository_url : str - Pre-normalized canonical Git URL - (e.g. ``https://host/owner/repo.git``). - head : str, optional - Branch name, commit SHA, or PR identifier. - head_type : {'branch', 'commit', 'pr'}, optional - Type of the ``head`` argument. - - Returns - ------- - recordset of cx.tower.server - Matching servers. Empty recordset if no matches. - """ - return self.env["cx.tower.server"].get_servers_by_git_ref( - repository_url, head, head_type - ) diff --git a/addons/cetmix_tower_git/models/cx_tower_command.py b/addons/cetmix_tower_git/models/cx_tower_command.py deleted file mode 100644 index 7726e36..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_command.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright 2024 Cetmix OÜ -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from odoo import _, models -from odoo.tools.safe_eval import wrap_module - -# Wrap giturlparse safely -giturlparse = wrap_module(__import__("giturlparse"), ["parse", "validate"]) - - -class CxTowerCommand(models.Model): - """Extends cx.tower.command to add giturlparse functionality.""" - - _inherit = "cx.tower.command" - - def _custom_python_libraries(self): - """ - Add the giturlparse library to the available libraries. - """ - custom_python_libraries = super()._custom_python_libraries() - custom_python_libraries.update( - { - "cetmix_tower_git": { - "giturlparse": { - "import": giturlparse, - "help": _( - "Python library for Git URL parsing. " - "Available methods: 'parse', 'validate'. " - " Documentation on GitHub." - ), - }, - } - } - ) - return custom_python_libraries diff --git a/addons/cetmix_tower_git/models/cx_tower_file.py b/addons/cetmix_tower_git/models/cx_tower_file.py deleted file mode 100644 index 583f935..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_file.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import api, fields, models - - -class CxTowerFile(models.Model): - _inherit = "cx.tower.file" - - git_project_id = fields.Many2one( - comodel_name="cx.tower.git.project", - compute="_compute_git_project_id", - store=True, - ) - git_project_rel_ids = fields.One2many( - comodel_name="cx.tower.git.project.rel", - inverse_name="file_id", - string="Git Project Relations", - copy=False, - ) - - # Get server from the first related git project relation - # This is needed for YAML import - server_id = fields.Many2one( - comodel_name="cx.tower.server", - compute="_compute_git_project_id", - store=True, - readonly=False, - ) - - @api.depends("git_project_rel_ids.server_id", "git_project_rel_ids.git_project_id") - def _compute_git_project_id(self): - """ - Link to project using the proxy model. - """ - for record in self: - # File is related to project via proxy model. - # So there can be only one record in o2m field. - git_project_relation = ( - record.git_project_rel_ids and record.git_project_rel_ids[0] - ) - if git_project_relation: - record.update( - { - "git_project_id": git_project_relation.git_project_id, - "server_id": git_project_relation.server_id, - } - ) diff --git a/addons/cetmix_tower_git/models/cx_tower_file_template.py b/addons/cetmix_tower_git/models/cx_tower_file_template.py deleted file mode 100644 index 792c453..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_file_template.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import api, fields, models - - -class CxTowerFileTemplate(models.Model): - _inherit = "cx.tower.file.template" - - git_project_ids = fields.Many2many( - comodel_name="cx.tower.git.project", - relation="cx_tower_git_project_file_template_rel", - column1="file_template_id", - column2="git_project_id", - string="Git Projects", - copy=False, - ) - git_project_id = fields.Many2one( - comodel_name="cx.tower.git.project", - compute="_compute_git_project_id", - ) - - @api.depends("git_project_ids") - def _compute_git_project_id(self): - """ - Link to project using the proxy model. - """ - for record in self: - # File is related to project via proxy model. - # So there can be only one record in o2m field. - record.git_project_id = ( - record.git_project_ids and record.git_project_ids[0].id - ) diff --git a/addons/cetmix_tower_git/models/cx_tower_git_project.py b/addons/cetmix_tower_git/models/cx_tower_git_project.py deleted file mode 100644 index 3f5b718..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_git_project.py +++ /dev/null @@ -1,370 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -import re - -from odoo import _, api, fields, models - - -class CxTowerGitProject(models.Model): - """ - Git Project. - Implements pre-defined git configuration. - """ - - _name = "cx.tower.git.project" - _description = "Cetmix Tower Git Project" - _order = "name" - - _inherit = [ - "cx.tower.reference.mixin", - "cx.tower.yaml.mixin", - "cx.tower.access.role.mixin", - ] - - def _get_post_create_fields(self): - res = super()._get_post_create_fields() - return res + [ - "source_ids", - "git_project_rel_ids", - "git_project_file_template_rel_ids", - ] - - active = fields.Boolean(default=True) - # IMPORTANT: This field may contain duplicates because of the relation nature! - server_ids = fields.Many2many( - comodel_name="cx.tower.server", - relation="cx_tower_git_project_server_rel", - readonly=True, - copy=False, - compute="_compute_server_ids", - store=True, - context={"active_test": False}, - help="Servers are added automatically based on the files" - " linked to the project.", - ) - source_ids = fields.One2many( - comodel_name="cx.tower.git.source", - inverse_name="git_project_id", - string="Sources", - auto_join=True, - copy=True, - ) - git_project_rel_ids = fields.One2many( - comodel_name="cx.tower.git.project.rel", - inverse_name="git_project_id", - string="Git Project Server File Relations", - copy=False, - ) - # Helper field to get all files related to git project - file_ids = fields.Many2many( - comodel_name="cx.tower.file", - relation="cx_tower_git_project_rel", - column1="git_project_id", - column2="file_id", - string="Files", - readonly=True, - depends=["git_project_rel_ids"], - copy=False, - ) - git_project_file_template_rel_ids = fields.One2many( - comodel_name="cx.tower.git.project.file.template.rel", - inverse_name="git_project_id", - string="Git Project File Template Relations", - copy=False, - ) - # Helper field to get all file templates related to git project - file_template_ids = fields.Many2many( - comodel_name="cx.tower.file.template", - relation="cx_tower_git_project_file_template_rel", - column1="git_project_id", - column2="file_template_id", - string="File Templates", - readonly=True, - depends=["git_project_file_template_rel_ids"], - copy=False, - ) - # Helper field to get all repositories used in this project - repo_ids = fields.Many2many( - comodel_name="cx.tower.git.repo", - relation="cx_tower_git_repo_project_rel", - column1="project_id", - column2="repo_id", - string="Repositories", - readonly=True, - copy=False, - help="Repositories used in this project through its sources and remotes", - ) - note = fields.Text() - - # ---- Access. Add relation for mixin fields - user_ids = fields.Many2many( - relation="cx_tower_git_project_user_rel", - compute="_compute_user_ids", - readonly=False, - store=True, - precompute=True, - domain=lambda self: [ - ("groups_id", "in", self.env.ref("cetmix_tower_server.group_manager").ids) - ], - ) - manager_ids = fields.Many2many( - relation="cx_tower_git_project_manager_rel", - compute="_compute_user_ids", - readonly=False, - store=True, - precompute=True, - ) - - # -- UI/UX fields - has_private_remotes = fields.Boolean( - compute="_compute_has_private_remotes", - help="Indicates if the project has any private remotes.", - ) - has_partially_private_remotes = fields.Boolean( - compute="_compute_has_private_remotes", - help="Indicates if the project has any partially private remotes.", - ) - - # -- Git Aggregator related fields - git_aggregator_root_dir = fields.Char( - help="Git aggregator root directory where sources will be cloned." - " Eg '/tmp/git-aggregator'" - " Will use '.' if not set", - ) - - def _selection_project_format(self): - """ - Possible project formats. - Inherit and extend when adding new project formats. - - Returns: - List of tuples: (code, name) - """ - return [ - ("git_aggregator", "Git Aggregator"), - ] - - def _default_project_format(self): - """ - Default project format. - """ - return "git_aggregator" - - @api.depends("git_project_rel_ids", "git_project_rel_ids.server_id") - def _compute_server_ids(self): - """Compute server ids for git projects. - - Why? Because a git project can be linked to multiple files - on the same server. - So we need to use a set to avoid duplicates so every server - is listed only once. - """ - for project in self: - project.server_ids = ( - list(set(project.git_project_rel_ids.server_id.ids)) - if project.git_project_rel_ids - else False - ) - - @api.depends( - "git_project_rel_ids.server_id", - "git_project_rel_ids.server_id.user_ids", - "git_project_rel_ids.server_id.manager_ids", - ) - def _compute_user_ids(self): - """ - Users. All users who have "Manager" group and are either set in "Users" - or in "Managers" in all related servers. - Managers. All users who have "Manager" group and are set as "Managers" - in all related servers. - - This is done to avoid unpredictable consequences when some of the servers - are not updated due to access restrictions when a project is updated. - """ - for project in self: - # Do not compute if no servers are related - server_ids = project.git_project_rel_ids.server_id - if not server_ids: - continue - - # Get all user and manager ids from related servers - all_user_ids = server_ids.user_ids.filtered( - lambda u: u.has_group("cetmix_tower_server.group_manager") - ).ids - all_manager_ids = server_ids.manager_ids.ids - - # Create a final list of user and manager ids - user_ids = [] - manager_ids = [] - # Check if user is present in all servers - for user_id in all_user_ids: - if all( - user_id in server.user_ids.ids or user_id in server.manager_ids.ids - for server in server_ids - ): - user_ids.append(user_id) - # Check if manager is present in all servers - for manager_id in all_manager_ids: - if all(manager_id in server.manager_ids.ids for server in server_ids): - manager_ids.append(manager_id) - - # Set the final lists - project.update( - { - "user_ids": [(6, 0, user_ids)], - "manager_ids": [(6, 0, manager_ids)], - } - ) - - @api.depends( - "source_ids", "source_ids.remote_ids", "source_ids.remote_ids.is_private" - ) - def _compute_has_private_remotes(self): - for project in self: - project.has_private_remotes = any( - source.remote_count > 0 - and source.remote_count_private == source.remote_count - for source in project.source_ids - ) - project.has_partially_private_remotes = any( - source.remote_count_private > 0 - and source.remote_count_private != source.remote_count - for source in project.source_ids - ) - - @api.model_create_multi - def create(self, vals_list): - res = super().create(vals_list) - # Update related files and templates on create - res._update_related_files_and_templates() - return res - - def write(self, vals): - res = super().write(vals) - # Update related files and templates on update - self._update_related_files_and_templates() - return res - - # ------------------------------ - # Helper methods - # ------------------------------ - def _update_related_files_and_templates(self): - # Update related files and templates - if self.git_project_rel_ids: - self.git_project_rel_ids._save_to_file() - if self.git_project_file_template_rel_ids: - self.git_project_file_template_rel_ids._save_to_file_template() - - def _extract_variables_from_text(self, text): - """Extract environment variables from text. - Helper method for file content generation. - - Args: - text (str): Text to extract variables from - Returns: - List: List of variables - """ - variables = re.findall(r"\$([A-Z0-9_]+)", text) - return sorted(list(set(variables))) - - def _compose_copy_name(self, server=False): - """ - Compose copy name of a git project copy. - Helper method used when creating a copy of a git project. - - Args: - server (cx.tower.server): Server to get the copy name for. - - Returns: - Char: Copy name - """ - self.ensure_one() - if server: - return server.name - return _("%(name)s (copy)", name=self.name) - - # ------------------------------ - # YAML mixin methods - # ------------------------------ - def _get_fields_for_yaml(self): - res = super()._get_fields_for_yaml() - res += [ - "name", - "note", - "source_ids", - "git_aggregator_root_dir", - ] - return res - - # ------------------------------- - # Git Aggregator related methods - # ------------------------------- - def _git_aggregator_prepare_record(self): - """Prepare json structure for git aggregator. - - Returns: - Dict: Json structure for git aggregator - """ - self.ensure_one() - values = {} - for source in self.source_ids: - if source.enabled and source.remote_count: - root_dir = self.git_aggregator_root_dir or "." - values.update( - { - f"/{source.reference}" - if root_dir == "/" - else f"{root_dir}/{source.reference}": source._git_aggregator_prepare_record() # noqa: E501 - } - ) - return values - - def _git_aggregator_prepare_yaml_comment(self, yaml_code): - """Generate commentary for yaml file. - It includes brief instructions for git aggregator - and lists environment variables that are required. - - Args: - yaml_code (str): Yaml code - - Returns: - Char: comment text or None - """ - - comment_text = _( - "# This file is generated with Cetmix Tower https://cetmix.com/tower\n" - "# It's designed to be used with git-aggregator tool developed by Acsone.\n" - "# Documentation for git-aggregator: https://github.com/acsone/git-aggregator\n" - ) - variable_list = self._extract_variables_from_text(yaml_code) - if variable_list: - comment_text += _( - "\n# You need to set the following variables in your environment:\n# %(vars)s\n" # noqa: E501 - "# and run git-aggregator with '--expand-env' parameter.\n", # noqa: E501 - vars=(", ".join(variable_list)), - ) - return comment_text - - def _generate_code_git_aggregator(self, record): - """Generate code in git-aggregator format. - - Args: - record (recordset()): Model record to generate code for. - must be a single record and have git_project_id field. - - Returns: - Text: Yaml code - """ - yaml_mixin = self.env["cx.tower.yaml.mixin"] - - # Do not generate code if record values are empty - record_values = record.git_project_id._git_aggregator_prepare_record() - if record_values: - yaml_code = yaml_mixin._convert_dict_to_yaml(record_values) - # Prepend comment to yaml code - comment = record.git_project_id._git_aggregator_prepare_yaml_comment( - yaml_code - ) - return f"{comment}\n{yaml_code}" - return "" diff --git a/addons/cetmix_tower_git/models/cx_tower_git_project_file_template_rel.py b/addons/cetmix_tower_git/models/cx_tower_git_project_file_template_rel.py deleted file mode 100644 index def2f61..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_git_project_file_template_rel.py +++ /dev/null @@ -1,113 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import _, api, fields, models -from odoo.exceptions import ValidationError - - -class CxTowerGitProjectFileTemplateRel(models.Model): - """ - Relation between git projects and file templates. - """ - - _name = "cx.tower.git.project.file.template.rel" - _table = "cx_tower_git_project_file_template_rel" - _description = "Cetmix Tower Git Project relation to File Templates" - _log_access = False - - name = fields.Char(related="git_project_id.name", readonly=True) - git_project_id = fields.Many2one( - comodel_name="cx.tower.git.project", - index=True, - required=True, - ondelete="cascade", - ) - file_template_id = fields.Many2one( - comodel_name="cx.tower.file.template", - required=True, - ondelete="cascade", - ) - project_format = fields.Selection( - selection=lambda self: self.env[ - "cx.tower.git.project" - ]._selection_project_format(), - default=lambda self: self.env["cx.tower.git.project"]._default_project_format(), - required=True, - string="Format", - ) - - _sql_constraints = [ - ( - "project_server_file_format_uniq", - "unique(git_project_id, file_template_id, project_format)", - "File template is already related to the same project and format", - ), - ] - - @api.model_create_multi - def create(self, vals_list): - res = super().create(vals_list) - - # Export project to file - res._save_to_file_template() - return res - - def write(self, vals): - res = super().write(vals) - # Export project to file - self._save_to_file_template() - return res - - def action_open_file_template(self): - """ - Open file template record in current window - """ - self.ensure_one() - return { - "type": "ir.actions.act_window", - "name": self.file_template_id.name, - "res_model": "cx.tower.file.template", - "res_id": self.file_template_id.id, # pylint: disable=no-member - "view_mode": "form", - "view_type": "form", - "target": "current", - } - - # ---------------------------------------------------- - # Save project to linked file based on selected format - # ---------------------------------------------------- - def _save_to_file_template(self): - """Save project to linked file using format-specific function.""" - - # Get required function based on project format - # Following the pattern: _generate_code__ where format - # is one of the values in _selection_project_format - # Function gets a single record as an argument. - - # Save resolved functions to dict for faster access - code_generator_functions = {} - - for record in self: - code_generator_function = code_generator_functions.get( - record.project_format - ) - if not code_generator_function: - code_generator_function = getattr( - self.git_project_id, f"_generate_code_{record.project_format}", None - ) - if not code_generator_function: - raise ValidationError( - _( - "Code generator function for '%(project_format)s'" - " format not found.", - project_format=record.project_format, - ) - ) - code_generator_functions[ - record.project_format - ] = code_generator_function - - # Generate code for current record - code = code_generator_function(record) - if record.file_template_id.code != code: - record.file_template_id.write({"code": code}) diff --git a/addons/cetmix_tower_git/models/cx_tower_git_project_rel.py b/addons/cetmix_tower_git/models/cx_tower_git_project_rel.py deleted file mode 100644 index d9fba6b..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_git_project_rel.py +++ /dev/null @@ -1,177 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import _, api, fields, models -from odoo.exceptions import ValidationError - - -class CxTowerGitProjectRel(models.Model): - """ - Relation between git projects and other model records. - """ - - _name = "cx.tower.git.project.rel" - _inherit = [ - "cx.tower.reference.mixin", - "cx.tower.yaml.mixin", - ] - _table = "cx_tower_git_project_rel" - _description = "Cetmix Tower Git Project relation to Files and Servers" - _log_access = False - - name = fields.Char(related="git_project_id.name", readonly=True) - git_project_id = fields.Many2one( - comodel_name="cx.tower.git.project", - index=True, - required=True, - ondelete="cascade", - ) - server_id = fields.Many2one( - comodel_name="cx.tower.server", - index=True, - required=True, - ondelete="cascade", - ) - file_id = fields.Many2one( - comodel_name="cx.tower.file", - domain="[('server_id', '=', server_id)," - "('source', '=', 'tower')," - "('file_type', '=', 'text')]", - required=True, - ondelete="cascade", - ) - project_format = fields.Selection( - selection=lambda self: self.env[ - "cx.tower.git.project" - ]._selection_project_format(), - default=lambda self: self.env["cx.tower.git.project"]._default_project_format(), - required=True, - string="Format", - ) - auto_sync = fields.Boolean(related="file_id.auto_sync", readonly=False) - - _sql_constraints = [ - ( - "project_server_file_format_uniq", - "unique(git_project_id, file_id, project_format)", - "File is already related to the same project and format", - ), - ] - - @api.constrains("server_id", "file_id") - def _check_server_file_relation(self): - """ - Check if server and file are related. - """ - for record in self: - if ( - record.file_id.server_id - and record.server_id != record.file_id.server_id - ): - raise ValidationError( - _( - "File '%(file)s' doesn't belong to server '%(server)s'", - file=record.file_id.name, - server=record.server_id.name, - ) - ) - - @api.model_create_multi - def create(self, vals_list): - res = super().create(vals_list) - - # Export project to file - res._save_to_file() - return res - - def write(self, vals): - res = super().write(vals) - # Export project to file - self._save_to_file() - return res - - def action_open_project(self): - """ - Open project record in current window - """ - self.ensure_one() - return { - "type": "ir.actions.act_window", - "name": self.name, - "res_model": "cx.tower.git.project", - "res_id": self.git_project_id.id, # pylint: disable=no-member - "view_mode": "form", - "view_type": "form", - "target": "current", - } - - def action_open_server(self): - """ - Open server record in current window - """ - self.ensure_one() - return { - "type": "ir.actions.act_window", - "name": self.server_id.name, - "res_model": "cx.tower.server", - "res_id": self.server_id.id, # pylint: disable=no-member - "view_mode": "form", - "view_type": "form", - "target": "current", - } - - # ---------------------------------------------------- - # Save project to linked file based on selected format - # ---------------------------------------------------- - def _save_to_file(self): - """Save project to linked file using format-specific function.""" - - # Get required function based on project format - # Following the pattern: _generate_code_ where format - # is one of the values in _selection_project_format - # Function gets a single record as an argument. - - # Save resolved functions to dict for faster access - code_generator_functions = {} - - for record in self: - # Disconnect file from file template if it is connected - if record.file_id.template_id: - record.file_id.action_unlink_from_template() - - code_generator_function = code_generator_functions.get( - record.project_format - ) - if not code_generator_function: - code_generator_function = getattr( - self.git_project_id, f"_generate_code_{record.project_format}", None - ) - if not code_generator_function: - raise ValidationError( - _( - "Code generator function for '%(project_format)s'" - " format not found.", - project_format=record.project_format, - ) - ) - code_generator_functions[ - record.project_format - ] = code_generator_function - - # Generate code for current record - code = code_generator_function(record) - if record.file_id.code != code: - record.file_id.write({"code": code}) - - # ------------------------------ - # YAML mixin methods - # ------------------------------ - def _get_fields_for_yaml(self): - res = super()._get_fields_for_yaml() - res += [ - "file_id", - "git_project_id", - "project_format", - "auto_sync", - ] - return res diff --git a/addons/cetmix_tower_git/models/cx_tower_git_remote.py b/addons/cetmix_tower_git/models/cx_tower_git_remote.py deleted file mode 100644 index 7c556a4..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_git_remote.py +++ /dev/null @@ -1,415 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import giturlparse - -from odoo import _, api, fields, models -from odoo.exceptions import ValidationError - - -class CxTowerGitRemote(models.Model): - """ - Git Remote. - Implements single git remote. - Eg a branch or a pull request. - """ - - _name = "cx.tower.git.remote" - _inherit = [ - "cx.tower.reference.mixin", - "cx.tower.yaml.mixin", - ] - _description = "Cetmix Tower Git Remote" - _order = "sequence, name" - - # Used to detect git ssh urls - GIT_SSH_URL_PATTERN = r"^[\w\.-]+@[\w\.-]+:.*\.git$" - GIT_HTTPS_URL_PATTERN = r"^https://.*\.git$" - GIT_GIT_URL_PATTERN = r"^git://.*\.git$" - - active = fields.Boolean(related="source_id.active", store=True, readonly=True) - enabled = fields.Boolean( - default=True, help="Enable in configuration and exported to files" - ) - sequence = fields.Integer(default=10) - name = fields.Char(compute="_compute_name", store=True, default="remote") - source_id = fields.Many2one( - comodel_name="cx.tower.git.source", - required=True, - ondelete="cascade", - auto_join=True, - ) - git_project_id = fields.Many2one( - comodel_name="cx.tower.git.project", - related="source_id.git_project_id", - store=True, - readonly=True, - ) - repo_id = fields.Many2one( - comodel_name="cx.tower.git.repo", - string="Repository", - required=True, - ondelete="restrict", - help="If selected, the remote URL will be filled from the" - " repo settings based on the remote protocol", - ) - repo_provider = fields.Selection( - related="repo_id.provider", - readonly=True, - ) - # -- Repo related fields - url_protocol = fields.Selection( - string="Protocol", - selection=[ - ("ssh", "SSH"), - ("https", "HTTPS"), - ("git", "GIT"), - ], - required=True, - default=lambda self: self._get_default_url_protocol(), - ) - is_private = fields.Boolean( - string="Private", - help="Repository is private", - related="repo_id.is_private", - store=True, - readonly=True, - ) - head_type = fields.Selection( - selection=[ - ("branch", "Branch"), - ("pr", "Pull/Merge Request"), - ("commit", "Commit"), - ], - required=True, - ) - head = fields.Char( - help="Git remote head. Link to branch, PR, commit or commit hash.", - required=True, - index=True, - ) - - def _get_default_url_protocol(self): - """Default URL protocol for new remote. - - Returns: - Char: Default URL protocol. - """ - return "https" - - @api.depends("source_id", "sequence") - def _compute_name(self): - """ - Compute remote name. - By default all remotes are named `remote_` - where position is the position of the remote in the source. - Eg first remote is `remote_1`, second is `remote_2`, etc. - """ - for remote in self: - if remote.source_id: - for index, source_remote in enumerate(remote.source_id.remote_ids): - source_remote.name = f"remote_{index + 1}" - - @api.onchange("head") - def onchange_head(self): - """ - Extract head number from head url - and set it as head. - """ - for remote in self: - if remote.head and "/" in remote.head: - remote.head = self._sanitize_head(remote.head) - - @api.model_create_multi - def create(self, vals_list): - # Sanitize head - for vals in vals_list: - head = vals.get("head") - if head and "/" in head: - vals["head"] = self._sanitize_head(head) - res = super().create(vals_list) - # Export project to related files and templates - res._update_related_files_and_templates() - return res - - def write(self, vals): - # Sanitize head - if "head" in vals: - head = vals["head"] - if head and "/" in head: - vals["head"] = self._sanitize_head(head) - res = super().write(vals) - # Update related files and templates on update - self._update_related_files_and_templates() - return res - - def unlink(self): - """ - Override to update related files and templates on unlink - """ - related_files = self.mapped("git_project_id").mapped("git_project_rel_ids") - related_templates = self.mapped("git_project_id").mapped( - "git_project_file_template_rel_ids" - ) - res = super().unlink() - - # Update related files and templates on unlink - if related_files: - related_files._save_to_file() - if related_templates: - related_templates._save_to_file_template() - return res - - def _sanitize_head(self, head): - """Sanitize head. - Extract head number from head url - and set it as head. - - Args: - head (Char): Head to sanitize - - Returns: - Char: Sanitized head - """ - if head and "/" in head: - return head.split("/")[-1].strip() - return head - - @api.model - def get_head_data(self): - """ - This method is used to get values for the dropdown dynamic widget. - It is designed for integrations with repo providers using APIs. - - Returns: - List: List of tuples(selection, name) - eg [('18.0', '18.0'), ('main', 'main'), ('develop', 'develop')] - """ - values = [ - ("18.0", "18.0"), - ("main", "Main"), - ("develop", "Develop"), - ("17.0", "17.0"), - ] - return values - - def _update_related_files_and_templates(self): - # Update related files on update - related_files = self.mapped("git_project_id").mapped("git_project_rel_ids") - if related_files: - related_files._save_to_file() - related_templates = self.mapped("git_project_id").mapped( - "git_project_file_template_rel_ids" - ) - if related_templates: - related_templates._save_to_file_template() - - # ------------------------------ - # Reference mixin methods - # ------------------------------ - def _get_pre_populated_model_data(self): - res = super()._get_pre_populated_model_data() - res.update({"cx.tower.git.remote": ["cx.tower.git.source", "source_id"]}) - return res - - # ------------------------------ - # YAML mixin methods - # ------------------------------ - def _get_fields_for_yaml(self): - res = super()._get_fields_for_yaml() - res += [ - "name", - "enabled", - "sequence", - "repo_id", - "head", - "head_type", - ] - return res - - # ------------------------------ - # Git Aggregator related methods - # ------------------------------ - def _git_aggregator_prepare_url(self): - """Prepare url for git aggregator - - Returns: - Char: Prepared url for git aggregator - """ - self.ensure_one() - - if not self.repo_id: - raise ValidationError(_("Repository is required")) - if not self.repo_id.url: - raise ValidationError(_("Repository URL is not set")) - - url = self.repo_id.url - prepared_url = giturlparse.parse(url).urls.get(self.url_protocol, url) - - # If repo is public or is not using HTTPS protocol return URL as is - if not self.is_private or self.url_protocol != "https": - return prepared_url - - if self.repo_provider == "github": - prepared_url = self._git_aggregator_prepare_url_github(prepared_url) - elif self.repo_provider == "gitlab": - prepared_url = self._git_aggregator_prepare_url_gitlab(prepared_url) - elif self.repo_provider == "bitbucket": - prepared_url = self._git_aggregator_prepare_url_bitbucket(prepared_url) - - return prepared_url - - def _git_aggregator_prepare_url_github(self, url): - """Prepare url for git aggregator - for private Github repo using https protocol. - - Args: - url (Char): URL to prepare - - Returns: - Char: Prepared url for git aggregator - """ - self.ensure_one() - - # This is how final url will look like - # https://$GITHUB_TOKEN:x-oauth-basic@github.com/soem_org/some_private_repo.git - url_without_protocol = url.replace("https://", "") - url = f"https://$GITHUB_TOKEN:x-oauth-basic@{url_without_protocol}" - return url - - def _git_aggregator_prepare_url_gitlab(self, url): - """Prepare url for git aggregator - for private GitLab repo using https protocol. - - Args: - url (Char): URL to prepare - - Returns: - Char: Prepared url for git aggregator - """ - self.ensure_one() - - # This is how final url will look like - # https://:@.git - url_without_protocol = url.replace("https://", "") - url = f"https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@{url_without_protocol}" - return url - - def _git_aggregator_prepare_url_bitbucket(self, url): - """Prepare url for git aggregator - for private Github repo using https protocol. - - Args: - url (Char): URL to prepare - - Returns: - Char: Prepared url for git aggregator - """ - self.ensure_one() - - # This is how final url will look like - # https://x-token-auth:{access_token}@bitbucket.org/user/repo.git - # From https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/ - url_without_protocol = url.replace("https://", "") - url = f"https://x-token-auth:$BITBUCKET_TOKEN@{url_without_protocol}" - return url - - def _git_aggregator_prepare_head(self): - """Prepare head for git aggregator - - Returns: - Char: Prepared head for git aggregator - """ - self.ensure_one() - if self.repo_provider == "github": - return self._git_aggregator_prepare_head_github() - if self.repo_provider == "gitlab": - return self._git_aggregator_prepare_head_gitlab() - if self.repo_provider == "bitbucket": - return self._git_aggregator_prepare_head_bitbucket() - return self.head - - def _git_aggregator_prepare_head_github(self): - """Prepare head for git aggregator for Github. - - Returns: - Char: Prepared head for git aggregator - """ - - # Extract branch name, PR/MR or commit number from head - head_number = self.head.split("/")[-1] - if not head_number: - raise ValidationError( - _("Git Aggregator: " "Head number is empty in %(head)s", head=self.head) - ) - - # PR/MR - if self.head_type == "pr": - return f"refs/pull/{head_number}/head" - - # Commit - if self.head_type in ["commit", "branch"]: - return f"{head_number}" - - # Fallback to original head - return self.head - - def _git_aggregator_prepare_head_gitlab(self): - """Prepare head for git aggregator for GitLab. - - Returns: - Char: Prepared head for git aggregator - """ - # Extract branch name, PR/MR or commit number from head - head_number = self.head.split("/")[-1] - if not head_number: - raise ValidationError( - _("Git Aggregator: " "Head number is empty in %(head)s", head=self.head) - ) - - # PR/MR - if self.head_type == "pr": - return f"merge-requests/{head_number}/head" - - # Commit - # https://gitlab.com/cetmix/test/-/tree/17.0-test-branch?ref_type=heads - if self.head_type in ["commit", "branch"]: - head_parts = head_number.split("?") - return f"{head_parts[0]}" - - # Fallback to original head - return self.head - - def _git_aggregator_prepare_head_bitbucket(self): - """Prepare head for git aggregator for Bitbucket. - - Returns: - Char: Prepared head for git aggregator - """ - # Extract branch name, PR/MR or commit number from head - head_number = self.head.split("/")[-1] - if not head_number: - raise ValidationError( - _("Git Aggregator: " "Head number is empty in %(head)s", head=self.head) - ) - # PR/MR - if self.head_type == "pr": - raise ValidationError( - _( - "Git Aggregator: " - "Bitbucket does not support" - " fetching PRs. Please use branch instead.\n\n" - "Source: %(src)s\n" - "URL: %(url)s\n" - "Head: %(head)s", - src=self.source_id.name, - url=self.repo_id.url, - head=self.head, - ) - ) - - # Commit - if self.head_type in ["commit", "branch"]: - return f"{head_number}" - - # Fallback to original head - return self.head diff --git a/addons/cetmix_tower_git/models/cx_tower_git_repo.py b/addons/cetmix_tower_git/models/cx_tower_git_repo.py deleted file mode 100644 index e8dc3a7..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_git_repo.py +++ /dev/null @@ -1,409 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -import giturlparse - -from odoo import _, api, fields, models -from odoo.exceptions import ValidationError -from odoo.tools import ormcache - - -class CxTowerGitRepo(models.Model): - """ - Git Repository. - Represents a git repository with its metadata and configuration. - """ - - _name = "cx.tower.git.repo" - _inherit = [ - "cx.tower.reference.mixin", - "cx.tower.yaml.mixin", - ] - _description = "Cetmix Tower Git Repository" - _order = "name" - _rec_names_search = ["repo", "host", "owner_id"] - - active = fields.Boolean(default=True, help="Indicates if the repository is active") - name = fields.Char( - compute="_compute_name", store=True, required=False, index="trigram" - ) - reference = fields.Char( - index=True, - compute="_compute_name", - required=False, - store=True, - ) - repo = fields.Char( - string="Repository Name", - readonly=True, - help="Repository name (e.g., 'cetmix-tower', 'odoo')", - ) - url = fields.Char( - string="Generic URL", - help="Displayed in 'https' format, but can be entered in any format", - compute="_compute_url", - inverse="_inverse_url", - required=True, - compute_sudo=True, - ) - url_ssh = fields.Char( - string="SSH URL", - help="SSH URL of the repository", - compute="_compute_url", - compute_sudo=True, - ) - url_git = fields.Char( - string="GIT URL", - help="GIT URL of the repository", - compute="_compute_url", - compute_sudo=True, - ) - is_private = fields.Boolean( - string="Private", default=False, help="Indicates if the repository is private" - ) - provider = fields.Selection( - selection="_selection_provider", - required=True, - default="other", - help="Repository provider to determine provider-based behaviour", - ) - host = fields.Char( - readonly=True, - index=True, - help="Repository host (e.g., 'github.com', 'gitlab.com')", - ) - owner_id = fields.Many2one( - comodel_name="cx.tower.git.repo.owner", - readonly=True, - help="Repository owner (e.g., 'cetmix' or 'OCA')", - ) - secret_id = fields.Many2one( - comodel_name="cx.tower.key", - string="Secret", - domain="[('key_type', '=', 's')]", - help="Custom secret used for this repository", - ) - remote_ids = fields.One2many( - comodel_name="cx.tower.git.remote", - inverse_name="repo_id", - help="Remotes that use this repository", - ) - git_project_ids = fields.Many2many( - comodel_name="cx.tower.git.project", - relation="cx_tower_git_repo_project_rel", - column1="repo_id", - column2="project_id", - compute="_compute_git_project_ids", - store=True, - help="Projects this repository is used in", - ) - remote_count = fields.Integer( - compute="_compute_remote_count", - help="Number of remotes this repository is used in", - ) - git_project_count = fields.Integer( - compute="_compute_git_project_count", - help="Number of projects this repository is used in", - ) - - _sql_constraints = [ - ( - "unique_repo_host_owner", - "unique(repo, host, owner_id)", - "A repository with the same name, host, and owner already exists.", - ), - ] - - # -- Selection - def _selection_provider(self): - """Available repository providers. - - Returns: - List of tuples: available options. - """ - return [ - ("github", "GitHub"), - ("gitlab", "GitLab"), - ("bitbucket", "Bitbucket"), - ("assembla", "Assembla"), - ("other", "Other"), - ] - - # -- Computes - @api.depends("host", "owner_id", "repo") - def _compute_name(self): - """ - Compute name in format: host/owner/name. - Compute reference based on name. - """ - for repo in self: - if repo.host and repo.owner_id and repo.repo: - name = f"{repo.host}/{repo.owner_id.name}/{repo.repo}" - reference = repo._generate_or_fix_reference(name) - repo.update( - { - "name": name, - "reference": reference, - } - ) - else: - repo.update( - { - "name": False, - "reference": False, - } - ) - - @api.depends("remote_ids", "remote_ids.git_project_id") - def _compute_git_project_ids(self): - """Compute projects this repository is used in.""" - for repo in self: - projects = repo.remote_ids.mapped("git_project_id") - repo.git_project_ids = [(6, 0, projects.ids)] - - @api.depends("remote_ids") - def _compute_remote_count(self): - """Compute remote count field.""" - for repo in self: - repo.remote_count = len(repo.remote_ids) - - @api.depends("git_project_ids") - def _compute_git_project_count(self): - """Compute project count field.""" - for repo in self: - repo.git_project_count = len(repo.git_project_ids) - - @api.depends("repo", "host", "owner_id") - def _compute_url(self): - """Compute URL from repository properties.""" - for repo in self: - if repo.repo and repo.host and repo.owner_id: - https_url = f"https://{repo.host}/{repo.owner_id.name}/{repo.repo}.git" - elif repo.repo and repo.host: - https_url = f"https://{repo.host}/{repo.repo}.git" - else: - https_url = "" - if https_url: - try: - parsed_urls = giturlparse.parse(https_url).urls - urls = { - "url": https_url, - "url_ssh": parsed_urls["ssh"], - "url_git": parsed_urls["git"], - } - except Exception as e: # noqa: F841 catch all errors - urls = { - "url": "", - "url_ssh": "", - "url_git": "", - } - else: - urls = { - "url": "", - "url_ssh": "", - "url_git": "", - } - repo.update(urls) - - def _inverse_url(self): - """Parse URL to update repository properties.""" - for repo in self: - if not repo.url: - continue - # Parse URL - parsed_url_dict = self._parse_url(repo.url) - # Update repository properties - repo.update(parsed_url_dict) - - def action_view_remotes(self): - """Open remotes list view.""" - self.ensure_one() - action = self.env["ir.actions.actions"]._for_xml_id( - "cetmix_tower_git.action_cx_tower_git_remote" - ) - action.update( - { - "domain": [("repo_id", "=", self.id)], - "context": {"default_repo_id": self.id}, - } - ) - return action - - def action_view_projects(self): - """Open projects list view.""" - self.ensure_one() - action = self.env["ir.actions.actions"]._for_xml_id( - "cetmix_tower_git.cx_tower_git_project_action" - ) - action.update( - { - "domain": [("repo_ids", "in", self.id)], - "context": {"default_repo_ids": [(4, self.id)]}, - } - ) - return action - - @api.model_create_multi - def create(self, vals_list): - """Create multiple repositories.""" - # Check if any of the repositories already exist - # This is needed to allow creating repositories using just an URL. - # Eg when importing repositories from a YAML file. - res = self.browse() - existing_repo_ids = [] - vals_list_to_create = [] - for vals in vals_list: - url = vals.get("url") - if url: - # Try to get repository by URL - repo_id = self._get_repo_id_by_url( - url=url, create=False, raise_if_invalid=False - ) - if repo_id: - existing_repo_ids.append(repo_id) - continue - # Parse URL and update vals - parsed_url_dict = self._parse_url(url=url, raise_if_invalid=True) - vals.update(parsed_url_dict) - # Otherwise, add to create list - vals_list_to_create.append(vals) - # Compose the result - if vals_list_to_create: - res |= super().create(vals_list_to_create) - if existing_repo_ids: - res |= self.browse(existing_repo_ids) - self.clear_caches() - return res - - def write(self, vals): - """Write repositories.""" - res = super().write(vals) - self.clear_caches() - return res - - def unlink(self): - """Unlink repositories.""" - res = super().unlink() - self.clear_caches() - return res - - @api.model - def name_create(self, name): - """ - Create a new repository from a URL. - """ - repo_id = self._get_repo_id_by_url(url=name, create=True, raise_if_invalid=True) - repo = self.browse(repo_id) - - return repo_id, repo.display_name - - @ormcache("self.env.uid", "self.env.su", "url") - def _get_repo_id_by_url(self, url, create=False, raise_if_invalid=False): - """Get repository id by URL. - - Args: - url (Char): URL to get repository id - create (Bool, optional): Create repository if not found. - Default is False. - raise_if_invalid (Bool, optional): Raise ValidationError - if the URL is not valid. Default is False. - - Returns: - int: Repository ID - or False if the URL is not valid and raise_if_invalid is False - - Raises: - ValidationError: If the URL is not valid and raise_if_invalid is True - """ - # Parse URL - parsed_url_dict = self._parse_url(url, raise_if_invalid=raise_if_invalid) - if not parsed_url_dict: - return False - - # Check if repository already exists and use it - repo = self.search( - [ - ("repo", "=", parsed_url_dict["repo"]), - ("host", "=", parsed_url_dict["host"]), - ("owner_id", "=", parsed_url_dict["owner_id"]), - ], - limit=1, - ) - - # Otherwise, create a new one - if not repo and create: - repo = self.create(parsed_url_dict) - - return repo.id if repo else False - - def _parse_url(self, url, raise_if_invalid=True): - """Parse URL to get name, host and owner. - - Args: - url (Char): URL to parse - - Raises: - ValidationError: If the URL is not valid - - Returns: - Dict: Dictionary with name, host and owner - or empty dict if the URL is not valid and raise_if_invalid is False - """ - - # Validate URL - if not giturlparse.validate(url): - if raise_if_invalid: - raise ValidationError(_("Not a valid repository URL!")) - return {} - - # Parse URL - parsed_url = giturlparse.parse(url) - - # Get or create owner - owner_id = self.env["cx.tower.git.repo.owner"]._get_owner_id_by_name( - name=parsed_url.owner, - create=True, - ) - - # Get provider based on host - provider = self._get_provider(parsed_url) - - return { - "repo": parsed_url.repo, - "host": parsed_url.host, - "owner_id": owner_id, - "provider": provider, - } - - def _get_provider(self, parsed_url): - """Get provider. - - Args: - parsed_url (GitUrlParsed): Parsed URL object - - Returns: - str: Provider name - """ - provider = "other" - if parsed_url.assembla: - provider = "assembla" - elif parsed_url.bitbucket or "bitbucket" in parsed_url.host: - provider = "bitbucket" - elif parsed_url.gitlab: - provider = "gitlab" - elif parsed_url.github: - provider = "github" - - return provider - - # ------------------------------ - # YAML mixin methods - # ------------------------------ - def _get_fields_for_yaml(self): - res = super()._get_fields_for_yaml() - res += [ - "url", - "is_private", - "secret_id", - ] - return res diff --git a/addons/cetmix_tower_git/models/cx_tower_git_repo_owner.py b/addons/cetmix_tower_git/models/cx_tower_git_repo_owner.py deleted file mode 100644 index 204f3d4..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_git_repo_owner.py +++ /dev/null @@ -1,107 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import api, fields, models -from odoo.tools import ormcache - - -class CxTowerGitRepoOwner(models.Model): - """ - Git Repository Owner. - Represents an organization or user that owns repositories. - Examples: "cetmix", "OCA", etc. - """ - - _name = "cx.tower.git.repo.owner" - _inherit = ["cx.tower.reference.mixin", "cx.tower.yaml.mixin"] - _description = "Cetmix Tower Git Repository Owner" - _order = "name" - - display_name = fields.Char( - readonly=False, compute="_compute_display_name", store=True - ) - - name = fields.Char( - help="Name of the repository owner (e.g., 'cetmix', 'OCA')", - ) - reference = fields.Char( - index=True, - compute="_compute_display_name", - required=False, - store=True, - ) - repo_ids = fields.One2many( - comodel_name="cx.tower.git.repo", - inverse_name="owner_id", - string="Repositories", - copy=False, - help="Repositories owned by this organization/user", - ) - secret_id = fields.Many2one( - comodel_name="cx.tower.key", - string="Secret", - domain="[('key_type', '=', 's')]", - help="Custom secret used for this repository owner", - ) - - @api.depends("name") - def _compute_display_name(self): - """Compute display name.""" - for owner in self: - # By default, display name is the same as name - name = owner.name - owner.update( - { - "display_name": name or False, - "reference": owner._generate_or_fix_reference(name) - if name - else False, - } - ) - - @ormcache("self.env.uid", "self.env.su", "name") - def _get_owner_id_by_name(self, name, create=False): - """Get owner id by name. - - Args: - name (str): Owner name - create (bool): Create owner if not found - Returns: - int: Owner ID or None if not found - """ - owner = self.search([("name", "=ilike", name)], limit=1) if name else None - if not owner and create and name: - owner = self.create({"name": name}) - return owner.id if owner else None - - @api.model_create_multi - def create(self, vals_list): - """Clear cache on create.""" - res = super().create(vals_list) - self.clear_caches() - return res - - def write(self, vals): - """Clear cache on write.""" - res = super().write(vals) - if "name" in vals: - self.clear_caches() - return res - - def unlink(self): - """Clear cache on unlink.""" - res = super().unlink() - self.clear_caches() - return res - - # ------------------------------ - # YAML mixin methods - # ------------------------------ - def _get_fields_for_yaml(self): - res = super()._get_fields_for_yaml() - res += [ - "display_name", - "name", - "secret_id", - ] - return res diff --git a/addons/cetmix_tower_git/models/cx_tower_git_source.py b/addons/cetmix_tower_git/models/cx_tower_git_source.py deleted file mode 100644 index a850431..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_git_source.py +++ /dev/null @@ -1,189 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models - - -class CxTowerGitSource(models.Model): - """ - Git Source. - Implements single git source. - Each source can include multiple remotes which can be - branches or pull requests of different repositories. - """ - - _name = "cx.tower.git.source" - _description = "Cetmix Tower Git Source" - - _inherit = [ - "cx.tower.reference.mixin", - "cx.tower.yaml.mixin", - ] - _order = "sequence, name" - - active = fields.Boolean(related="git_project_id.active", store=True, readonly=True) - enabled = fields.Boolean( - default=True, help="Enable in configuration and exported to files" - ) - name = fields.Char(required=False) - sequence = fields.Integer(default=10) - git_project_id = fields.Many2one( - comodel_name="cx.tower.git.project", - string="Git Configuration", - required=True, - ondelete="cascade", - auto_join=True, - ) - - remote_ids = fields.One2many( - comodel_name="cx.tower.git.remote", - inverse_name="source_id", - auto_join=True, - copy=True, - ) - remote_count = fields.Integer( - compute="_compute_remote_count", - string="Remotes", - ) - remote_count_private = fields.Integer( - compute="_compute_remote_count", - string="Private Remotes", - ) - - @api.depends("remote_ids", "remote_ids.enabled", "remote_ids.is_private") - def _compute_remote_count(self): - for record in self: - remote_count = private_remote_count = 0 - for remote in record.remote_ids: - if not remote.enabled: - continue - if remote.is_private: - private_remote_count += 1 - remote_count += 1 - record.update( - { - "remote_count": remote_count, - "remote_count_private": private_remote_count, - } - ) - - @api.model_create_multi - def create(self, vals_list): - res = super().create(vals_list) - # Update name - res._compose_name() - # Update related files and templates on create - res._update_related_files_and_templates() - return res - - def write(self, vals): - res = super().write(vals) - # Compose name - if "name" in vals and not vals.get("name"): - self._compose_name() - # Update related files and templates on update - self._update_related_files_and_templates() - return res - - def unlink(self): - """ - Override to update related files and templates on unlink - """ - related_files = self.mapped("git_project_id").mapped("git_project_rel_ids") - related_templates = self.mapped("git_project_id").mapped( - "git_project_file_template_rel_ids" - ) - res = super().unlink() - # Update related files and templates on unlink - if related_files: - related_files._save_to_file() - if related_templates: - related_templates._save_to_file_template() - return res - - def _compose_name(self): - """Compose name if not provided explicitly""" - for source in self: - if source.name: - continue - remote = fields.first(source.remote_ids) - if not remote: - source.name = _("Empty Source") - continue - - remote_repo = remote.repo_id - source.name = f"{remote_repo.owner_id.name}/{remote_repo.repo}" - - def _update_related_files_and_templates(self): - # Update related files and templates on update - related_files = self.mapped("git_project_id").mapped("git_project_rel_ids") - if related_files: - related_files._save_to_file() - related_templates = self.mapped("git_project_id").mapped( - "git_project_file_template_rel_ids" - ) - if related_templates: - related_templates._save_to_file_template() - - # ------------------------------ - # Reference mixin methods - # ------------------------------ - def _get_pre_populated_model_data(self): - res = super()._get_pre_populated_model_data() - res.update({"cx.tower.git.source": ["cx.tower.git.project", "git_project_id"]}) - return res - - # ------------------------------ - # YAML mixin methods - # ------------------------------ - def _get_fields_for_yaml(self): - res = super()._get_fields_for_yaml() - res += [ - "name", - "enabled", - "sequence", - "remote_ids", - ] - return res - - # ------------------------------ - # Git Aggregator related methods - # ------------------------------ - def _git_aggregator_prepare_record(self): - """Prepare json structure for git aggregator. - - Returns: - Dict: Json structure for git aggregator - """ - self.ensure_one() - - # Prepare remotes, merges and target - remotes = {} - merges = [] - target = None - for remote in self.remote_ids: - if remote.enabled: - remotes.update({remote.name: remote._git_aggregator_prepare_url()}) - merges.append( - { - "remote": remote.name, - "ref": remote._git_aggregator_prepare_head(), - } - ) - # Set target to first remote name - if not target: - target = remote.name - - # If no remotes, return empty dict - if not remotes: - return {} - - vals = { - "remotes": remotes, - "merges": merges, - "target": target, - } - - # Fetch only first commit if there is only one remote - if len(remotes) == 1: - vals.update({"defaults": {"depth": 1}}) - return vals diff --git a/addons/cetmix_tower_git/models/cx_tower_plan_line.py b/addons/cetmix_tower_git/models/cx_tower_plan_line.py deleted file mode 100644 index 0c676c0..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_plan_line.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import fields, models - - -class CxTowerPlanLine(models.Model): - """Flight Plan Line""" - - _inherit = "cx.tower.plan.line" - - git_project_id = fields.Many2one( - comodel_name="cx.tower.git.project", - string="Git Project", - help="Select a git project to be linked to the file and server.", - ) - is_make_copy = fields.Boolean( - string="Make a Copy", - help="Create a copy of the Git Project instead of linking " - "the file to the existing one.", - ) - - # ------------------------------ - # YAML mixin methods - # ------------------------------ - def _get_fields_for_yaml(self): - res = super()._get_fields_for_yaml() - res += [ - "git_project_id", - "is_make_copy", - ] - return res diff --git a/addons/cetmix_tower_git/models/cx_tower_server.py b/addons/cetmix_tower_git/models/cx_tower_server.py deleted file mode 100644 index 55c9c56..0000000 --- a/addons/cetmix_tower_git/models/cx_tower_server.py +++ /dev/null @@ -1,187 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -import logging - -from odoo import api, fields, models - -_logger = logging.getLogger(__name__) - - -class CxTowerServer(models.Model): - _inherit = "cx.tower.server" - - git_project_rel_ids = fields.One2many( - comodel_name="cx.tower.git.project.rel", - inverse_name="server_id", - copy=False, - depends=["git_project_ids"], - groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root", - ) - - # Helper field to get all git projects related to server - # IMPORTANT: This field may contain duplicates because of the relation nature! - git_project_ids = fields.Many2many( - comodel_name="cx.tower.git.project", - relation="cx_tower_git_project_rel", - column1="server_id", - column2="git_project_id", - readonly=True, - copy=False, - depends=["git_project_rel_ids"], - groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root", - ) - - # ------------------------------ - # YAML mixin methods - # ------------------------------ - def _get_fields_for_yaml(self): - res = super()._get_fields_for_yaml() - res += [ - "git_project_rel_ids", - ] - return res - - def _get_force_x2m_resolve_models(self): - res = super()._get_force_x2m_resolve_models() - - # Add File in order to always try to use existing one - res += ["cx.tower.file"] - return res - - def _update_or_create_related_record( - self, model, reference, values, create_immediately=False - ): - # Files must be created immediately because they are related - # to both server and git project. - # So if a file is not created immediately when it is created - # for the server, the same file will be created for the git project. - # This will lead to creation of two files with the same content - # for the same server. - - if model._name == "cx.tower.file": - create_immediately = True - return super()._update_or_create_related_record( - model, reference, values, create_immediately=create_immediately - ) - - @api.model - def get_servers_by_git_ref(self, repository_url, head=None, head_type=None): - """ - Return servers linked to a given Git repository reference. - - Parameters - ---------- - repository_url : str - Pre-normalized canonical Git URL - (e.g. ``https://host/owner/repo.git``). - head : str, optional - Branch name, commit SHA, or PR identifier. - head_type : {'branch', 'commit', 'pr'}, optional - Type of the ``head`` argument. - If only ``head`` is provided, it will match across all head types. - If only ``head_type`` is provided, it will filter by type regardless of head - - Returns - ------- - recordset of cx.tower.server - Matching servers. Empty recordset if no matches. - """ - - server_obj = self.env["cx.tower.server"] - # URL MUST be already canonical. - if not repository_url: - return server_obj - - # Get repository id by URL - repo_id = self.env["cx.tower.git.repo"]._get_repo_id_by_url( - repository_url, raise_if_invalid=False - ) - if not repo_id: - return server_obj - repo = self.env["cx.tower.git.repo"].browse(repo_id) - - # Compose domain for remotes - remote_domain = [ - ("source_id.enabled", "=", True), - ("enabled", "=", True), - ] - if head: - head = self.env["cx.tower.git.remote"]._sanitize_head(head) - remote_domain.append(("head", "=", head)) - if head_type: - remote_domain.append(("head_type", "=", head_type)) - - # Get remotes - remotes = repo.remote_ids.filtered_domain(remote_domain) - if not remotes: - return server_obj - - # Get servers from remotes - servers = remotes.mapped("git_project_id.git_project_rel_ids.server_id") - return servers - - def _command_runner_file_using_template_create_file( - self, - log_record, - server_dir, - **kwargs, - ): - """Override to create git project relation - when creating a file using a template. - """ - file = super()._command_runner_file_using_template_create_file( - log_record, server_dir, **kwargs - ) - if file: - # Get the flight plan line from log record - plan_line = log_record.plan_log_id.plan_line_executed_id - # Try to get git project from custom values - custom_values = log_record.variable_values - git_project_reference = custom_values and custom_values.get( - "__git_project__" - ) - if git_project_reference: - git_project = self.env["cx.tower.git.project"].get_by_reference( - git_project_reference - ) - if not git_project: - _logger.warning( - "Git project '%s' provided with the `__git_project__` " - "custom value not found for server '%s' " - "in flight plan line '%s' " - "of the flight plan '%s'. " - "No project was linked to the file '%s'.", - git_project_reference, - self.name, - plan_line.name, - log_record.plan_log_id.plan_id.name, - file.name, - ) - - # Try to get git project set explicitly in the flight plan line - else: - git_project = plan_line.git_project_id - if not git_project: - return file - - if plan_line.is_make_copy: - # Remove default_server_ids from context, because this relation - # will be created through git_project_rel_ids. - # default_server_ids will interfere at the moment when - # pairs of values are created through SQL query - # in the method write_real and it does not take into account - # that in this case we are creating a copy of the git project - git_project = git_project.with_context(default_server_ids=False).copy( - {"name": git_project._compose_copy_name(server=self)} - ) - - self.env["cx.tower.git.project.rel"].create( - { - "git_project_id": git_project.id, - "server_id": self.id, - "file_id": file.id, - "project_format": git_project._default_project_format(), - } - ) - return file diff --git a/addons/cetmix_tower_git/pyproject.toml b/addons/cetmix_tower_git/pyproject.toml deleted file mode 100644 index 4231d0c..0000000 --- a/addons/cetmix_tower_git/pyproject.toml +++ /dev/null @@ -1,3 +0,0 @@ -[build-system] -requires = ["whool"] -build-backend = "whool.buildapi" diff --git a/addons/cetmix_tower_git/readme/CONFIGURE.md b/addons/cetmix_tower_git/readme/CONFIGURE.md deleted file mode 100644 index 8c717e5..0000000 --- a/addons/cetmix_tower_git/readme/CONFIGURE.md +++ /dev/null @@ -1 +0,0 @@ -Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions. diff --git a/addons/cetmix_tower_git/readme/DESCRIPTION.md b/addons/cetmix_tower_git/readme/DESCRIPTION.md deleted file mode 100644 index 19da7e3..0000000 --- a/addons/cetmix_tower_git/readme/DESCRIPTION.md +++ /dev/null @@ -1,3 +0,0 @@ -This module implements Git Management functionality for [Cetmix Tower](https://cetmix.com/tower). - -Please refer to the [official documentation](https://cetmix.com/tower) for detailed information. diff --git a/addons/cetmix_tower_git/readme/HISTORY.md b/addons/cetmix_tower_git/readme/HISTORY.md deleted file mode 100644 index 0e19dec..0000000 --- a/addons/cetmix_tower_git/readme/HISTORY.md +++ /dev/null @@ -1,53 +0,0 @@ -## 16.0.2.0.4 (2026-02-14) - -- Bugfixes: Link server to git project only once. (5214) - - -## 16.0.2.0.3 (2026-01-11) - -- Features: Provide git project name using the `__git_project__` custom value when creating a project in flight plan. Improve the UI and UX of Git Projects. (5197) - - -## 16.0.2.0.1 (2025-12-11) - -- Features: Improve search views, implement the search panel for selected views. (5139) - - -## 16.0.2.0.0 (2025-10-27) - -- Features: Major refactoring: implement Git repository entity. (4914) - - -## 16.0.1.0.6 (2025-08-18) - -- Features: Link or copy a git project when uploading the linked file using command (4759) - - -## 16.0.1.0.5 (2025-08-17) - -- Features: Search servers by git reference (4838) - - -## 16.0.1.0.4 (2025-07-29) - -- Features: Export related commands and flight plans together with server (4849) - - -## 16.0.1.0.3 (2025-05-23) - -- Bugfixes: Duplicated file is created when importing a YAML file with a git project. (4715) - - -## 16.0.1.0.2 (2025-05-16) - -- Features: Record references for git relations. (4670) - - -## 16.0.1.0.1 (2025-05-09) - -- Bugfixes: Non-critical issues and performance improvements. (4663) - - -## 16.0.1.0.0 - -Release for Odoo 16.0 diff --git a/addons/cetmix_tower_git/readme/USAGE.md b/addons/cetmix_tower_git/readme/USAGE.md deleted file mode 100644 index 901f5a6..0000000 --- a/addons/cetmix_tower_git/readme/USAGE.md +++ /dev/null @@ -1 +0,0 @@ -Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions. diff --git a/addons/cetmix_tower_git/readme/newsfragments/.gitkeep b/addons/cetmix_tower_git/readme/newsfragments/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/addons/cetmix_tower_git/security/cx_tower_git_project_file_template_rel_security.xml b/addons/cetmix_tower_git/security/cx_tower_git_project_file_template_rel_security.xml deleted file mode 100644 index 6d4f088..0000000 --- a/addons/cetmix_tower_git/security/cx_tower_git_project_file_template_rel_security.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - Git Project File Template Relation: Manager Read Access - - ['&', - '|', - ('git_project_id.user_ids', 'in', [user.id]), - ('git_project_id.manager_ids', 'in', [user.id]), - '|', - ('file_template_id.user_ids', 'in', [user.id]), - ('file_template_id.manager_ids', 'in', [user.id])] - - - - - - - - - - Git Project File Template Relation: Manager Write Access - - [ - ('git_project_id.manager_ids', 'in', [user.id]), - ('file_template_id.manager_ids', 'in', [user.id])] - - - - - - - - - - - Git Project File Template Relation: Root Full Access - - [(1, '=', 1)] - - - - diff --git a/addons/cetmix_tower_git/security/cx_tower_git_project_rel_security.xml b/addons/cetmix_tower_git/security/cx_tower_git_project_rel_security.xml deleted file mode 100644 index 3365257..0000000 --- a/addons/cetmix_tower_git/security/cx_tower_git_project_rel_security.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - Git Project Relation: Manager Read Access - - ['&', - '|', - ('git_project_id.user_ids', 'in', [user.id]), - ('git_project_id.manager_ids', 'in', [user.id]), - '|', - ('server_id.user_ids', 'in', [user.id]), - ('server_id.manager_ids', 'in', [user.id])] - - - - - - - - - - Git Project Relation: Manager Create/Write/Delete Access - - [('git_project_id.manager_ids', 'in', [user.id]), - ('server_id.manager_ids', 'in', [user.id])] - - - - - - - - - - Git Project Relation: Root Full Access - - [(1, '=', 1)] - - - - diff --git a/addons/cetmix_tower_git/security/cx_tower_git_project_security.xml b/addons/cetmix_tower_git/security/cx_tower_git_project_security.xml deleted file mode 100644 index 2149081..0000000 --- a/addons/cetmix_tower_git/security/cx_tower_git_project_security.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - Git Project: Manager Read Access - - ['|', ('user_ids', 'in', [user.id]), ('manager_ids', 'in', [user.id])] - - - - - - - - - - Git Project: Manager Read Access via Server - - ['|', - ('server_ids.user_ids', 'in', [user.id]), - ('server_ids.manager_ids', 'in', [user.id])] - - - - - - - - - - Git Project: Manager Write Access - - [('manager_ids', 'in', [user.id])] - - - - - - - - - - Git Project: Manager Delete Access - - [('manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)] - - - - - - - - - - Git Project: Root Full Access - - [(1, '=', 1)] - - - - - diff --git a/addons/cetmix_tower_git/security/cx_tower_git_remote_security.xml b/addons/cetmix_tower_git/security/cx_tower_git_remote_security.xml deleted file mode 100644 index bd4a48d..0000000 --- a/addons/cetmix_tower_git/security/cx_tower_git_remote_security.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - Git Remote: Manager Read Access - - ['|', ('git_project_id.user_ids', 'in', [user.id]), ('git_project_id.manager_ids', 'in', [user.id])] - - - - - - - - - - Git Remote: Manager Read Access via Server - - ['|', - ('git_project_id.server_ids.user_ids', 'in', [user.id]), - ('git_project_id.server_ids.manager_ids', 'in', [user.id])] - - - - - - - - - - Git Remote: Manager Write/Create Access - - [('git_project_id.manager_ids', 'in', [user.id])] - - - - - - - - - - Git Remote: Manager Delete Access - - [('git_project_id.manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)] - - - - - - - - - - Git Remote: Root Full Access - - [(1, '=', 1)] - - - - - - - - - diff --git a/addons/cetmix_tower_git/security/cx_tower_git_repo_owner_security.xml b/addons/cetmix_tower_git/security/cx_tower_git_repo_owner_security.xml deleted file mode 100644 index 7ef0ee9..0000000 --- a/addons/cetmix_tower_git/security/cx_tower_git_repo_owner_security.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - Git Repository Owner: Manager Write/Create Access - - [('create_uid', '=', user.id)] - - - - - - - - - - Git Repository Owner: Root Full Access - - [(1, '=', 1)] - - - - diff --git a/addons/cetmix_tower_git/security/cx_tower_git_repo_security.xml b/addons/cetmix_tower_git/security/cx_tower_git_repo_security.xml deleted file mode 100644 index 9968a42..0000000 --- a/addons/cetmix_tower_git/security/cx_tower_git_repo_security.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - Git Repository: Manager Write/Create Access - - [('create_uid', '=', user.id)] - - - - - - - - - - Git Repository: Root Full Access - - [(1, '=', 1)] - - - - diff --git a/addons/cetmix_tower_git/security/cx_tower_git_source_security.xml b/addons/cetmix_tower_git/security/cx_tower_git_source_security.xml deleted file mode 100644 index bad536b..0000000 --- a/addons/cetmix_tower_git/security/cx_tower_git_source_security.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - Git Source: Manager Read Access - - ['|', ('git_project_id.user_ids', 'in', [user.id]), ('git_project_id.manager_ids', 'in', [user.id])] - - - - - - - - - - Git Source: Manager Read Access via Server - - ['|', - ('git_project_id.server_ids.user_ids', 'in', [user.id]), - ('git_project_id.server_ids.manager_ids', 'in', [user.id])] - - - - - - - - - - Git Source: Manager Write/Create Access - - [('git_project_id.manager_ids', 'in', [user.id])] - - - - - - - - - - Git Source: Manager Delete Access - - [('git_project_id.manager_ids', 'in', [user.id]), ('create_uid', '=', user.id)] - - - - - - - - - - Git Source: Root Full Access - - [(1, '=', 1)] - - - - - - - - - diff --git a/addons/cetmix_tower_git/security/ir.model.access.csv b/addons/cetmix_tower_git/security/ir.model.access.csv deleted file mode 100644 index 750134f..0000000 --- a/addons/cetmix_tower_git/security/ir.model.access.csv +++ /dev/null @@ -1,15 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_git_config_manager,Git Config Manager,model_cx_tower_git_project,cetmix_tower_server.group_manager,1,1,1,1 -access_git_config_root,Git Config Root,model_cx_tower_git_project,cetmix_tower_server.group_root,1,1,1,1 -access_git_source_manager,Git Source Manager,model_cx_tower_git_source,cetmix_tower_server.group_manager,1,1,1,1 -access_git_source_root,Git Source Root,model_cx_tower_git_source,cetmix_tower_server.group_root,1,1,1,1 -access_git_remote_manager,Git Remote Manager,model_cx_tower_git_remote,cetmix_tower_server.group_manager,1,1,1,1 -access_git_remote_root,Git Remote Root,model_cx_tower_git_remote,cetmix_tower_server.group_root,1,1,1,1 -access_git_repo_manager,Git Repository Manager,model_cx_tower_git_repo,cetmix_tower_server.group_manager,1,1,1,1 -access_git_repo_root,Git Repository Root,model_cx_tower_git_repo,cetmix_tower_server.group_root,1,1,1,1 -access_git_repo_owner_manager,Git Repository Owner Manager,model_cx_tower_git_repo_owner,cetmix_tower_server.group_manager,1,1,1,0 -access_git_repo_owner_root,Git Repository Owner Root,model_cx_tower_git_repo_owner,cetmix_tower_server.group_root,1,1,1,1 -access_git_project_server_file_rel,Git Project Server File Rel Manager,model_cx_tower_git_project_rel,cetmix_tower_server.group_manager,1,1,1,1 -access_git_project_server_file_rel_root,Git Project Server File Rel Root,model_cx_tower_git_project_rel,cetmix_tower_server.group_root,1,1,1,1 -access_git_project_file_template_rel,Git Project File Template Rel Manager,model_cx_tower_git_project_file_template_rel,cetmix_tower_server.group_manager,1,1,1,1 -access_git_project_file_template_rel_root,Git Project File Template Rel Root,model_cx_tower_git_project_file_template_rel,cetmix_tower_server.group_root,1,1,1,1 diff --git a/addons/cetmix_tower_git/static/description/banner.png b/addons/cetmix_tower_git/static/description/banner.png deleted file mode 100644 index 48d0b50..0000000 Binary files a/addons/cetmix_tower_git/static/description/banner.png and /dev/null differ diff --git a/addons/cetmix_tower_git/static/description/icon.png b/addons/cetmix_tower_git/static/description/icon.png deleted file mode 100644 index 2507f55..0000000 Binary files a/addons/cetmix_tower_git/static/description/icon.png and /dev/null differ diff --git a/addons/cetmix_tower_git/static/description/index.html b/addons/cetmix_tower_git/static/description/index.html deleted file mode 100644 index a3987fe..0000000 --- a/addons/cetmix_tower_git/static/description/index.html +++ /dev/null @@ -1,513 +0,0 @@ - - - - - -Cetmix Tower Git - - - -
-

Cetmix Tower Git

- - -

Beta License: AGPL-3 cetmix/cetmix-tower

-

This module implements Git Management functionality for Cetmix -Tower.

-

Please refer to the official -documentation for detailed information.

-

Table of contents

- -
-

Configuration

-

Please refer to the official -documentation for detailed configuration -instructions.

-
-
-

Usage

-

Please refer to the official -documentation for detailed usage -instructions.

-
-
-

Changelog

-
-

16.0.2.0.4 (2026-02-14)

-
    -
  • Bugfixes: Link server to git project only once. (5214)
  • -
-
-
-

16.0.2.0.3 (2026-01-11)

-
    -
  • Features: Provide git project name using the __git_project__ -custom value when creating a project in flight plan. Improve the UI -and UX of Git Projects. (5197)
  • -
-
-
-

16.0.2.0.1 (2025-12-11)

-
    -
  • Features: Improve search views, implement the search panel for -selected views. (5139)
  • -
-
-
-

16.0.2.0.0 (2025-10-27)

-
    -
  • Features: Major refactoring: implement Git repository entity. (4914)
  • -
-
-
-

16.0.1.0.6 (2025-08-18)

-
    -
  • Features: Link or copy a git project when uploading the linked file -using command (4759)
  • -
-
-
-

16.0.1.0.5 (2025-08-17)

-
    -
  • Features: Search servers by git reference (4838)
  • -
-
-
-

16.0.1.0.4 (2025-07-29)

-
    -
  • Features: Export related commands and flight plans together with -server (4849)
  • -
-
-
-

16.0.1.0.3 (2025-05-23)

-
    -
  • Bugfixes: Duplicated file is created when importing a YAML file with a -git project. (4715)
  • -
-
-
-

16.0.1.0.2 (2025-05-16)

-
    -
  • Features: Record references for git relations. (4670)
  • -
-
-
-

16.0.1.0.1 (2025-05-09)

-
    -
  • Bugfixes: Non-critical issues and performance improvements. (4663)
  • -
-
-
-

16.0.1.0.0

-

Release for Odoo 16.0

-
-
-
-

Bug Tracker

-

Bugs are tracked on GitHub Issues. -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.

-

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 project on GitHub.

-

You are welcome to contribute.

-
-
-
- - diff --git a/addons/cetmix_tower_git/tests/__init__.py b/addons/cetmix_tower_git/tests/__init__.py deleted file mode 100644 index 97c9645..0000000 --- a/addons/cetmix_tower_git/tests/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from . import test_remote -from . import test_source -from . import test_project -from . import test_file_rel -from . import test_file_template_rel -from . import test_server -from . import test_repo diff --git a/addons/cetmix_tower_git/tests/common.py b/addons/cetmix_tower_git/tests/common.py deleted file mode 100644 index b3c9643..0000000 --- a/addons/cetmix_tower_git/tests/common.py +++ /dev/null @@ -1,136 +0,0 @@ -from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon - - -class CommonTest(TestTowerCommon): - """Common test class for all tests.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Models - cls.GitProject = cls.env["cx.tower.git.project"] - cls.GitProjectRel = cls.env["cx.tower.git.project.rel"] - cls.GitProjectFileTemplateRel = cls.env[ - "cx.tower.git.project.file.template.rel" - ] - cls.GitSource = cls.env["cx.tower.git.source"] - cls.GitRemote = cls.env["cx.tower.git.remote"] - - # Data - # Project - cls.git_project_1 = cls.GitProject.create({"name": "Git Project 1"}) - - # Sources - cls.git_source_1 = cls.GitSource.create( - {"name": "Git Source 1", "git_project_id": cls.git_project_1.id} - ) - cls.git_source_2 = cls.GitSource.create( - {"name": "Git Source 2", "git_project_id": cls.git_project_1.id} - ) - # Repositories - cls.Repo = cls.env["cx.tower.git.repo"] - cls.RepoOwner = cls.env["cx.tower.git.repo.owner"] - - cls.repo_cetmix_tower = cls.Repo.create( - { - "name": "Cetmix Tower", - "url": "https://github.com/cetmix-test/cetmix-tower-test.git", - } - ) - cls.repo_oca_web = cls.Repo.create( - { - "name": "OCA Web", - "url": "https://github.com/oca-test/web-test.git", - } - ) - cls.repo_odoo_enterprise = cls.Repo.create( - { - "name": "Odoo Enterprise", - "url": "https://github.com/odoo-test/enterprise-test.git", - "is_private": True, - } - ) - cls.repo_gitlab_private = cls.Repo.create( - { - "name": "GitLab Private", - "url": "git@my.gitlab.com:cetmix-test/cetmix-tower-test.git", - "is_private": True, - } - ) - cls.repo_bitbucket_private = cls.Repo.create( - { - "name": "Bitbucket Private", - "url": "https://bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git", - "is_private": True, - } - ) - - # Same urls, different protocols (intentionally aliased) - cls.repo_other_ssh = cls.Repo.create( - {"url": "git@memegit.com:cetmix-test/cetmix-tower-test.git"} - ) - cls.repo_other_https = cls.repo_other_ssh - - # Remotes - cls.remote_github_https = cls.GitRemote.create( - { - "repo_id": cls.repo_cetmix_tower.id, - "source_id": cls.git_source_1.id, - "head_type": "pr", - "head": "https://github.com/cetmix-test/cetmix-tower-test/pull/123", - "sequence": 1, - } - ) - cls.remote_gitlab_https = cls.GitRemote.create( - { - "repo_id": cls.repo_gitlab_private.id, - "source_id": cls.git_source_1.id, - "head_type": "branch", - "head": "main", - "sequence": 2, - } - ) - cls.remote_gitlab_ssh = cls.GitRemote.create( - { - "repo_id": cls.repo_gitlab_private.id, - "source_id": cls.git_source_1.id, - "head_type": "commit", - "url_protocol": "ssh", - "head": "10000000", - "sequence": 3, - } - ) - cls.remote_bitbucket_https = cls.GitRemote.create( - { - "repo_id": cls.repo_bitbucket_private.id, - "source_id": cls.git_source_2.id, - "head_type": "branch", - "head": "dev", - "sequence": 4, - } - ) - cls.remote_other_ssh = cls.GitRemote.create( - { - "repo_id": cls.repo_other_ssh.id, - "source_id": cls.git_source_2.id, - "head_type": "branch", - "url_protocol": "ssh", - "head": "old", - "sequence": 5, - } - ) - - # File - cls.server_1_file_1 = cls.File.create( - { - "name": "File 1", - "server_id": cls.server_test_1.id, - "source": "tower", - } - ) - cls.file_template_1 = cls.FileTemplate.create( - { - "name": "File Template 1", - } - ) diff --git a/addons/cetmix_tower_git/tests/test_file_rel.py b/addons/cetmix_tower_git/tests/test_file_rel.py deleted file mode 100644 index a0e5215..0000000 --- a/addons/cetmix_tower_git/tests/test_file_rel.py +++ /dev/null @@ -1,390 +0,0 @@ -from odoo.exceptions import AccessError - -from .common import CommonTest - - -class TestFileRel(CommonTest): - """Test class for git file relation.""" - - def setUp(self): - super().setUp() - self.file_1_rel = self.GitProjectRel.create( - { - "server_id": self.server_test_1.id, - "file_id": self.server_1_file_1.id, - "git_project_id": self.git_project_1.id, - "project_format": "git_aggregator", - } - ) - - def test_file_rel_create(self): - """Test if file relation is created correctly""" - - # -- 1 -- - # Check if file content is updated - - # Get code from project - yaml_code_from_project = ( - self.file_1_rel.git_project_id._generate_code_git_aggregator( - self.file_1_rel - ) - ) - - self.assertEqual( - self.server_1_file_1.code, - yaml_code_from_project, - "File content is not updated correctly", - ) - - # Check specific if remote is present in file - self.assertIn( - self.remote_other_ssh.repo_id.url_ssh, - self.server_1_file_1.code, - "Remote is not present in file", - ) - - # -- 2 -- - # Modify remove and check if file content is updated - self.remote_other_ssh.repo_id = self.Repo.create( - { - "url": "https://github.com/cetmix/cetmix-memes.git", - } - ) - self.remote_other_ssh.url_protocol = "https" - - # Must be different from previous project code - self.assertNotEqual( - self.server_1_file_1.code, - yaml_code_from_project, - "File content is not updated correctly", - ) - # New remote must be present in file - self.assertIn( - "https://github.com/cetmix/cetmix-memes.git", - self.server_1_file_1.code, - "Remote is not present in file", - ) - - # -- 3 -- - # Disable source and check if file content is updated - self.git_source_2.active = False - self.assertNotIn( - "https://github.com/cetmix/cetmix-memes.git", - self.server_1_file_1.code, - "Remote is present in file", - ) - - def test_format_git_aggregator(self): - """Test if format git aggregator works correctly""" - - # -- 1 -- - # Check if YAML code is generated correctly - - yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower -# It's designed to be used with git-aggregator tool developed by Acsone. -# Documentation for git-aggregator: https://github.com/acsone/git-aggregator - -# You need to set the following variables in your environment: -# BITBUCKET_TOKEN, GITLAB_TOKEN, GITLAB_TOKEN_NAME -# and run git-aggregator with '--expand-env' parameter. - -./git_project_1_git_source_1: - remotes: - remote_1: https://github.com/cetmix-test/cetmix-tower-test.git - remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git - remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git - merges: - - remote: remote_1 - ref: refs/pull/123/head - - remote: remote_2 - ref: main - - remote: remote_3 - ref: '10000000' - target: remote_1 -./git_project_1_git_source_1_2: - remotes: - remote_1: https://x-token-auth:$BITBUCKET_TOKEN@bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git - remote_2: git@memegit.com:cetmix-test/cetmix-tower-test.git - merges: - - remote: remote_1 - ref: dev - - remote: remote_2 - ref: old - target: remote_1 -""" # noqa: E501 - - # Get code from project - yaml_code_from_project = ( - self.file_1_rel.git_project_id._generate_code_git_aggregator( - self.file_1_rel - ) - ) - self.assertEqual( - yaml_code_from_project, - yaml_code, - "YAML code is not generated correctly", - ) - - # -- 2 -- - # Unlink remote and check if file content is updated - self.remote_github_https.unlink() - yaml_code_from_project = ( - self.file_1_rel.git_project_id._generate_code_git_aggregator( - self.file_1_rel - ) - ) - yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower -# It's designed to be used with git-aggregator tool developed by Acsone. -# Documentation for git-aggregator: https://github.com/acsone/git-aggregator - -# You need to set the following variables in your environment: -# BITBUCKET_TOKEN, GITLAB_TOKEN, GITLAB_TOKEN_NAME -# and run git-aggregator with '--expand-env' parameter. - -./git_project_1_git_source_1: - remotes: - remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git - remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git - merges: - - remote: remote_2 - ref: main - - remote: remote_3 - ref: '10000000' - target: remote_2 -./git_project_1_git_source_1_2: - remotes: - remote_1: https://x-token-auth:$BITBUCKET_TOKEN@bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git - remote_2: git@memegit.com:cetmix-test/cetmix-tower-test.git - merges: - - remote: remote_1 - ref: dev - - remote: remote_2 - ref: old - target: remote_1 -""" # noqa: E501 - - self.assertEqual( - yaml_code_from_project, - yaml_code, - "YAML code is not generated correctly", - ) - - # -- 3 -- - # Unlink source and check if file content is updated - self.git_source_2.unlink() - yaml_code_from_project = ( - self.file_1_rel.git_project_id._generate_code_git_aggregator( - self.file_1_rel - ) - ) - yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower -# It's designed to be used with git-aggregator tool developed by Acsone. -# Documentation for git-aggregator: https://github.com/acsone/git-aggregator - -# You need to set the following variables in your environment: -# GITLAB_TOKEN, GITLAB_TOKEN_NAME -# and run git-aggregator with '--expand-env' parameter. - -./git_project_1_git_source_1: - remotes: - remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git - remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git - merges: - - remote: remote_2 - ref: main - - remote: remote_3 - ref: '10000000' - target: remote_2 -""" # noqa: E501 - self.assertEqual( - yaml_code_from_project, - yaml_code, - "YAML code is not generated correctly", - ) - - def test_user_access(self): - """Test that regular users have no access to git project relations""" - user_rel = self.GitProjectRel.with_user(self.user) - - # Try create - should fail - with self.assertRaises(AccessError): - user_rel.create( - { - "server_id": self.server_test_1.id, - "file_id": self.server_1_file_1.id, - "git_project_id": self.git_project_1.id, - "project_format": "git_aggregator", - } - ) - - # Try read - should fail - with self.assertRaises(AccessError): - user_rel.browse(self.file_1_rel.id).read(["name"]) - - # Try write - should fail - with self.assertRaises(AccessError): - user_rel.browse(self.file_1_rel.id).write( - {"project_format": "git_aggregator"} - ) - - # Try unlink - should fail - with self.assertRaises(AccessError): - user_rel.browse(self.file_1_rel.id).unlink() - - def test_manager_read_access(self): - """Test manager read access rules""" - manager_rel = self.GitProjectRel.with_user(self.manager) - - # Initially manager should not have access - with self.assertRaises(AccessError): - manager_rel.browse(self.file_1_rel.id).read(["name"]) - - # Add manager as project user - should have read access - self.git_project_1.write({"user_ids": [(4, self.manager.id)]}) - self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1") - - # Remove from project, add as server user - should have read access - self.git_project_1.write({"user_ids": [(3, self.manager.id)]}) - self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) - self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1") - - # Remove from server users, add as project manager - should have read access - self.server_test_1.write({"user_ids": [(3, self.manager.id)]}) - self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) - self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1") - - # Remove from project, add as server manager - should have read access - self.git_project_1.write({"manager_ids": [(3, self.manager.id)]}) - self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) - self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1") - - def test_manager_write_access(self): - """Test manager write/create access rules""" - manager_rel = self.GitProjectRel.with_user(self.manager) - - # Create new file to avoid unique constraint violation - file_2 = self.File.create( - { - "name": "test_file_2", - "server_id": self.server_test_1.id, - "source": "tower", - "file_type": "text", - } - ) - - # Try create without being project and server manager - should fail - with self.assertRaises(AccessError): - manager_rel.create( - { - "server_id": self.server_test_1.id, - "file_id": file_2.id, - "git_project_id": self.git_project_1.id, - "project_format": "git_aggregator", - } - ) - - # Add as project manager only - should still fail - file_3 = self.File.create( - { - "name": "test_file_3", - "server_id": self.server_test_1.id, - "source": "tower", - "file_type": "text", - } - ) - self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) - with self.assertRaises(AccessError): - manager_rel.create( - { - "server_id": self.server_test_1.id, - "file_id": file_3.id, - "git_project_id": self.git_project_1.id, - "project_format": "git_aggregator", - } - ) - - # Add as server manager - should succeed - file_4 = self.File.create( - { - "name": "test_file_4", - "server_id": self.server_test_1.id, - "source": "tower", - "file_type": "text", - } - ) - self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) - rel = manager_rel.create( - { - "server_id": self.server_test_1.id, - "file_id": file_4.id, - "git_project_id": self.git_project_1.id, - "project_format": "git_aggregator", - } - ) - self.assertTrue(rel.exists()) - - # Test write access - rel.write({"project_format": "git_aggregator"}) - - # Remove server manager access - should fail to write - self.server_test_1.write({"manager_ids": [(3, self.manager.id)]}) - with self.assertRaises(AccessError): - rel.write({"project_format": "git_aggregator"}) - - # Remove project manager access - should fail to write - self.git_project_1.write({"manager_ids": [(3, self.manager.id)]}) - with self.assertRaises(AccessError): - rel.write({"project_format": "git_aggregator"}) - - def test_manager_unlink_access(self): - """Test manager unlink access rules""" - manager_rel = self.GitProjectRel.with_user(self.manager) - - # Try delete without being project and server manager - should fail - with self.assertRaises(AccessError): - manager_rel.browse(self.file_1_rel.id).unlink() - - # Add as project manager only - should fail - self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) - with self.assertRaises(AccessError): - manager_rel.browse(self.file_1_rel.id).unlink() - - # Add as server manager - should succeed - self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) - self.file_1_rel.unlink() - self.assertFalse(self.file_1_rel.exists()) - - def test_root_access(self): - """Test root access rules""" - root_rel = self.GitProjectRel.with_user(self.root) - - # Create new file to avoid unique constraint violation - file_3 = self.File.create( - { - "name": "test_file_3", - "server_id": self.server_test_1.id, - "source": "tower", - "file_type": "text", - } - ) - - # Create - should succeed - rel = root_rel.create( - { - "server_id": self.server_test_1.id, - "file_id": file_3.id, - "git_project_id": self.git_project_1.id, - "project_format": "git_aggregator", - } - ) - self.assertTrue(rel.exists()) - - # Read - should succeed - self.assertEqual(root_rel.browse(rel.id).name, "Git Project 1") - - # Write - should succeed - root_rel.browse(rel.id).write({"project_format": "git_aggregator"}) - - # Delete - should succeed - rel.unlink() - self.assertFalse(rel.exists()) diff --git a/addons/cetmix_tower_git/tests/test_file_template_rel.py b/addons/cetmix_tower_git/tests/test_file_template_rel.py deleted file mode 100644 index 11e8849..0000000 --- a/addons/cetmix_tower_git/tests/test_file_template_rel.py +++ /dev/null @@ -1,308 +0,0 @@ -from odoo.exceptions import AccessError - -from .common import CommonTest - - -class TestFileTemplateRel(CommonTest): - """Test class for git file template relation.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.file_template_1_rel = cls.GitProjectFileTemplateRel.create( - { - "git_project_id": cls.git_project_1.id, - "file_template_id": cls.file_template_1.id, - "project_format": "git_aggregator", - } - ) - - def test_file_template_rel_create(self): - """Test if file template relation is created correctly""" - - # -- 1 -- - # Check if file content is updated - - # Get code from project - yaml_code_from_project = ( - self.file_template_1_rel.git_project_id._generate_code_git_aggregator( - self.file_template_1_rel - ) - ) - - self.assertEqual( - self.file_template_1.code, - yaml_code_from_project, - "File template content is not updated correctly", - ) - - # Check specific if remote is present in file - self.assertIn( - self.remote_other_ssh.repo_id.url_ssh, - self.file_template_1.code, - "Remote is not present in file template", - ) - - # -- 2 -- - # Modify remove and check if file template content is updated - self.remote_other_ssh.repo_id = self.Repo.create( - { - "url": "https://github.com/cetmix/cetmix-memes.git", - } - ) - self.remote_other_ssh.url_protocol = "https" - - # Must be different from previous project code - self.assertNotEqual( - self.file_template_1.code, - yaml_code_from_project, - "File template content is not updated correctly", - ) - # New remote must be present in file - self.assertIn( - "https://github.com/cetmix/cetmix-memes.git", - self.file_template_1.code, - "Remote is not present in file template", - ) - - # -- 3 -- - # Disable source and check if file content is updated - self.git_source_2.active = False - self.assertNotIn( - "https://github.com/cetmix/cetmix-memes.git", - self.file_template_1.code, - "Remote is present in file template", - ) - - def test_format_git_aggregator(self): - """Test if format git aggregator works correctly""" - - # -- 1 -- - # Check if YAML code is generated correctly - - yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower -# It's designed to be used with git-aggregator tool developed by Acsone. -# Documentation for git-aggregator: https://github.com/acsone/git-aggregator - -# You need to set the following variables in your environment: -# BITBUCKET_TOKEN, GITLAB_TOKEN, GITLAB_TOKEN_NAME -# and run git-aggregator with '--expand-env' parameter. - -./git_project_1_git_source_1: - remotes: - remote_1: https://github.com/cetmix-test/cetmix-tower-test.git - remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git - remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git - merges: - - remote: remote_1 - ref: refs/pull/123/head - - remote: remote_2 - ref: main - - remote: remote_3 - ref: '10000000' - target: remote_1 -./git_project_1_git_source_1_2: - remotes: - remote_1: https://x-token-auth:$BITBUCKET_TOKEN@bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git - remote_2: git@memegit.com:cetmix-test/cetmix-tower-test.git - merges: - - remote: remote_1 - ref: dev - - remote: remote_2 - ref: old - target: remote_1 -""" # noqa: E501 - - # Get code from project - yaml_code_from_project = ( - self.file_template_1_rel.git_project_id._generate_code_git_aggregator( - self.file_template_1_rel - ) - ) - self.assertEqual( - yaml_code_from_project, - yaml_code, - "YAML code is not generated correctly", - ) - - def test_user_access(self): - """Test that regular users have no access to git project relations""" - user_rel = self.GitProjectFileTemplateRel.with_user(self.user) - - # Try create - should fail - with self.assertRaises(AccessError): - user_rel.create( - { - "git_project_id": self.git_project_1.id, - "file_template_id": self.file_template_1.id, - "project_format": "git_aggregator", - } - ) - - # Try read - should fail - with self.assertRaises(AccessError): - user_rel.browse(self.file_template_1_rel.id).read(["name"]) - - # Try write - should fail - with self.assertRaises(AccessError): - user_rel.browse(self.file_template_1_rel.id).write( - {"project_format": "git_aggregator"} - ) - - # Try unlink - should fail - with self.assertRaises(AccessError): - user_rel.browse(self.file_template_1_rel.id).unlink() - - def test_manager_read_access(self): - """Test manager read access rules""" - manager_rel = self.GitProjectFileTemplateRel.with_user(self.manager) - - # Initially manager should not have access - with self.assertRaises(AccessError): - manager_rel.browse(self.file_template_1_rel.id).read(["name"]) - - # Add manager as project user - should have read access - self.git_project_1.write({"user_ids": [(4, self.manager.id)]}) - self.assertEqual( - manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1" - ) - - # Remove from project, add as file template user - # should have read access - self.git_project_1.write({"user_ids": [(3, self.manager.id)]}) - self.file_template_1.write({"user_ids": [(4, self.manager.id)]}) - self.assertEqual( - manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1" - ) - - # Remove from file template users, add as project manager - # should have read access - self.file_template_1.write({"user_ids": [(3, self.manager.id)]}) - self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) - self.assertEqual( - manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1" - ) - - # Remove from project, add as file template manager - # should have read access - self.git_project_1.write({"manager_ids": [(3, self.manager.id)]}) - self.file_template_1.write({"manager_ids": [(4, self.manager.id)]}) - self.assertEqual( - manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1" - ) - - def test_manager_write_access(self): - """Test manager write/create access rules""" - manager_rel = self.GitProjectFileTemplateRel.with_user(self.manager) - - # Create new file template to avoid unique constraint violation - file_template_2 = self.FileTemplate.create( - { - "name": "test_file_template_2", - } - ) - - # Try create without being project and file template manager - should fail - with self.assertRaises(AccessError): - manager_rel.create( - { - "git_project_id": self.git_project_1.id, - "file_template_id": file_template_2.id, - "project_format": "git_aggregator", - } - ) - - # Add as project manager only - should still fail - file_template_3 = self.FileTemplate.create( - { - "name": "test_file_template_3", - } - ) - self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) - with self.assertRaises(AccessError): - manager_rel.create( - { - "git_project_id": self.git_project_1.id, - "file_template_id": file_template_3.id, - "project_format": "git_aggregator", - } - ) - - # Add as file template manager - should succeed - file_template_4 = self.FileTemplate.create( - { - "name": "test_file_template_4", - } - ) - file_template_4.write({"manager_ids": [(4, self.manager.id)]}) - rel = manager_rel.create( - { - "git_project_id": self.git_project_1.id, - "file_template_id": file_template_4.id, - "project_format": "git_aggregator", - } - ) - self.assertTrue(rel.exists()) - - # Test write access - rel.write({"project_format": "git_aggregator"}) - - # Remove file template manager access - should fail to write - file_template_4.write({"manager_ids": [(3, self.manager.id)]}) - with self.assertRaises(AccessError): - rel.write({"project_format": "git_aggregator"}) - - # Remove project manager access - should fail to write - self.git_project_1.write({"manager_ids": [(3, self.manager.id)]}) - file_template_4.write({"manager_ids": [(4, self.manager.id)]}) - with self.assertRaises(AccessError): - rel.write({"project_format": "git_aggregator"}) - - def test_manager_unlink_access(self): - """Test manager unlink access rules""" - manager_rel = self.GitProjectFileTemplateRel.with_user(self.manager) - - # Try delete without being project and server manager - should fail - with self.assertRaises(AccessError): - manager_rel.browse(self.file_template_1_rel.id).unlink() - - # Add as project manager only - should fail - self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) - with self.assertRaises(AccessError): - manager_rel.browse(self.file_template_1_rel.id).unlink() - - # Add as file template manager - should succeed - self.file_template_1.write({"manager_ids": [(4, self.manager.id)]}) - self.file_template_1_rel.unlink() - self.assertFalse(self.file_template_1_rel.exists()) - - def test_root_access(self): - """Test root access rules""" - root_rel = self.GitProjectFileTemplateRel.with_user(self.root) - - # Create new file to avoid unique constraint violation - file_template_3 = self.FileTemplate.create( - { - "name": "test_file_template_3", - } - ) - - # Create - should succeed - rel = root_rel.create( - { - "git_project_id": self.git_project_1.id, - "file_template_id": file_template_3.id, - "project_format": "git_aggregator", - } - ) - self.assertTrue(rel.exists()) - - # Read - should succeed - self.assertEqual(root_rel.browse(rel.id).name, "Git Project 1") - - # Write - should succeed - root_rel.browse(rel.id).write({"project_format": "git_aggregator"}) - - # Delete - should succeed - rel.unlink() - self.assertFalse(rel.exists()) diff --git a/addons/cetmix_tower_git/tests/test_project.py b/addons/cetmix_tower_git/tests/test_project.py deleted file mode 100644 index 588d520..0000000 --- a/addons/cetmix_tower_git/tests/test_project.py +++ /dev/null @@ -1,315 +0,0 @@ -from odoo.exceptions import AccessError - -from .common import CommonTest - - -class TestProject(CommonTest): - """Test class for git project.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - # Remove user bob from all groups - cls.remove_from_group( - cls.user_bob, - [ - "cetmix_tower_server.group_user", - "cetmix_tower_server.group_manager", - "cetmix_tower_server.group_root", - ], - ) - - # Create another manager for testing - cls.manager_2 = cls.Users.create( - { - "name": "Second Manager", - "login": "manager2", - "email": "manager2@test.com", - "groups_id": [(4, cls.env.ref("cetmix_tower_server.group_manager").id)], - } - ) - - # Create test project as root - cls.project = cls.GitProject.create( - { - "name": "Test Project", - } - ) - - def test_user_access(self): - """Test that regular users have no access to git projects""" - user_project = self.GitProject.with_user(self.user) - - # Test CRUD operations - with self.assertRaises(AccessError): - user_project.create({"name": "New Project"}) - with self.assertRaises(AccessError): - user_project.browse(self.project.id).read(["name"]) - with self.assertRaises(AccessError): - user_project.browse(self.project.id).write({"name": "Updated Name"}) - with self.assertRaises(AccessError): - user_project.browse(self.project.id).unlink() - - def test_manager_read_access(self): - """Test manager read access rules""" - manager_project = self.GitProject.with_user(self.manager) - - # Manager not in user_ids or manager_ids - should not read - with self.assertRaises(AccessError): - manager_project.browse(self.project.id).read(["name"]) - - # Add manager to user_ids - should read - self.project.write({"user_ids": [(4, self.manager.id)]}) - self.assertEqual(manager_project.browse(self.project.id).name, "Test Project") - - # Remove from user_ids, add to manager_ids - should read - self.project.write( - {"user_ids": [(3, self.manager.id)], "manager_ids": [(4, self.manager.id)]} - ) - self.assertEqual(manager_project.browse(self.project.id).name, "Test Project") - - def test_manager_write_access(self): - """Test manager write/create access rules""" - manager_project = self.GitProject.with_user(self.manager) - - # Create - should succeed as manager is added by default - new_project = manager_project.create({"name": "New Project"}) - self.assertTrue(new_project.exists()) - self.assertIn(self.manager, new_project.manager_ids) - - # Write - not in manager_ids, should fail - with self.assertRaises(AccessError): - manager_project.browse(self.project.id).write({"name": "Updated Name"}) - - # Add to manager_ids - should write - self.project.write({"manager_ids": [(4, self.manager.id)]}) - manager_project.browse(self.project.id).write({"name": "Updated Name"}) - self.assertEqual(self.project.name, "Updated Name") - - def test_manager_unlink_access(self): - """Test manager unlink access rules""" - # Create project as manager_2 - project = self.GitProject.with_user(self.manager_2).create( - {"name": "Project to Delete"} - ) - manager_project = self.GitProject.with_user(self.manager) - - # Try delete as different manager - should fail - with self.assertRaises(AccessError): - manager_project.browse(project.id).unlink() - - # Add to manager_ids but not creator - should fail - project.write({"manager_ids": [(4, self.manager.id)]}) - with self.assertRaises(AccessError): - manager_project.browse(project.id).unlink() - - # Create as manager and try delete - should succeed - own_project = manager_project.create({"name": "Own Project"}) - self.assertTrue(own_project.exists()) - own_project.unlink() - self.assertFalse(own_project.exists()) - - def test_root_access(self): - """Test root access rules""" - root_project = self.GitProject.with_user(self.root) - - # Create - new_project = root_project.create({"name": "Root Project"}) - self.assertTrue(new_project.exists()) - - # Read - self.assertEqual(root_project.browse(self.project.id).name, "Test Project") - - # Write - root_project.browse(self.project.id).write({"name": "Updated by Root"}) - self.assertEqual(self.project.name, "Updated by Root") - - # Delete - new_project.unlink() - self.assertFalse(new_project.exists()) - - def test_compute_user_ids(self): - """Test computation of user_ids and manager_ids for git projects""" - # Add users "Bob" and "user" to the group "cetmix_tower_server.group_manager" - self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") - self.add_to_group(self.user, "cetmix_tower_server.group_manager") - - # -- 1 -- - # Create project as manager - project_as_manager = self.GitProject.with_user(self.manager).create( - { - "name": "Project As Manager", - } - ) - # Check that manager is added to both user_ids and manager_ids by default - self.assertEqual(len(project_as_manager.user_ids), 1) - self.assertIn(self.manager, project_as_manager.user_ids) - self.assertEqual(len(project_as_manager.manager_ids), 1) - self.assertIn(self.manager, project_as_manager.manager_ids) - - # -- 2 -- - # Create servers with multiple users and managers - server_1 = self.Server.create( - { - "name": "Test Server 1", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": self.os_debian_10.id, - "user_ids": [(6, 0, [self.user_bob.id, self.user.id])], # Two users - "manager_ids": [ - (6, 0, [self.manager.id, self.manager_2.id]) - ], # Two managers - } - ) - - server_2 = self.Server.create( - { - "name": "Test Server 2", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": self.os_debian_10.id, - "user_ids": [ - (6, 0, [self.user_bob.id, self.user.id]) - ], # Same two users - "manager_ids": [ - (6, 0, [self.manager.id, self.manager_2.id]) - ], # Same two managers - } - ) - - # Create project and link servers - project = self.GitProject.create( - { - "name": "Test Project", - } - ) - - # Create files and link them to the project - for server in [server_1, server_2]: - file = self.File.create( - { - "name": f"test_file_{server.name}", - "server_id": server.id, - } - ) - self.GitProjectRel.create( - { - "server_id": server.id, - "file_id": file.id, - "git_project_id": project.id, - "project_format": "git_aggregator", - } - ) - - # Invalidate cache to ensure computed fields are updated - project.invalidate_recordset(["server_ids", "user_ids", "manager_ids"]) - - # -- 3 -- - # Test computed values with linked servers - # Each user/manager should be counted only once even if present in both servers - self.assertEqual(len(project.server_ids), 2) - self.assertEqual(len(project.user_ids), 2) # Two unique users - self.assertIn(self.user_bob, project.user_ids) - self.assertIn(self.user, project.user_ids) - self.assertEqual(len(project.manager_ids), 2) # Two unique managers - self.assertIn(self.manager, project.manager_ids) - self.assertIn(self.manager_2, project.manager_ids) - - # -- 4 -- - # Add server with different users/managers - server_3 = self.Server.create( - { - "name": "Test Server 3", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": self.os_debian_10.id, - "user_ids": [(6, 0, [self.user_bob.id])], # Only one user - "manager_ids": [(6, 0, [self.manager_2.id])], # Only second manager - } - ) - file_3 = self.File.create( - { - "name": "test_file_3", - "server_id": server_3.id, - } - ) - self.GitProjectRel.create( - { - "server_id": server_3.id, - "file_id": file_3.id, - "git_project_id": project.id, - "project_format": "git_aggregator", - } - ) - - # Invalidate cache to ensure computed fields are updated - project.invalidate_recordset(["server_ids", "user_ids", "manager_ids"]) - - # Test that computed values are updated correctly - # Only users/managers present in all servers should remain - self.assertEqual(len(project.server_ids), 3) - self.assertEqual(len(project.user_ids), 1) # Only bob is in all servers - self.assertIn(self.user_bob, project.user_ids) - self.assertEqual( - len(project.manager_ids), 1 - ) # Only manager_2 is in all servers - self.assertIn(self.manager_2, project.manager_ids) - - # -- 5 -- - # Verify that first manager can still access the project - project_as_manager_1 = self.GitProject.with_user(self.manager).browse( - project.id - ) - self.assertTrue(project_as_manager_1.exists()) - self.assertEqual(project_as_manager_1.name, "Test Project") - - def test_manager_server_based_access(self): - """Test manager access through server relationships""" - manager_project = self.GitProject.with_user(self.manager) - - # Create a server where manager is a user - server = self.Server.create( - { - "name": "Test Server", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": self.os_debian_10.id, - "user_ids": [(4, self.manager.id)], - } - ) - - # Create a file and link project to server - file = self.File.create( - { - "name": "test_file", - "server_id": server.id, - } - ) - self.GitProjectRel.create( - { - "server_id": server.id, - "file_id": file.id, - "git_project_id": self.project.id, - "project_format": "git_aggregator", - } - ) - - # Manager should be able to read project through server relationship - self.assertEqual(manager_project.browse(self.project.id).name, "Test Project") - - # Remove manager from server users - server.write({"user_ids": [(3, self.manager.id)]}) - - # Manager should not be able to read project anymore - with self.assertRaises(AccessError): - manager_project.browse(self.project.id).read(["name"]) - - # Add manager to server managers - server.write({"manager_ids": [(4, self.manager.id)]}) - - # Manager should be able to read project again - self.assertEqual(manager_project.browse(self.project.id).name, "Test Project") diff --git a/addons/cetmix_tower_git/tests/test_remote.py b/addons/cetmix_tower_git/tests/test_remote.py deleted file mode 100644 index e66811b..0000000 --- a/addons/cetmix_tower_git/tests/test_remote.py +++ /dev/null @@ -1,462 +0,0 @@ -from odoo.exceptions import AccessError - -from .common import CommonTest - - -class TestRemote(CommonTest): - """Test class for git remote.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - # Create another manager for testing - cls.manager_2 = cls.Users.create( - { - "name": "Second Manager", - "login": "manager2", - "email": "manager2@test.com", - "groups_id": [(4, cls.env.ref("cetmix_tower_server.group_manager").id)], - } - ) - - # Create test project and source as root - cls.project = cls.GitProject.create( - { - "name": "Test Project", - } - ) - cls.source = cls.GitSource.create( - { - "name": "Test Source", - "git_project_id": cls.project.id, - } - ) - cls.repo_cetmix_tower = cls.Repo.create( - { - "name": "Cetmix Tower", - "url": "https://github.com/cetmix-test/cetmix-tower.git", - } - ) - cls.remote = cls.GitRemote.create( - { - "repo_id": cls.repo_cetmix_tower.id, - "source_id": cls.source.id, - "head_type": "branch", - "head": "main", - } - ) - cls.repo_test = cls.Repo.create( - { - "name": "Test Repository", - "url": "https://github.com/cetmix-test/test.git", - } - ) - - def test_user_access(self): - """Test that regular users have no access to git remotes""" - user_remote = self.GitRemote.with_user(self.user) - - # Test CRUD operations - with self.assertRaises(AccessError): - user_remote.create( - { - "repo_id": self.repo_test.id, - "url_protocol": "https", - "source_id": self.source.id, - "head": "main", - } - ) - with self.assertRaises(AccessError): - user_remote.search([("id", "=", self.remote.id)]) - with self.assertRaises(AccessError): - self.remote.with_user(self.user).write({"head": "dev"}) - with self.assertRaises(AccessError): - self.remote.with_user(self.user).unlink() - - def test_manager_read_access(self): - """Test manager read access rules""" - manager_remote = self.GitRemote.with_user(self.manager) - - # Manager not in project user_ids or manager_ids - should not read - self.assertFalse(manager_remote.search([("id", "=", self.remote.id)])) - - # Add manager to project user_ids - should read - self.project.write({"user_ids": [(4, self.manager.id)]}) - remote = manager_remote.search([("id", "=", self.remote.id)]) - self.assertTrue(remote) - self.assertEqual(remote.head, "main") - - # Remove from user_ids, add to manager_ids - should read - self.project.write( - {"user_ids": [(3, self.manager.id)], "manager_ids": [(4, self.manager.id)]} - ) - remote = manager_remote.search([("id", "=", self.remote.id)]) - self.assertTrue(remote.exists()) - - def test_manager_write_access(self): - """Test manager write/create access rules""" - manager_remote = self.GitRemote.with_user(self.manager) - - # Create project as manager - should be added to manager_ids automatically - project = self.GitProject.with_user(self.manager).create( - { - "name": "Manager Project", - } - ) - source = self.GitSource.create( - { - "name": "Manager Source", - "git_project_id": project.id, - } - ) - - # Create remote in own project - should succeed - new_remote = manager_remote.create( - { - "repo_id": self.repo_test.id, - "url_protocol": "https", - "source_id": source.id, - "head_type": "branch", - "head": "main", - } - ) - self.assertTrue(new_remote.exists()) - - # Write to own remote - should succeed - new_remote.write({"head": "dev"}) - self.assertEqual(new_remote.head, "dev") - - # Write to other's remote - should fail - with self.assertRaises(AccessError): - self.remote.with_user(self.manager).write({"head": "dev"}) - - def test_manager_unlink_access(self): - """Test manager unlink access rules""" - # Create project and remote as manager_2 - project = self.GitProject.with_user(self.manager_2).create( - { - "name": "Manager 2 Project", - } - ) - source = self.GitSource.create( - { - "name": "Manager 2 Source", - "git_project_id": project.id, - } - ) - remote = self.GitRemote.with_user(self.manager_2).create( - { - "repo_id": self.repo_test.id, - "url_protocol": "https", - "source_id": source.id, - "head_type": "branch", - "head": "main", - } - ) - - # Try delete as different manager - should fail even if added to manager_ids - project.write({"manager_ids": [(4, self.manager.id)]}) - with self.assertRaises(AccessError): - remote.with_user(self.manager).unlink() - - # Create remote as manager and try delete - should succeed - own_remote = self.GitRemote.with_user(self.manager).create( - { - "repo_id": self.repo_test.id, - "url_protocol": "https", - "source_id": source.id, - "head_type": "branch", - "head": "main", - } - ) - self.assertTrue(own_remote.exists()) - own_remote.with_user(self.manager).unlink() - self.assertFalse(own_remote.exists()) - - def test_root_access(self): - """Test root access rules""" - root_remote = self.GitRemote.with_user(self.root) - - # Create - new_remote = root_remote.create( - { - "repo_id": self.repo_test.id, - "url_protocol": "https", - "source_id": self.source.id, - "head_type": "branch", - "head": "main", - } - ) - self.assertTrue(new_remote.exists()) - - # Read - remote = root_remote.search([("id", "=", self.remote.id)]) - self.assertTrue(remote) - self.assertEqual(remote.head, "main") - - # Write - self.remote.with_user(self.root).write({"head": "dev"}) - self.assertEqual(self.remote.head, "dev") - - # Delete - new_remote.with_user(self.root).unlink() - self.assertFalse(new_remote.exists()) - - def test_remote_provider_protocol_and_name(self): - """Test if remote provider is detected correctly""" - - # -- 1-- - # GitHub + https - # Check if remote provider is detected correctly - self.assertEqual( - self.remote_github_https.repo_provider, - "github", - "Provider is not detected correctly", - ) - self.assertEqual( - self.remote_github_https.url_protocol, - "https", - "Protocol is not detected correctly", - ) - self.assertEqual( - self.remote_github_https.name, - "remote_1", - "Name is not prepared correctly", - ) - - # -- 2 -- - # GitLab + ssh - # Check if remote provider is detected correctly - self.assertEqual( - self.remote_gitlab_ssh.repo_provider, - "gitlab", - "Provider is not detected correctly", - ) - self.assertEqual( - self.remote_gitlab_ssh.url_protocol, - "ssh", - "Protocol is not detected correctly", - ) - self.assertEqual( - self.remote_gitlab_ssh.name, - "remote_3", - "Name is not prepared correctly", - ) - - # -- 3 -- - # Bitbucket + https - # Check if remote provider is detected correctly - self.assertEqual( - self.remote_bitbucket_https.repo_provider, - "bitbucket", - "Provider is not detected correctly", - ) - self.assertEqual( - self.remote_bitbucket_https.url_protocol, - "https", - "Protocol is not detected correctly", - ) - self.assertEqual( - self.remote_bitbucket_https.name, - "remote_1", - "Name is not prepared correctly", - ) - - # -- 4 -- - # Other + ssh - # Check if remote provider is detected correctly - self.assertEqual( - self.remote_other_ssh.repo_provider, - "gitlab", # this is how giturlparse detects the provider - "Provider is not detected correctly", - ) - self.assertEqual( - self.remote_other_ssh.url_protocol, - "ssh", - "Protocol is not detected correctly", - ) - self.assertEqual( - self.remote_other_ssh.name, - "remote_2", - "Name is not prepared correctly", - ) - - def test_git_aggregator_prepare_url(self): - """Test if url is prepared correctly""" - - # -- 1 -- - # GitHub + https - self.remote_github_https.repo_id.is_private = False - self.assertEqual( - self.remote_github_https._git_aggregator_prepare_url(), - self.remote_github_https.repo_id.url, - "URL is not prepared correctly", - ) - - # -- 2 -- - # GitHub + https -> private - self.remote_github_https.repo_id.is_private = True - self.assertEqual( - self.remote_github_https._git_aggregator_prepare_url(), - "https://$GITHUB_TOKEN:x-oauth-basic@github.com/cetmix-test/cetmix-tower-test.git", - "URL is not prepared correctly", - ) - - # -- 3 -- - # Gitlab + https - self.remote_gitlab_https.repo_id.is_private = False - self.assertEqual( - self.remote_gitlab_https._git_aggregator_prepare_url(), - self.remote_gitlab_https.repo_id.url, - "URL is not prepared correctly", - ) - - # -- 4 -- - # Gitlab + https -> private - self.remote_gitlab_https.repo_id.is_private = True - self.assertEqual( - self.remote_gitlab_https._git_aggregator_prepare_url(), - "https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git", - "URL is not prepared correctly", - ) - - # -- 5 -- - # Bitbucket + https - self.remote_bitbucket_https.repo_id.is_private = False - self.assertEqual( - self.remote_bitbucket_https._git_aggregator_prepare_url(), - self.remote_bitbucket_https.repo_id.url, - "URL is not prepared correctly", - ) - - # -- 6 -- - # Bitbucket + https -> private - self.remote_bitbucket_https.repo_id.is_private = True - self.assertEqual( - self.remote_bitbucket_https._git_aggregator_prepare_url(), - "https://x-token-auth:$BITBUCKET_TOKEN@bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git", - "URL is not prepared correctly", - ) - - # -- 7 -- - # Other + ssh - self.remote_other_ssh.repo_id.is_private = False - self.assertEqual( - self.remote_other_ssh._git_aggregator_prepare_url(), - self.remote_other_ssh.repo_id.url_ssh, - "URL is not prepared correctly", - ) - - def test_git_aggregator_prepare_head(self): - """Test if head is prepared correctly""" - - # -- 1 -- - # GitHub + PR/MR as link - self.assertEqual( - self.remote_github_https._git_aggregator_prepare_head(), - "refs/pull/123/head", - "Head is not prepared correctly", - ) - - # -- 2 -- - # GitHub + PR/MR as number - self.remote_github_https.write({"head": "123", "head_type": "pr"}) - self.assertEqual( - self.remote_github_https._git_aggregator_prepare_head(), - "refs/pull/123/head", - "Head is not prepared correctly", - ) - - # -- 3 -- - # GitHub + branch as name - self.remote_github_https.write({"head": "main", "head_type": "branch"}) - self.assertEqual( - self.remote_github_https._git_aggregator_prepare_head(), - self.remote_github_https.head, - "Head is not prepared correctly", - ) - - # -- 4 -- - # GitHub + branch as link - self.remote_github_https.write( - { - "head": "https://github.com/cetmix-test/cetmix-tower/tree/14.0-demo-branch", - "head_type": "branch", - } - ) - self.assertEqual( - self.remote_github_https._git_aggregator_prepare_head(), - "14.0-demo-branch", - "Head is not prepared correctly", - ) - - # -- 5 -- - # GitHub + commit as number - self.remote_github_https.write({"head": "1234567890", "head_type": "commit"}) - self.assertEqual( - self.remote_github_https._git_aggregator_prepare_head(), - "1234567890", - "Head is not prepared correctly", - ) - - # -- 6 -- - # GitHub + commit as link - self.remote_github_https.head = ( - "https://github.com/cetmix-test/cetmix-tower/commit/1234567890" - ) - self.assertEqual( - self.remote_github_https._git_aggregator_prepare_head(), - "1234567890", - "Head is not prepared correctly", - ) - - def test_manager_server_based_access(self): - """Test manager access to remotes through server relationships""" - manager_remote = self.GitRemote.with_user(self.manager) - - # Create a server where manager is a user - server = self.Server.create( - { - "name": "Test Server", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": self.os_debian_10.id, - "user_ids": [(4, self.manager.id)], - } - ) - - # Link project to server - file = self.File.create( - { - "name": "test_file", - "server_id": server.id, - } - ) - self.GitProjectRel.create( - { - "server_id": server.id, - "file_id": file.id, - "git_project_id": self.project.id, - "project_format": "git_aggregator", - } - ) - - # Manager should be able to read remote through server relationship - remote = manager_remote.search([("id", "=", self.remote.id)]) - self.assertTrue(remote) - self.assertEqual(remote.head, "main") - - # Remove manager from server users - server.write({"user_ids": [(3, self.manager.id)]}) - - # Manager should not be able to read remote anymore - self.assertFalse(manager_remote.search([("id", "=", self.remote.id)])) - - # Add manager to server managers - server.write({"manager_ids": [(4, self.manager.id)]}) - - # Manager should be able to read remote again - remote = manager_remote.search([("id", "=", self.remote.id)]) - self.assertTrue(remote) - self.assertEqual(remote.head, "main") diff --git a/addons/cetmix_tower_git/tests/test_repo.py b/addons/cetmix_tower_git/tests/test_repo.py deleted file mode 100644 index 05db569..0000000 --- a/addons/cetmix_tower_git/tests/test_repo.py +++ /dev/null @@ -1,84 +0,0 @@ -from odoo.exceptions import ValidationError - -from .common import CommonTest - - -class TestRepo(CommonTest): - """Test class for git repository.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - def test_repo_create_from_url_https_success(self): - """Test if repository is created correctly""" - # -- 1 -- - # Valid HTTPS URL - repo = self.Repo.create( - { - "url": "https://github.com/memes-demo/doge-memes.git", - } - ) - repo.invalidate_recordset() - - self.assertEqual(repo.name, "github.com/memes-demo/doge-memes") - self.assertEqual(repo.host, "github.com") - self.assertEqual(repo.owner_id.name, "memes-demo") - self.assertEqual(repo.provider, "github") - self.assertEqual(repo.is_private, False) - self.assertEqual(repo.url_ssh, "git@github.com:memes-demo/doge-memes.git") - self.assertEqual(repo.url_git, "git://github.com/memes-demo/doge-memes.git") - - def test_repo_create_from_url_ssh_success(self): - """Test if repository is created correctly""" - # -- 1 -- - # Valid SSH URL - repo = self.Repo.create( - { - "url": "git@gitlab.com:chad-guy/chad-guy.git", - } - ) - repo.invalidate_recordset() - - self.assertEqual(repo.name, "gitlab.com/chad-guy/chad-guy") - self.assertEqual(repo.host, "gitlab.com") - self.assertEqual(repo.owner_id.name, "chad-guy") - self.assertEqual(repo.provider, "gitlab") - self.assertEqual(repo.is_private, False) - self.assertEqual(repo.url, "https://gitlab.com/chad-guy/chad-guy.git") - self.assertEqual(repo.url_git, "git://gitlab.com/chad-guy/chad-guy.git") - - def test_repo_create_from_url_git_success(self): - """Test if repository is created correctly""" - # -- 1 -- - # Valid GIT URL - repo = self.Repo.create( - { - "url": "git://bitbucket.com/much-pepe/pepe-memes.git", - } - ) - self.assertEqual(repo.name, "bitbucket.com/much-pepe/pepe-memes") - self.assertEqual(repo.host, "bitbucket.com") - self.assertEqual(repo.owner_id.name, "much-pepe") - self.assertEqual(repo.provider, "bitbucket") - self.assertEqual(repo.is_private, False) - self.assertEqual(repo.url_ssh, "git@bitbucket.com:much-pepe/pepe-memes.git") - self.assertEqual(repo.url, "https://bitbucket.com/much-pepe/pepe-memes.git") - - def test_repo_create_from_url_fails(self): - """Test if repository creation fails with invalid URLs""" - - # Invalid URL 1 - with self.assertRaises(ValidationError): - self.Repo.create( - { - "url": "something.com", - } - ) - # Invalid URL 2 - with self.assertRaises(ValidationError): - self.Repo.create( - { - "url": "random string", - } - ) diff --git a/addons/cetmix_tower_git/tests/test_server.py b/addons/cetmix_tower_git/tests/test_server.py deleted file mode 100644 index f1353b8..0000000 --- a/addons/cetmix_tower_git/tests/test_server.py +++ /dev/null @@ -1,415 +0,0 @@ -try: - from odoo.addons.queue_job.tests.common import trap_jobs -except ImportError: - trap_jobs = None - -from .common import CommonTest - - -class TestServer(CommonTest): - """Test setting git project to server from plan line.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.GitProjectRel.create( - { - "git_project_id": cls.git_project_1.id, - "server_id": cls.server_test_1.id, - "file_id": cls.server_1_file_1.id, - } - ) - - def test_server_creation_running_flight_plan(self): - """Test that server is created with git project from plan line.""" - git_project = self.GitProject.create( - { - "name": "Test Git Project", - "manager_ids": [(4, self.manager.id)], - } - ) - - file_template = self.FileTemplate.create( - { - "name": "Git Config Template", - "file_name": "repos.yaml", - "server_dir": "/var/test", - "code": "repositories:\n test_repo:\n " - "url: https://github.com/test/repo.git\n target: main", - } - ) - - command = self.Command.create( - { - "name": "Create Git Config File", - "action": "file_using_template", - "file_template_id": file_template.id, - } - ) - - flight_plan = self.Plan.create( - { - "name": "Git Project Setup Plan", - "note": "Sets up a git project on the server", - } - ) - - self.plan_line.create( - { - "plan_id": flight_plan.id, - "command_id": command.id, - "sequence": 10, - "git_project_id": git_project.id, - } - ) - - server_template = self.ServerTemplate.create( - { - "name": "Git Server Template", - "ssh_port": 22, - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "flight_plan_id": flight_plan.id, - "manager_ids": [(4, self.manager.id)], - } - ) - - action = server_template.action_create_server() - - # Open the wizard and fill in the data - wizard = ( - self.env["cx.tower.server.template.create.wizard"] - .with_context(**action["context"]) - .create( - { - "name": "Git Server", - "ip_v4_address": "192.168.1.10", - "server_template_id": server_template.id, - } - ) - ) - - # If cetmix_tower_server_queue module is installed, test async processing - if self.env["ir.module.module"].search_count( - [("name", "=", "cetmix_tower_server_queue"), ("state", "=", "installed")] - ): - with trap_jobs() as trap: - wizard.action_confirm() - - # Verify that jobs were created - self.assertGreater( - len(trap.enqueued_jobs), 0, "Jobs should have been enqueued" - ) - - # Execute all trapped jobs to simulate async processing - trap.perform_enqueued_jobs() - else: - wizard.action_confirm() - - # Now search for the created records after jobs have been executed - server = self.Server.search( - [ - ("name", "=", "Git Server"), - ("server_template_id", "=", server_template.id), - ] - ) - self.assertEqual(len(server), 1, "Exactly one server should have been created") - - # Verify the file was created - file = self.File.search( - [("server_id", "=", server.id), ("name", "=", "repos.yaml")] - ) - - self.assertEqual( - len(file), 1, "Exactly one git config file should have been created" - ) - - # Verify the git project relation exists - git_project_rel = self.GitProjectRel.search( - [ - ("server_id", "=", server.id), - ("git_project_id", "=", git_project.id), - ("file_id", "=", file.id), - ] - ) - - self.assertEqual( - len(git_project_rel), 1, "Exactly one git project relation should exist" - ) - self.assertEqual( - git_project_rel.file_id, - file, - "The related file should be the git config file", - ) - self.assertEqual( - git_project_rel.git_project_id, - git_project, - "The related git project should match the one in the flight plan", - ) - self.assertEqual( - git_project_rel.project_format, - git_project._default_project_format(), - "Project format should match the default format", - ) - - def test_file_creation_with_git_project_from_custom_values(self): - """Test that git project relation is created when git project - is provided from custom values in variable_values. - """ - git_project = self.GitProject.create( - { - "name": "Test Git Project From Custom Values", - "manager_ids": [(4, self.manager.id)], - } - ) - - file_template = self.FileTemplate.create( - { - "name": "Git Config Template Custom Values", - "file_name": "repos_custom.yaml", - "server_dir": "/var/test", - "code": "repositories:\n test_repo:\n " - "url: https://github.com/test/repo.git\n target: main", - } - ) - - command = self.Command.create( - { - "name": "Create Git Config File Custom Values", - "action": "file_using_template", - "file_template_id": file_template.id, - } - ) - - flight_plan = self.Plan.create( - { - "name": "Git Project Setup Plan Custom Values", - "note": "Sets up a git project on the server from custom values", - } - ) - - # Create plan line WITHOUT git_project_id set - # The git project should come from custom values instead - plan_line = self.plan_line.create( - { - "plan_id": flight_plan.id, - "command_id": command.id, - "sequence": 10, - } - ) - - # Create plan log - plan_log = self.env["cx.tower.plan.log"].create( - { - "server_id": self.server_test_1.id, - "plan_id": flight_plan.id, - "plan_line_executed_id": plan_line.id, - } - ) - - # Create command log with variable_values containing __git_project__ - command_log = self.CommandLog.create( - { - "server_id": self.server_test_1.id, - "command_id": command.id, - "plan_log_id": plan_log.id, - "variable_values": {"__git_project__": git_project.reference}, - } - ) - - # Call the method directly to test the custom values path - file = self.server_test_1._command_runner_file_using_template_create_file( - log_record=command_log, server_dir="/var/test" - ) - - # Verify the file was created - self.assertTrue(file, "File should have been created") - - # Verify the git project relation exists - git_project_rel = self.GitProjectRel.search( - [ - ("server_id", "=", self.server_test_1.id), - ("git_project_id", "=", git_project.id), - ("file_id", "=", file.id), - ] - ) - - self.assertEqual( - len(git_project_rel), 1, "Exactly one git project relation should exist" - ) - self.assertEqual( - git_project_rel.file_id, - file, - "The related file should be the git config file", - ) - self.assertEqual( - git_project_rel.git_project_id, - git_project, - "The related git project should match the one from custom values", - ) - self.assertEqual( - git_project_rel.project_format, - git_project._default_project_format(), - "Project format should match the default format", - ) - - def test_file_creation_with_git_project_from_custom_values_priority(self): - """Test that git project from custom values takes priority - over git project from plan line. - """ - git_project_custom = self.GitProject.create( - { - "name": "Test Git Project From Custom Values Priority", - "manager_ids": [(4, self.manager.id)], - } - ) - - git_project_plan_line = self.GitProject.create( - { - "name": "Test Git Project From Plan Line", - "manager_ids": [(4, self.manager.id)], - } - ) - - file_template = self.FileTemplate.create( - { - "name": "Git Config Template Priority", - "file_name": "repos_priority.yaml", - "server_dir": "/var/test", - "code": "repositories:\n test_repo:\n " - "url: https://github.com/test/repo.git\n target: main", - } - ) - - command = self.Command.create( - { - "name": "Create Git Config File Priority", - "action": "file_using_template", - "file_template_id": file_template.id, - } - ) - - flight_plan = self.Plan.create( - { - "name": "Git Project Setup Plan Priority", - "note": "Tests priority of custom values over plan line", - } - ) - - # Create plan line WITH git_project_id set - # But custom values should take priority - plan_line = self.plan_line.create( - { - "plan_id": flight_plan.id, - "command_id": command.id, - "sequence": 10, - "git_project_id": git_project_plan_line.id, - } - ) - - # Create plan log - plan_log = self.env["cx.tower.plan.log"].create( - { - "server_id": self.server_test_1.id, - "plan_id": flight_plan.id, - "plan_line_executed_id": plan_line.id, - } - ) - - # Create command log with variable_values containing __git_project__ - # This should take priority over plan_line.git_project_id - command_log = self.CommandLog.create( - { - "server_id": self.server_test_1.id, - "command_id": command.id, - "plan_log_id": plan_log.id, - "variable_values": {"__git_project__": git_project_custom.reference}, - } - ) - - # Call the method directly to test the custom values path - file = self.server_test_1._command_runner_file_using_template_create_file( - log_record=command_log, server_dir="/var/test" - ) - - # Verify the file was created - self.assertTrue(file, "File should have been created") - - # Verify the git project relation uses the git project from custom values - # (not the one from plan line) - git_project_rel = self.GitProjectRel.search( - [ - ("server_id", "=", self.server_test_1.id), - ("git_project_id", "=", git_project_custom.id), - ("file_id", "=", file.id), - ] - ) - - self.assertEqual( - len(git_project_rel), 1, "Exactly one git project relation should exist" - ) - self.assertEqual( - git_project_rel.git_project_id, - git_project_custom, - "The related git project should match the one from custom values, " - "not from plan line", - ) - - # Verify that the plan line git project was NOT used - git_project_rel_plan_line = self.GitProjectRel.search( - [ - ("server_id", "=", self.server_test_1.id), - ("git_project_id", "=", git_project_plan_line.id), - ("file_id", "=", file.id), - ] - ) - self.assertEqual( - len(git_project_rel_plan_line), - 0, - "No relation should exist for the plan line git project", - ) - - def test_server_get_servers_by_git_ref_success(self): - """Check the success case of server.get_servers_by_git_ref""" - - # 1. URL only - servers = self.Server.get_servers_by_git_ref( - self.remote_github_https.repo_id.url - ) - self.assertEqual(servers, self.server_test_1) - - # 2. Specific URL with specific head - servers = self.Server.get_servers_by_git_ref( - self.remote_github_https.repo_id.url, "123" - ) - self.assertEqual(servers, self.server_test_1) - - # 2. Specific URL with specific head and head type - servers = self.Server.get_servers_by_git_ref( - self.remote_github_https.repo_id.url, "123", "pr" - ) - self.assertEqual(servers, self.server_test_1) - - def test_server_get_servers_by_git_ref_no_match(self): - """Check the no match case of server.get_servers_by_git_ref""" - - # 1. Repo link does not exist - servers = self.Server.get_servers_by_git_ref( - "https://github.com/other-org/other-repo.git", "main", "branch" - ) - self.assertFalse(servers) - - # 2. Repo link exists, but remote does not exist - servers = self.Server.get_servers_by_git_ref( - self.repo_cetmix_tower.url, "3311", "pr" - ) - self.assertFalse(servers) - - # 3. Repo link exists, but remote type does not exist - servers = self.Server.get_servers_by_git_ref( - self.repo_cetmix_tower.url, "main", "commit" - ) - self.assertFalse(servers) diff --git a/addons/cetmix_tower_git/tests/test_source.py b/addons/cetmix_tower_git/tests/test_source.py deleted file mode 100644 index de67eef..0000000 --- a/addons/cetmix_tower_git/tests/test_source.py +++ /dev/null @@ -1,226 +0,0 @@ -from odoo.exceptions import AccessError - -from .common import CommonTest - - -class TestSource(CommonTest): - """Test class for git source.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - # Create another manager for testing - cls.manager_2 = cls.Users.create( - { - "name": "Second Manager", - "login": "manager2", - "email": "manager2@test.com", - "groups_id": [(4, cls.env.ref("cetmix_tower_server.group_manager").id)], - } - ) - - # Create test project and source as root - cls.project = cls.GitProject.create( - { - "name": "Test Project", - } - ) - cls.source = cls.GitSource.create( - { - "name": "Test Source", - "git_project_id": cls.project.id, - } - ) - - def test_user_access(self): - """Test that regular users have no access to git sources""" - user_source = self.GitSource.with_user(self.user) - - # Test CRUD operations - with self.assertRaises(AccessError): - user_source.create( - { - "name": "New Source", - "git_project_id": self.project.id, - } - ) - with self.assertRaises(AccessError): - user_source.browse(self.source.id).read(["name"]) - with self.assertRaises(AccessError): - user_source.browse(self.source.id).write({"name": "Updated Name"}) - with self.assertRaises(AccessError): - user_source.browse(self.source.id).unlink() - - def test_manager_read_access(self): - """Test manager read access rules""" - manager_source = self.GitSource.with_user(self.manager) - - # Manager not in project user_ids or manager_ids - should not read - with self.assertRaises(AccessError): - manager_source.browse(self.source.id).read(["name"]) - - # Add manager to project user_ids - should read - self.project.write({"user_ids": [(4, self.manager.id)]}) - self.assertEqual(manager_source.browse(self.source.id).name, "Test Source") - - # Remove from user_ids, add to manager_ids - should read - self.project.write( - {"user_ids": [(3, self.manager.id)], "manager_ids": [(4, self.manager.id)]} - ) - self.assertEqual(manager_source.browse(self.source.id).name, "Test Source") - - def test_manager_write_access(self): - """Test manager write/create access rules""" - manager_source = self.GitSource.with_user(self.manager) - - # Create project as manager - should be added to manager_ids automatically - project = self.GitProject.with_user(self.manager).create( - { - "name": "Manager Project", - } - ) - self.assertIn(self.manager, project.manager_ids) - - # Create source in own project - should succeed - new_source = manager_source.create( - { - "name": "New Source", - "git_project_id": project.id, - } - ) - self.assertTrue(new_source.exists()) - - # Write to own source - should succeed - new_source.write({"name": "Updated Name"}) - self.assertEqual(new_source.name, "Updated Name") - - # Write to other's source - should fail - with self.assertRaises(AccessError): - manager_source.browse(self.source.id).write({"name": "Updated Name"}) - - def test_manager_unlink_access(self): - """Test manager unlink access rules""" - # Create project and source as manager_2 - project = self.GitProject.with_user(self.manager_2).create( - { - "name": "Manager 2 Project", - } - ) - source = self.GitSource.with_user(self.manager_2).create( - { - "name": "Source to Delete", - "git_project_id": project.id, - } - ) - manager_source = self.GitSource.with_user(self.manager) - - # Try delete as different manager - should fail even if added to manager_ids - project.write({"manager_ids": [(4, self.manager.id)]}) - with self.assertRaises(AccessError): - manager_source.browse(source.id).unlink() - - # Create source as manager and try delete - should succeed - own_source = manager_source.create( - { - "name": "Own Source", - "git_project_id": project.id, - } - ) - self.assertTrue(own_source.exists()) - own_source.unlink() - self.assertFalse(own_source.exists()) - - def test_root_access(self): - """Test root access rules""" - root_source = self.GitSource.with_user(self.root) - - # Create - new_source = root_source.create( - { - "name": "Root Source", - "git_project_id": self.project.id, - } - ) - self.assertTrue(new_source.exists()) - - # Read - self.assertEqual(root_source.browse(self.source.id).name, "Test Source") - - # Write - root_source.browse(self.source.id).write({"name": "Updated by Root"}) - self.assertEqual(self.source.name, "Updated by Root") - - # Delete - new_source.unlink() - self.assertFalse(new_source.exists()) - - def test_source_git_aggregator_prepare_record(self): - """Test if source prepare record method works correctly.""" - - # -- 1 -- - # Source 1 - expected_result = { - "remotes": { - "remote_1": "https://github.com/cetmix-test/cetmix-tower-test.git", - "remote_2": "https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git", - "remote_3": "git@my.gitlab.com:cetmix-test/cetmix-tower-test.git", - }, - "merges": [ - {"remote": "remote_1", "ref": "refs/pull/123/head"}, - {"remote": "remote_2", "ref": "main"}, - {"remote": "remote_3", "ref": "10000000"}, - ], - "target": "remote_1", - } - prepared_result = self.git_source_1._git_aggregator_prepare_record() - self.assertEqual( - prepared_result, expected_result, "Prepared result is not correct" - ) - - def test_manager_server_based_access(self): - """Test manager access to sources through server relationships""" - manager_source = self.GitSource.with_user(self.manager) - - # Create a server where manager is a user - server = self.Server.create( - { - "name": "Test Server", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": self.os_debian_10.id, - "user_ids": [(4, self.manager.id)], - } - ) - - # Link project to server - file = self.File.create( - { - "name": "test_file", - "server_id": server.id, - } - ) - self.GitProjectRel.create( - { - "server_id": server.id, - "file_id": file.id, - "git_project_id": self.project.id, - "project_format": "git_aggregator", - } - ) - - # Manager should be able to read source through server relationship - self.assertEqual(manager_source.browse(self.source.id).name, "Test Source") - - # Remove manager from server users - server.write({"user_ids": [(3, self.manager.id)]}) - - # Manager should not be able to read source anymore - with self.assertRaises(AccessError): - manager_source.browse(self.source.id).read(["name"]) - - # Add manager to server managers - server.write({"manager_ids": [(4, self.manager.id)]}) - - # Manager should be able to read source again - self.assertEqual(manager_source.browse(self.source.id).name, "Test Source") diff --git a/addons/cetmix_tower_git/tools/git_aggregator.py b/addons/cetmix_tower_git/tools/git_aggregator.py deleted file mode 100644 index e69de29..0000000 diff --git a/addons/cetmix_tower_git/views/cx_tower_file_template_views.xml b/addons/cetmix_tower_git/views/cx_tower_file_template_views.xml deleted file mode 100644 index 6f0ec86..0000000 --- a/addons/cetmix_tower_git/views/cx_tower_file_template_views.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - cx.tower.file.template.view.form - cx.tower.file.template - - - - - - - - - diff --git a/addons/cetmix_tower_git/views/cx_tower_file_views.xml b/addons/cetmix_tower_git/views/cx_tower_file_views.xml deleted file mode 100644 index 917bbf4..0000000 --- a/addons/cetmix_tower_git/views/cx_tower_file_views.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - cx.tower.file.view.form - cx.tower.file - - - - - - - - - diff --git a/addons/cetmix_tower_git/views/cx_tower_git_project_views.xml b/addons/cetmix_tower_git/views/cx_tower_git_project_views.xml deleted file mode 100644 index b449088..0000000 --- a/addons/cetmix_tower_git/views/cx_tower_git_project_views.xml +++ /dev/null @@ -1,278 +0,0 @@ - - - - - - cx.tower.git.project.tree - cx.tower.git.project - - - - - - - - - - - - cx.tower.git.project.form - cx.tower.git.project - -
- - -
-

- -

-

- -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
-

- * Sources where all remotes are private -

-
-
-

- * Sources where some remotes are private -

-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- - - - cx.tower.git.repo.search - cx.tower.git.repo - - - - - - - - - - - - - - - - - - - - - - - - - - Repositories - cx.tower.git.repo - tree,form - -

- Create your first repository! -

-

- Repositories represent git repositories with their metadata and configuration. - They can be linked to remotes to automatically populate URL information. -

-
-
- -
diff --git a/addons/cetmix_tower_git/views/cx_tower_git_source_views.xml b/addons/cetmix_tower_git/views/cx_tower_git_source_views.xml deleted file mode 100644 index a9a3a31..0000000 --- a/addons/cetmix_tower_git/views/cx_tower_git_source_views.xml +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - cx.tower.git.source.tree - cx.tower.git.source - - - - - - - - - - - cx.tower.git.source.form - cx.tower.git.source - -
- - -
-

- -

-

- -

-
- - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- -
diff --git a/addons/cetmix_tower_git/views/cx_tower_plan_line_view.xml b/addons/cetmix_tower_git/views/cx_tower_plan_line_view.xml deleted file mode 100644 index 2783a8a..0000000 --- a/addons/cetmix_tower_git/views/cx_tower_plan_line_view.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - cx.tower.plan.line.view.form - cx.tower.plan.line - - - - - - - - - - - - diff --git a/addons/cetmix_tower_git/views/cx_tower_server_view.xml b/addons/cetmix_tower_git/views/cx_tower_server_view.xml deleted file mode 100644 index 39d2878..0000000 --- a/addons/cetmix_tower_git/views/cx_tower_server_view.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - cx.tower.server.view.form.shortcuts - cx.tower.server - - - - - - - - - - - - - - - -
-
- - -
-
- No secrets found - No variables found -
-
- - - diff --git a/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.esm.js b/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.esm.js deleted file mode 100644 index 42d274e..0000000 --- a/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.esm.js +++ /dev/null @@ -1,33 +0,0 @@ -/** @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); diff --git a/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.scss b/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.scss deleted file mode 100644 index 2562835..0000000 --- a/addons/cetmix_tower_server/static/src/components/server_status/server_status_field.scss +++ /dev/null @@ -1,33 +0,0 @@ -.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; - } -} diff --git a/addons/cetmix_tower_server/static/src/utils/server_utils.esm.js b/addons/cetmix_tower_server/static/src/utils/server_utils.esm.js deleted file mode 100644 index 158d82a..0000000 --- a/addons/cetmix_tower_server/static/src/utils/server_utils.esm.js +++ /dev/null @@ -1,17 +0,0 @@ -/** @odoo-module */ - -/** - * List of colors according to the selection value - */ -export const STATUS_COLORS = { - false: "info", - stopped: "danger", - starting: "warning", - running: "success", - stopping: "warning", - restarting: "warning", - delete_error: "danger", -}; - -export const STATUS_COLOR_PREFIX = - "o_server_status_bubble mx-0 o_color_server_status_bubble_"; diff --git a/addons/cetmix_tower_server/tests/__init__.py b/addons/cetmix_tower_server/tests/__init__.py deleted file mode 100644 index fc3c813..0000000 --- a/addons/cetmix_tower_server/tests/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -from . import test_server -from . import test_command -from . import test_file -from . import test_file_template -from . import test_plan -from . import test_plan_line -from . import test_plan_line_action -from . import test_command_log -from . import test_plan_log -from . import test_server_log -from . import test_server_template -from . import test_variable -from . import test_variable_value -from . import test_variable_option -from . import test_command_wizard -from . import test_reference_mixin -from . import test_scheduled_task -from . import test_update_related_variable_names -from . import test_key -from . import test_cetmix_tower -from . import test_tag -from . import test_shortcut -from . import test_tools -from . import test_partner_server_btn -from . import test_vault_mixin -from . import test_tag_mixin -from . import test_jet_template -from . import test_jet_template_access -from . import test_jet_template_dependency_access -from . import test_jet_template_install -from . import test_jet_template_install_access -from . import test_jet_template_install_line_access -from . import test_jet_access -from . import test_jet_dependency_access -from . import test_jet_action_access -from . import test_jet_create_wizard -from . import test_jet_state -from . import test_jet -from . import test_server_jet_action_command -from . import test_jet_waypoint -from . import test_jet_waypoint_template_access -from . import test_jet_waypoint_access diff --git a/addons/cetmix_tower_server/tests/common.py b/addons/cetmix_tower_server/tests/common.py deleted file mode 100644 index 7cb0061..0000000 --- a/addons/cetmix_tower_server/tests/common.py +++ /dev/null @@ -1,509 +0,0 @@ -# Copyright (C) 2022 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import os -from unittest.mock import MagicMock, patch - -from odoo import _ -from odoo.exceptions import ValidationError - -from odoo.addons.base.tests.common import BaseCommon - -from ..models.constants import GENERAL_ERROR -from ..ssh.ssh import SftpService, SSHConnection - - -class TestTowerCommon(BaseCommon): - """ - Common test class for Cetmix Tower. - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - # Disable transaction commit to avoid race conditions - cls.env = cls.env["base"].with_context(cetmix_tower_no_commit=True).env - - # ---------------------------------------------- - # -- Create core elements invoked in the tests - # ---------------------------------------------- - # Group XML records - cls.group_user = cls.env.ref("cetmix_tower_server.group_user") - cls.group_manager = cls.env.ref("cetmix_tower_server.group_manager") - cls.group_root = cls.env.ref("cetmix_tower_server.group_root") - - # Cetmix Tower helper model - cls.CetmixTower = cls.env["cetmix.tower"] - - # Tags - cls.Tag = cls.env["cx.tower.tag"] - cls.tag_test_staging = cls.Tag.create({"name": "Test Staging"}) - cls.tag_test_production = cls.Tag.create({"name": "Test Production"}) - - # Users - cls.Users = cls.env["res.users"] - cls.user_bob = cls.Users.create( - { - "name": "Bob", - "login": "bob", - "groups_id": [(4, cls.env.ref("base.group_user").id)], - } - ) - cls.user = cls.Users.create( - { - "name": "Test User", - "login": "test_user", - "email": "test_user@example.com", - "groups_id": [(6, 0, [cls.group_user.id])], - } - ) - cls.manager = cls.Users.create( - { - "name": "Test Manager", - "login": "test_manager", - "email": "test_manager@example.com", - "groups_id": [(6, 0, [cls.group_manager.id])], - } - ) - cls.root = cls.Users.create( - { - "name": "Test Root", - "login": "test_root", - "email": "test_root@example.com", - "groups_id": [(6, 0, [cls.group_root.id])], - } - ) - - # OS - cls.os_debian_10 = cls.env["cx.tower.os"].create({"name": "Test Debian 10"}) - - # Server - cls.Server = cls.env["cx.tower.server"] - cls.server_test_1 = cls.Server.create( - { - "name": "Test 1", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "host_key": "test_key", - "os_id": cls.os_debian_10.id, - } - ) - - # Server Template - cls.ServerTemplate = cls.env["cx.tower.server.template"] - cls.server_template_sample = cls.ServerTemplate.create( - { - "name": "Sample Template", - "ssh_port": 22, - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": cls.os_debian_10.id, - } - ) - - # Server log - cls.ServerLog = cls.env["cx.tower.server.log"] - - # Variable - cls.Variable = cls.env["cx.tower.variable"] - cls.VariableValue = cls.env["cx.tower.variable.value"] - cls.VariableOption = cls.env["cx.tower.variable.option"] - - cls.variable_path = cls.Variable.create({"name": "test_path_"}) - cls.variable_dir = cls.Variable.create({"name": "test_dir"}) - cls.variable_os = cls.Variable.create({"name": "test_os"}) - cls.variable_url = cls.Variable.create({"name": "test_url"}) - cls.variable_version = cls.Variable.create({"name": "test_version"}) - - # Key - cls.Key = cls.env["cx.tower.key"] - cls.KeyValue = cls.env["cx.tower.key.value"] - - cls.key_1 = cls.Key.create( - {"name": "Test Key 1", "key_type": "k", "secret_value": "much key"} - ) - cls.secret_2 = cls.Key.create( - {"name": "Test Key 2", "key_type": "s", "secret_value": "secret top"} - ) - - # Command - cls.sudo_prefix = "sudo -S -p ''" - cls.Command = cls.env["cx.tower.command"] - cls.command_create_dir = cls.Command.create( - { - "name": "Test create directory", - "path": "/home/{{ tower.server.username }}", - "code": "cd {{ test_path_ }} && mkdir {{ test_dir }}", - } - ) - cls.command_list_dir = cls.Command.create( - { - "name": "Test create directory", - "path": "/home/{{ tower.server.username }}", - "code": "cd {{ test_path_ }} && ls -l", - } - ) - - cls.template_file_tower = cls.env["cx.tower.file.template"].create( - { - "name": "Test file template", - "file_name": "test_os.txt", - "source": "tower", - "server_dir": "/home/{{ tower.server.username }}", - "code": "Hello, world!", - } - ) - - cls.template_file_server = cls.env["cx.tower.file.template"].create( - { - "name": "Test file template", - "file_name": "test_os.txt", - "source": "server", - "server_dir": "/home/{{ tower.server.username }}", - } - ) - - cls.command_create_file_with_template_tower_source = cls.Command.create( - { - "name": "Test create file with template with tower source", - "path": "/home/{{ tower.server.username }}", - "action": "file_using_template", - "file_template_id": cls.template_file_tower.id, - "if_file_exists": "raise", - } - ) - - cls.command_create_file_with_template_server_source = cls.Command.create( - { - "name": "Test create file with template with server source", - "path": "/home/{{ tower.server.username }}", - "action": "file_using_template", - "file_template_id": cls.template_file_server.id, - "if_file_exists": "raise", - } - ) - - # Command log - cls.CommandLog = cls.env["cx.tower.command.log"] - - # File template - cls.FileTemplate = cls.env["cx.tower.file.template"] - - # File - cls.File = cls.env["cx.tower.file"] - - # Flight Plans - cls.Plan = cls.env["cx.tower.plan"] - cls.plan_line = cls.env["cx.tower.plan.line"] - cls.plan_line_action = cls.env["cx.tower.plan.line.action"] - - cls.plan_1 = cls.Plan.create( - { - "name": "Test plan 1", - "note": "Create directory and list its content", - "tag_ids": [(6, 0, [cls.tag_test_staging.id])], - } - ) - cls.plan_line_1 = cls.plan_line.create( - { - "sequence": 5, - "plan_id": cls.plan_1.id, - "command_id": cls.command_create_dir.id, - "path": "/such/much/path", - } - ) - cls.plan_line_2 = cls.plan_line.create( - { - "sequence": 20, - "plan_id": cls.plan_1.id, - "command_id": cls.command_list_dir.id, - } - ) - cls.plan_line_1_action_1 = cls.plan_line_action.create( - { - "line_id": cls.plan_line_1.id, - "sequence": 1, - "condition": "==", - "value_char": "0", - } - ) - cls.plan_line_1_action_2 = cls.plan_line_action.create( - { - "line_id": cls.plan_line_1.id, - "sequence": 2, - "condition": ">", - "value_char": "0", - "action": "ec", - "custom_exit_code": 255, - } - ) - cls.plan_line_2_action_1 = cls.plan_line_action.create( - { - "line_id": cls.plan_line_2.id, - "sequence": 1, - "condition": "==", - "value_char": "-1", - "action": "ec", - "custom_exit_code": 100, - } - ) - cls.plan_line_2_action_2 = cls.plan_line_action.create( - { - "line_id": cls.plan_line_2.id, - "sequence": 2, - "condition": ">=", - "value_char": "3", - "action": "n", - } - ) - - # Flight plan log - cls.PlanLog = cls.env["cx.tower.plan.log"] - - # Shortcut - cls.Shortcut = cls.env["cx.tower.shortcut"] - - # Model references - cls.OS = cls.env["cx.tower.os"] - cls.PlanLineAction = cls.env["cx.tower.plan.line.action"] - - # Scheduled task - cls.ScheduledTask = cls.env["cx.tower.scheduled.task"] - cls.ScheduledTaskCv = cls.env["cx.tower.scheduled.task.cv"] - # Jet State - cls.JetState = cls.env["cx.tower.jet.state"] - - # Jet Action - cls.JetAction = cls.env["cx.tower.jet.action"] - - # Jet Template Install - cls.JetTemplateInstall = cls.env["cx.tower.jet.template.install"] - - # Jet Template Install Line - cls.JetTemplateInstallLine = cls.env["cx.tower.jet.template.install.line"] - - # Jet Template Dependency - cls.JetTemplateDependency = cls.env["cx.tower.jet.template.dependency"] - - # Jet Template - cls.JetTemplate = cls.env["cx.tower.jet.template"] - cls.jet_template_sample = cls.JetTemplate.create( - { - "name": "Sample Jet Template", - "server_ids": [(4, cls.server_test_1.id)], - "variable_value_ids": [ - ( - 0, - 0, - { - "variable_id": cls.variable_path.id, - "value_char": "/jets/templates/template1", - }, - ), - ( - 0, - 0, - {"variable_id": cls.variable_os.id, "value_char": "Debian 10"}, - ), - ( - 0, - 0, - { - "variable_id": cls.variable_url.id, - "value_char": "https://jets.example.com", - }, - ), - ( - 0, - 0, - { - "variable_id": cls.variable_dir.id, - "value_char": "jet_templates", - }, - ), - ], - } - ) - - # Jets - cls.Jet = cls.env["cx.tower.jet"] - cls.jet_sample = cls.Jet.create( - { - "name": "Sample Jet", - "jet_template_id": cls.jet_template_sample.id, - "server_id": cls.server_test_1.id, - "variable_value_ids": [ - ( - 0, - 0, - { - "variable_id": cls.variable_path.id, - "value_char": "/jets/jet1", - }, - ) - ], - } - ) - - # apply ssh connection patches - cls.apply_patches() - - @classmethod - def apply_patches(cls): - """ - Apply mock patches for SSH-related methods to simulate various - scenarios during testing. - - Patches: - 1. SSHConnection.connect: - - Returns a mock connection with a fake exec_command method, - which returns a successful or unsuccessful result depending on the - command content. - 2. SftpService.download_file: - - Returns b"ok\x00" for files with the .zip extension and - b"ok" for the rest. - 3. SftpService.upload_file: - - Returns MagicMock, simulating file upload. - 4. SftpService.delete_file: - - Returns MagicMock, simulating file deletion. - """ - - # Patch connection SSH method - def ssh_connect(self): - connection_mock = MagicMock() - - # set up stdin with a condition for error simulation - def exec_command_side_effect(command, *args, **kwargs): - # Create mocks for stdin, stdout, and stderr - stdin_mock = MagicMock() - stdout_mock = MagicMock() - stderr_mock = MagicMock() - - if "fail" in command: - # Simulate failure - stdout_mock.channel.recv_exit_status.return_value = GENERAL_ERROR - stdout_mock.readlines.return_value = [] - stderr_mock.readlines.return_value = ["error"] - return stdin_mock, stdout_mock, stderr_mock - elif "raise" in command: - # Simulate an exception - raise Exception("error") # pylint: disable=broad-exception-raised - else: - # Simulate success - stdout_mock.channel.recv_exit_status.return_value = 0 - stdout_mock.readlines.return_value = ["ok"] - stderr_mock.readlines.return_value = [] - return stdin_mock, stdout_mock, stderr_mock - - # Apply side effect to exec_command - connection_mock.exec_command.side_effect = exec_command_side_effect - - return connection_mock - - connect_patch = patch.object(SSHConnection, "connect", new=ssh_connect) - connect_patch.start() - cls.addClassCleanup(connect_patch.stop) - - # Patch file manipulation methods for testing - def ssh_download_file(self, remote_path): - if hasattr(self, "env"): - error = self.env.context.get("raise_download_error") - if error: - raise ValidationError(error) - - _, extension = os.path.splitext(remote_path) - if extension == ".zip": - return b"ok\x00" - return b"ok" - - download_patch = patch.object( - SftpService, "download_file", new=ssh_download_file - ) - download_patch.start() - cls.addClassCleanup(download_patch.stop) - - def ssh_upload_file(self, file, remote_path): - if hasattr(self, "env"): - error = self.env.context.get("raise_upload_error") - if error: - raise ValidationError(error) - return MagicMock() - - upload_patch = patch.object(SftpService, "upload_file", new=ssh_upload_file) - upload_patch.start() - cls.addClassCleanup(upload_patch.stop) - - def ssh_delete_file(self, remote_path): - return MagicMock() - - delete_patch = patch.object(SftpService, "delete_file", new=ssh_delete_file) - delete_patch.start() - cls.addClassCleanup(delete_patch.stop) - - @classmethod - def add_to_group(cls, user, group_refs): - """Add user to groups - - Args: - user (res.users): User record - group_refs (list): Group ref OR List of group references - eg ['base.group_user', 'some_module.some_group'...] - """ - if isinstance(group_refs, str): - group = cls.env.ref(group_refs, raise_if_not_found=False) - if not group: - raise ValidationError(_("Group reference %s not found!") % group_refs) - action = [(4, group.id)] - elif isinstance(group_refs, list): - action = [] - for group_ref in group_refs: - group = cls.env.ref(group_ref, raise_if_not_found=False) - if not group: - raise ValidationError( - _("Group reference %s not found!") % group_ref - ) - action.append((4, group.id)) - else: - raise ValidationError(_("groups_ref must be string or list of strings!")) - user.write({"groups_id": action}) - - @classmethod - def remove_from_group(cls, user, group_refs): - """Remove user from groups - - Args: - user (res.users): User record - group_refs (list): List of group references - eg ['base.group_user', 'some_module.some_group'...] - """ - if isinstance(group_refs, str): - group = cls.env.ref(group_refs, raise_if_not_found=False) - if not group: - raise ValidationError(_("Group reference %s not found!") % group_refs) - action = [(3, group.id)] - elif isinstance(group_refs, list): - action = [] - for group_ref in group_refs: - group = cls.env.ref(group_ref, raise_if_not_found=False) - if not group: - raise ValidationError( - _("Group reference %s not found!") % group_ref - ) - action.append((3, group.id)) - else: - raise ValidationError(_("groups_ref must be string or list of strings!")) - user.write({"groups_id": action}) - - @classmethod - def write_and_invalidate(cls, records, **values): - """Write values and invalidate cache - - Args: - records (recordset): recordset to save values - **values (dict): values to set - """ - if values: - records.write(values) - records.invalidate_recordset(values.keys()) diff --git a/addons/cetmix_tower_server/tests/common_jets.py b/addons/cetmix_tower_server/tests/common_jets.py deleted file mode 100644 index 4bb9b31..0000000 --- a/addons/cetmix_tower_server/tests/common_jets.py +++ /dev/null @@ -1,730 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import _ -from odoo.exceptions import AccessError - -from .common import TestTowerCommon - - -class TestTowerJetsCommon(TestTowerCommon): - """ - Common test class for Jet and JetTemplate models with shared test data - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create jet states for testing - cls.state_initial = cls.JetState.create( - { - "name": "Test Initial", - "reference": "test_initial", - "sequence": 10, - "color": 1, - } - ) - cls.state_running = cls.JetState.create( - { - "name": "Test Running", - "reference": "test_running", - "sequence": 20, - "color": 2, - } - ) - cls.state_stopped = cls.JetState.create( - { - "name": "Test Stopped", - "reference": "test_stopped", - "sequence": 30, - "color": 3, - } - ) - cls.state_error = cls.JetState.create( - { - "name": "Test Error", - "reference": "test_error", - "sequence": 40, - "color": 4, - } - ) - - # Create transit states - cls.state_starting = cls.JetState.create( - { - "name": "Test Starting", - "reference": "test_starting", - "sequence": 15, - "color": 5, - } - ) - cls.state_stopping = cls.JetState.create( - { - "name": "Test Stopping", - "reference": "test_stopping", - "sequence": 25, - "color": 6, - } - ) - - # Create test states for pathfinding and adjacency tests - cls.state_a = cls.JetState.create( - { - "name": "Test State A", - "reference": "test_state_a", - "sequence": 30, - } - ) - cls.state_b = cls.JetState.create( - { - "name": "Test State B", - "reference": "test_state_b", - "sequence": 31, - } - ) - cls.state_c = cls.JetState.create( - { - "name": "Test State C", - "reference": "test_state_c", - "sequence": 32, - } - ) - cls.state_d = cls.JetState.create( - { - "name": "Test State D", - "reference": "test_state_d", - "sequence": 33, - } - ) - - # Create jet template for testing - cls.jet_template_test = cls.JetTemplate.create( - { - "name": "Test Jet Template", - "reference": "test_jet_template", - } - ) - - # Create dependency hierarchy for testing: - # Odoo -> Postgres, Nginx -> Docker -> Tower Core - # Level 1: Base dependencies - cls.jet_template_tower_core = cls.JetTemplate.create( - { - "name": "Tower Core", - "reference": "tower_core", - } - ) - - # Level 2: Infrastructure - cls.jet_template_docker = cls.JetTemplate.create( - { - "name": "Docker", - "reference": "docker", - } - ) - # Docker requires Tower Core to be running - cls._create_jet_template_dependency( - template=cls.jet_template_docker, - template_required=cls.jet_template_tower_core, - state_required_id=cls.state_running.id, - ) - - # Level 3: Services - cls.jet_template_nginx = cls.JetTemplate.create( - { - "name": "Nginx", - "reference": "nginx", - } - ) - # Nginx requires Docker to be running - cls._create_jet_template_dependency( - template=cls.jet_template_nginx, - template_required=cls.jet_template_docker, - state_required_id=cls.state_running.id, - ) - - # Level 3: Database - cls.jet_template_postgres = cls.JetTemplate.create( - { - "name": "Postgres", - "reference": "postgres", - } - ) - # Postgres requires Docker to be running - cls._create_jet_template_dependency( - template=cls.jet_template_postgres, - template_required=cls.jet_template_docker, - state_required_id=cls.state_running.id, - ) - - cls.jet_template_mariadb = cls.JetTemplate.create( - { - "name": "MariaDB", - "reference": "mariadb", - } - ) - # MariaDB requires Docker to be running - cls._create_jet_template_dependency( - template=cls.jet_template_mariadb, - template_required=cls.jet_template_docker, - state_required_id=cls.state_running.id, - ) - - # Level 5: Applications - cls.jet_template_odoo = cls.JetTemplate.create( - { - "name": "Odoo", - "reference": "odoo", - } - ) - # Odoo requires Postgres to be running - cls._create_jet_template_dependency( - template=cls.jet_template_odoo, - template_required=cls.jet_template_postgres, - state_required_id=cls.state_running.id, - ) - # Odoo requires Nginx to be running - cls._create_jet_template_dependency( - template=cls.jet_template_odoo, - template_required=cls.jet_template_nginx, - state_required_id=cls.state_running.id, - ) - - cls.jet_template_wordpress = cls.JetTemplate.create( - { - "name": "WordPress", - "reference": "wordpress", - } - ) - # WordPress requires MariaDB to be running - cls._create_jet_template_dependency( - template=cls.jet_template_wordpress, - template_required=cls.jet_template_mariadb, - state_required_id=cls.state_running.id, - ) - # WordPress requires Nginx to be running - cls._create_jet_template_dependency( - template=cls.jet_template_wordpress, - template_required=cls.jet_template_nginx, - state_required_id=cls.state_running.id, - ) - - # Level 6: E-commerce Integration - cls.jet_template_woocommerce_odoo = cls.JetTemplate.create( - { - "name": "WooCommerce with Odoo", - "reference": "woocommerce_odoo", - } - ) - # WooCommerce requires WordPress to be running - cls._create_jet_template_dependency( - template=cls.jet_template_woocommerce_odoo, - template_required=cls.jet_template_wordpress, - state_required_id=cls.state_running.id, - ) - # WooCommerce requires Odoo to be running - cls._create_jet_template_dependency( - template=cls.jet_template_woocommerce_odoo, - template_required=cls.jet_template_odoo, - state_required_id=cls.state_running.id, - ) - - # Create test jets for different templates - cls.jet_test = cls._create_jet( - name="Test Jet", - reference="test_jet", - template=cls.jet_template_test, - server=cls.server_test_1, - ) - - cls.jet_odoo = cls._create_jet( - name="Odoo Jet", - reference="odoo_jet", - template=cls.jet_template_odoo, - server=cls.server_test_1, - ) - - cls.jet_wordpress = cls._create_jet( - name="WordPress Jet", - reference="wordpress_jet", - template=cls.jet_template_wordpress, - server=cls.server_test_1, - ) - - cls.jet_woocommerce = cls._create_jet( - name="WooCommerce Jet", - reference="woocommerce_jet", - template=cls.jet_template_woocommerce_odoo, - server=cls.server_test_1, - ) - - # Add some dependencies with different state requirements for testing - # Create a monitoring template that requires services to be in "running" state - cls.jet_template_monitoring = cls.JetTemplate.create( - { - "name": "Monitoring", - "reference": "monitoring", - } - ) - - # Monitoring requires Odoo to be running (for business metrics) - cls._create_jet_template_dependency( - template=cls.jet_template_monitoring, - template_required=cls.jet_template_odoo, - state_required_id=cls.state_running.id, - ) - - # Create a backup template that requires services to be in "stopped" state - cls.jet_template_backup = cls.JetTemplate.create( - { - "name": "Backup", - "reference": "backup", - } - ) - - # Backup requires Postgres to be stopped for safe backup - cls._create_jet_template_dependency( - template=cls.jet_template_backup, - template_required=cls.jet_template_postgres, - state_required_id=cls.state_stopped.id, - ) - - # Create common actions for testing - cls.action_running_to_stopped = cls.JetAction.create( - { - "name": "Stop Action", - "reference": "stop_action", - "jet_template_id": cls.jet_template_test.id, - "state_from_id": cls.state_running.id, - "state_to_id": cls.state_stopped.id, - "state_transit_id": cls.state_stopping.id, - "priority": 10, - } - ) - - cls.action_stopped_to_running = cls.JetAction.create( - { - "name": "Start Action", - "reference": "start_action", - "jet_template_id": cls.jet_template_test.id, - "state_from_id": cls.state_stopped.id, - "state_to_id": cls.state_running.id, - "state_transit_id": cls.state_starting.id, - "priority": 10, - } - ) - - cls.action_running_to_error = cls.JetAction.create( - { - "name": "Error Action", - "reference": "error_action", - "jet_template_id": cls.jet_template_test.id, - "state_from_id": cls.state_running.id, - "state_to_id": cls.state_error.id, - "state_transit_id": cls.state_error.id, - "priority": 20, - } - ) - - cls.action_error_to_running = cls.JetAction.create( - { - "name": "Recover Action", - "reference": "recover_action", - "jet_template_id": cls.jet_template_test.id, - "state_from_id": cls.state_error.id, - "state_to_id": cls.state_running.id, - "state_transit_id": cls.state_starting.id, - "priority": 10, - } - ) - - cls.action_initial_to_running = cls.JetAction.create( - { - "name": "Initialize Action", - "reference": "initialize_action", - "jet_template_id": cls.jet_template_test.id, - "state_from_id": cls.state_initial.id, - "state_to_id": cls.state_running.id, - "state_transit_id": cls.state_starting.id, - "priority": 5, - } - ) - - # Create actions for pathfinding tests (A -> B -> C -> D) - cls.action_a_to_b = cls.JetAction.create( - { - "name": "Action A to B", - "reference": "action_a_to_b", - "jet_template_id": cls.jet_template_test.id, - "state_from_id": cls.state_a.id, - "state_to_id": cls.state_b.id, - "state_transit_id": cls.state_starting.id, - "priority": 10, - } - ) - - cls.action_b_to_c = cls.JetAction.create( - { - "name": "Action B to C", - "reference": "action_b_to_c", - "jet_template_id": cls.jet_template_test.id, - "state_from_id": cls.state_b.id, - "state_to_id": cls.state_c.id, - "state_transit_id": cls.state_stopping.id, - "priority": 10, - } - ) - - cls.action_c_to_d = cls.JetAction.create( - { - "name": "Action C to D", - "reference": "action_c_to_d", - "jet_template_id": cls.jet_template_test.id, - "state_from_id": cls.state_c.id, - "state_to_id": cls.state_d.id, - "state_transit_id": cls.state_stopping.id, - "priority": 10, - } - ) - - cls.action_a_to_c = cls.JetAction.create( - { - "name": "Action A to C (direct)", - "reference": "action_a_to_c", - "jet_template_id": cls.jet_template_test.id, - "state_from_id": cls.state_a.id, - "state_to_id": cls.state_c.id, - "state_transit_id": cls.state_stopping.id, - "priority": 10, - } - ) - - # Create border actions (create and destroy) - cls.action_create = cls.JetAction.create( - { - "name": "Create Action", - "reference": "create_action", - "jet_template_id": cls.jet_template_test.id, - "state_from_id": False, # No initial state - "state_to_id": cls.state_running.id, - "state_transit_id": cls.state_starting.id, - "priority": 1, - } - ) - - cls.action_destroy = cls.JetAction.create( - { - "name": "Destroy Action", - "reference": "destroy_action", - "jet_template_id": cls.jet_template_test.id, - "state_from_id": cls.state_running.id, - "state_to_id": False, # No final state - "state_transit_id": cls.state_stopping.id, - "priority": 1, - } - ) - - # Create a clean template for tests that need isolation from common actions - cls.clean_template = cls.JetTemplate.create( - { - "name": "Clean Template", - "reference": "clean_template", - } - ) - - # Create waypoint template for testing - cls.waypoint_template = cls.env["cx.tower.jet.waypoint.template"].create( - { - "name": "Test Waypoint Template", - "jet_template_id": cls.jet_template_test.id, - } - ) - cls.waypoint_template_2 = cls.env["cx.tower.jet.waypoint.template"].create( - { - "name": "Test Waypoint Template 2", - "jet_template_id": cls.jet_template_test.id, - } - ) - - # Create waypoint for testing - cls.waypoint = cls.env["cx.tower.jet.waypoint"].create( - { - "name": "Test Waypoint", - "jet_id": cls.jet_test.id, - "waypoint_template_id": cls.waypoint_template.id, - } - ) - - # Model references reused by helpers - cls.JetDependency = cls.env["cx.tower.jet.dependency"] - cls.JetWaypointTemplate = cls.env["cx.tower.jet.waypoint.template"] - cls.JetWaypoint = cls.env["cx.tower.jet.waypoint"] - - @classmethod - def _create_jet( - cls, - name, - reference, - template=None, - server=None, - user_ids=None, - manager_ids=None, - server_user_ids=None, - server_manager_ids=None, - with_user=None, - ): - """ - Helper method to create a jet - with specified access configuration - - Args: - name (str): Name of the jet - reference (str): Reference of the jet - template (cx.tower.jet.template): Template for the jet - (if None, defaults to jet_template_test) - server (cx.tower.server): Server for the jet - (if None, defaults to server_test_1) - user_ids (list): List of user IDs for the jet - manager_ids (list): List of manager IDs for the jet - server_user_ids (list): List of user IDs for the server - server_manager_ids (list): List of manager IDs for the server - with_user (res.users): Optional user - to create the jet as (for access rule testing) - - Returns: - cx.tower.jet: Created jet record - """ - if template is None: - template = cls.jet_template_test - if server is None: - server = cls.server_test_1 - - # Configure server access - if server_user_ids is not None or server_manager_ids is not None: - server.write( - { - "user_ids": server_user_ids - if server_user_ids is not None - else [(5, 0, 0)], - "manager_ids": server_manager_ids - if server_manager_ids is not None - else [(5, 0, 0)], - } - ) - - # Create jet with access configuration - jet_vals = { - "name": name, - "reference": reference, - "jet_template_id": template.id, - "server_id": server.id, - "user_ids": user_ids if user_ids is not None else [(5, 0, 0)], - "manager_ids": manager_ids if manager_ids is not None else [(5, 0, 0)], - } - jet_model = cls.Jet.with_user(with_user) if with_user else cls.Jet - jet = jet_model.create(jet_vals) - return jet - - @classmethod - def _create_jet_dependency( - cls, - jet_name, - jet_reference, - depends_on_name, - depends_on_reference, - jet_user_ids=None, - jet_manager_ids=None, - depends_on_user_ids=None, - depends_on_manager_ids=None, - jet_server_user_ids=None, - jet_server_manager_ids=None, - depends_on_server_user_ids=None, - depends_on_server_manager_ids=None, - with_user=None, - jet_template=None, - depends_on_template=None, - ): - """Helper method to create a dependency between two jets - - Args: - jet_name (str): Name of the main jet - jet_reference (str): Reference of the main jet - depends_on_name (str): Name of the jet this depends on - depends_on_reference (str): Reference of the jet this depends on - jet_user_ids (list): User IDs for the main jet - jet_manager_ids (list): Manager IDs for the main jet - depends_on_user_ids (list): User IDs for the depends_on jet - depends_on_manager_ids (list): Manager IDs for the depends_on jet - jet_server_user_ids (list): User IDs for the main jet's server - jet_server_manager_ids (list): Manager IDs for the main jet's server - depends_on_server_user_ids (list): User IDs for the depends_on jet's server - depends_on_server_manager_ids (list): Manager IDs for the depends_on - jet's server (if None, defaults to server_test_1) - with_user (res.users): Optional user to create the dependency as - (for access rule testing) - jet_template: Optional template for the main jet - (if None, defaults to jet_template_test) - depends_on_template: Optional template for the depends_on jet - (if None, defaults to jet_template_tower_core) - - Returns: - tuple: (jet, depends_on_jet, dependency) - """ - - # Use different templates to avoid self-dependency error - # Default to jet_template_test for the main jet and - # jet_template_tower_core for depends_on - jet_template = jet_template or cls.jet_template_test - depends_on_template = depends_on_template or cls.jet_template_tower_core - - # Check if template dependency already exists, if so reuse it - template_dep = cls.JetTemplateDependency.search( - [ - ("template_id", "=", jet_template.id), - ("template_required_id", "=", depends_on_template.id), - ], - limit=1, - ) - if not template_dep: - # Create template dependency first - # to ensure templates are different - ( - _template, - _required_template, - template_dep, - ) = cls._create_jet_template_dependency( - template=jet_template, - template_required=depends_on_template, - ) - - # Create first jet - # (always create as root to ensure proper setup) - jet = cls._create_jet( - jet_name, - jet_reference, - template=jet_template, - user_ids=jet_user_ids, - manager_ids=jet_manager_ids, - server_user_ids=jet_server_user_ids, - server_manager_ids=jet_server_manager_ids, - with_user=None, # Create as root to ensure proper setup - ) - - # Create second jet (depended on) - # (also create as root to ensure proper setup) - depends_on_jet = cls._create_jet( - depends_on_name, - depends_on_reference, - template=depends_on_template, - user_ids=depends_on_user_ids, - manager_ids=depends_on_manager_ids, - server_user_ids=depends_on_server_user_ids, - server_manager_ids=depends_on_server_manager_ids, - with_user=None, # Create as root to ensure proper setup, - ) - - # If creating dependency with a user context, verify access first - if with_user: - # Verify manager can access both jets by searching in their context - # This ensures the access rule domain can evaluate correctly - # when creating the dependency - jet_search = cls.Jet.with_user(with_user).search([("id", "=", jet.id)]) - depends_search = cls.Jet.with_user(with_user).search( - [("id", "=", depends_on_jet.id)] - ) - - if not jet_search or not depends_search: - raise AccessError( - _("Manager must have access to both jets before creating") - ) - # Force cache refresh to ensure Many2one relations are accessible, - jet.invalidate_recordset(["manager_ids", "user_ids"]) - depends_on_jet.invalidate_recordset(["user_ids", "manager_ids"]) - - # Create dependency - dependency_vals = { - "jet_id": jet.id, - "jet_depends_on_id": depends_on_jet.id, - "jet_template_dependency_id": template_dep.id, - } - dependency_model = ( - cls.JetDependency.with_user(with_user) if with_user else cls.JetDependency - ) - dependency = dependency_model.create(dependency_vals) - - return jet, depends_on_jet, dependency - - @classmethod - def _create_jet_template_dependency( - cls, - template_name=None, - template_reference=None, - access_level="2", - user_ids=None, - manager_ids=None, - template=None, - template_required=None, - state_required_id=None, - with_user=None, - ): - """Helper method to create a dependency between two templates - - Args: - template_name (str, optional): Name of the template (if creating new) - template_reference (str, optional): Reference of the template - (if creating new) - access_level (str): Access level for the template - (if creating new, defaults to "2") - user_ids (list): List of user IDs for the template - manager_ids (list): List of manager IDs for the template - template: Existing template record or None to create new - (if None, defaults to jet_template_test) - template_required: Existing required template record or None to create new - (if None, defaults to jet_template_tower_core) - state_required_id: Optional state required ID for the dependency - - Returns: - tuple: (template, required_template, dependency) - """ - # Create or use existing template - if template is None: - template_vals = { - "name": template_name, - "reference": template_reference, - "access_level": access_level, - "user_ids": user_ids if user_ids is not None else [(5, 0, 0)], - "manager_ids": manager_ids if manager_ids is not None else [(5, 0, 0)], - } - template = cls.JetTemplate.create(template_vals) - - # Create or use existing required template - if template_required is None: - required_template = cls.JetTemplate.create( - { - "name": "Required Template", - "reference": "required_template", - "access_level": "2", - } - ) - else: - required_template = template_required - - # Create dependency - dependency_vals = { - "template_id": template.id if hasattr(template, "id") else template, - "template_required_id": required_template.id - if hasattr(required_template, "id") - else required_template, - "state_required_id": state_required_id - if state_required_id is not None - else cls.state_running.id, - } - dependency_model = ( - cls.JetTemplateDependency.with_user(with_user) - if with_user - else cls.JetTemplateDependency - ) - dependency = dependency_model.create(dependency_vals) - - return template, required_template, dependency diff --git a/addons/cetmix_tower_server/tests/test_cetmix_tower.py b/addons/cetmix_tower_server/tests/test_cetmix_tower.py deleted file mode 100644 index 031396b..0000000 --- a/addons/cetmix_tower_server/tests/test_cetmix_tower.py +++ /dev/null @@ -1,244 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from unittest.mock import patch - -from odoo.tools import mute_logger - -from ..models.constants import GENERAL_ERROR, NOT_FOUND, SSH_CONNECTION_ERROR -from .common import TestTowerCommon - - -class TestCetmixTower(TestTowerCommon): - """ - Tests for the 'cetmix.tower' helper model - """ - - @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") - def test_server_set_variable_value(self): - """Test plan line action naming""" - - # -- 1-- - # Create new variable - variable_meme = self.Variable.create( - {"name": "Meme Variable", "reference": "meme_variable"} - ) - - # Set variable for Server 1 - result = self.CetmixTower.server_set_variable_value( - server_reference=self.server_test_1.reference, - variable_reference=variable_meme.reference, - value="Doge", - ) - - # Check exit code - self.assertEqual(result["exit_code"], 0, "Exit code must be equal to 0") - - # Check variable value - variable_value = self.VariableValue.search( - [("variable_id", "=", variable_meme.id)] - ) - - self.assertEqual(len(variable_value), 1, "Must be 1 result") - self.assertEqual(variable_value.value_char, "Doge", "Must be Doge!") - - # -- 2 -- - # Update existing variable value - - # Set variable for Server 1 - result = self.CetmixTower.server_set_variable_value( - server_reference=self.server_test_1.reference, - variable_reference=variable_meme.reference, - value="Pepe", - ) - - # Check exit code - self.assertEqual(result["exit_code"], 0, "Exit code must be equal to 0") - - # Check variable value - variable_value = self.VariableValue.search( - [("variable_id", "=", variable_meme.id)] - ) - - self.assertEqual(len(variable_value), 1, "Must be 1 result") - self.assertEqual(variable_value.value_char, "Pepe", "Must be Pepe!") - - @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") - def test_server_get_variable_value(self): - """Test getting value for server""" - variable_meme = self.Variable.create( - {"name": "Meme Variable", "reference": "meme_variable"} - ) - global_value = self.VariableValue.create( - {"variable_id": variable_meme.id, "value_char": "Memes Globalvs"} - ) - - # -- 1 -- Get value for Server with no server value defined - value = self.CetmixTower.server_get_variable_value( - self.server_test_1.reference, variable_meme.reference - ) - self.assertEqual(value, global_value.value_char) - - # -- 2 -- Add server value and try again - server_value = self.VariableValue.create( - { - "variable_id": variable_meme.id, - "value_char": "Memes Servervs", - "server_id": self.server_test_1.id, - } - ) - value = self.CetmixTower.server_get_variable_value( - self.server_test_1.reference, variable_meme.reference - ) - self.assertEqual(value, server_value.value_char) - - @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") - def test_server_check_ssh_connection(self): - """ - Test SSH connection check with a mocked function that - either returns a dictionary or raises an exception. - """ - - # Test successful connection - result = self.env["cetmix.tower"].server_check_ssh_connection( - self.server_test_1.reference, - ) - self.assertEqual(result["exit_code"], 0, "SSH connection should be successful.") - - def test_ssh_connection(this, *args, **kwargs): - return {"status": GENERAL_ERROR} - - with patch.object( - self.registry["cx.tower.server"], "test_ssh_connection", test_ssh_connection - ): - # Test connection timeout after max attempts - result = self.env["cetmix.tower"].server_check_ssh_connection( - self.server_test_1.reference, - attempts=2, - wait_time=1, - ) - self.assertEqual( - result["exit_code"], - SSH_CONNECTION_ERROR, - "SSH connection should timeout after maximum attempts.", - ) - - @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") - def test_server_run_command(self): - """Test running command on server""" - # Create test command - command = self.Command.create( - { - "name": "Test Command", - "reference": "test_command", - "code": "echo 'Hello World'", - "action": "ssh_command", - } - ) - - # -- 1 -- Test with non-existent server - result = self.CetmixTower.server_run_command( - server_reference="non_existent", - command_reference=command.reference, - ) - self.assertEqual(result["exit_code"], NOT_FOUND) - self.assertEqual(result["message"], "Server not found") - - # -- 2 -- Test with non-existent command - result = self.CetmixTower.server_run_command( - server_reference=self.server_test_1.reference, - command_reference="non_existent", - ) - self.assertEqual(result["exit_code"], NOT_FOUND) - self.assertEqual(result["message"], "Command not found") - - # -- 3 -- Test successful command execution - result = self.CetmixTower.server_run_command( - server_reference=self.server_test_1.reference, - command_reference=command.reference, - ) - self.assertEqual(result["exit_code"], 0) - - @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") - def test_server_run_flight_plan(self): - """Test running flight plan on server""" - # Create test flight plan - flight_plan = self.Plan.create( - { - "name": "Test Flight Plan", - "reference": "test_flight_plan", - } - ) - - # -- 1 -- Test with non-existent server - result = self.CetmixTower.server_run_flight_plan( - server_reference="non_existent", - flight_plan_reference=flight_plan.reference, - ) - self.assertFalse(result, "Should return False for non-existent server") - - # -- 2 -- Test with non-existent flight plan - result = self.CetmixTower.server_run_flight_plan( - server_reference=self.server_test_1.reference, - flight_plan_reference="non_existent", - ) - self.assertFalse(result, "Should return False for non-existent flight plan") - - # -- 3 -- Test successful flight plan execution - with patch.object(self.server_test_1.__class__, "run_flight_plan") as mock_run: - # Setup mock to return a plan log record - plan_log = self.PlanLog.create( - { - "name": "Test Log", - "server_id": self.server_test_1.id, - "plan_id": flight_plan.id, - } - ) - mock_run.return_value = plan_log - - # Run flight plan - result = self.CetmixTower.server_run_flight_plan( - server_reference=self.server_test_1.reference, - flight_plan_reference=flight_plan.reference, - ) - - # Verify result - self.assertEqual(result, plan_log, "Should return plan log record") - mock_run.assert_called_once_with(flight_plan) - - @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") - def test_server_run_command_with_variable_values(self): - """Test running command with variable values""" - # Create test command - command = self.Command.create( - { - "name": "Test Command", - "reference": "test_command", - "code": "result = {'exit_code': 0, 'message': {{ test_version }}}", - "action": "python_code", - } - ) - # Set variable value for the server - self.CetmixTower.server_set_variable_value( - server_reference=self.server_test_1.reference, - variable_reference=self.variable_version.reference, - value="prod", - ) - - # -- 1 -- - # Run command without modifying variable values - result = self.CetmixTower.server_run_command( - server_reference=self.server_test_1.reference, - command_reference=command.reference, - ) - self.assertEqual(result["exit_code"], 0) - self.assertEqual(result["message"], "prod") - - # -- 2 -- - # Run command with modified variable values - result = self.CetmixTower.server_run_command( - server_reference=self.server_test_1.reference, - command_reference=command.reference, - **{"test_version": "dev"}, - ) - self.assertEqual(result["exit_code"], 0) - self.assertEqual(result["message"], "dev") diff --git a/addons/cetmix_tower_server/tests/test_command.py b/addons/cetmix_tower_server/tests/test_command.py deleted file mode 100644 index 4ba2784..0000000 --- a/addons/cetmix_tower_server/tests/test_command.py +++ /dev/null @@ -1,1964 +0,0 @@ -# Copyright (C) 2022 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from datetime import timedelta -from unittest.mock import patch - -from odoo.exceptions import AccessError, ValidationError -from odoo.fields import Datetime -from odoo.tests.common import Form -from odoo.tools import mute_logger - -from ..models.constants import ( - ANOTHER_COMMAND_RUNNING, - COMMAND_TIMED_OUT, - COMMAND_TIMED_OUT_MESSAGE, - GENERAL_ERROR, -) -from .common import TestTowerCommon - - -class TestTowerCommand(TestTowerCommon): - """ - Test the command model. - - Important! - As this model inherits from the `cx.tower.template.mixin` - we will tests template rendering methods in this class too. - - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Save variable values for Server 1 - with Form(cls.server_test_1) as f: - with f.variable_value_ids.new() as line: - line.variable_id = cls.variable_dir - line.value_char = "test-odoo-1" - with f.variable_value_ids.new() as line: - line.variable_id = cls.variable_path - line.value_char = "/opt/tower" - f.save() - - # Secret key - cls.secret_folder_key = cls.Key.create( - { - "name": "Folder", - "reference": "FOLDER", - "secret_value": "secretFolder", - "key_type": "s", - } - ) - cls.secret_python_key = cls.Key.create( - { - "name": "python", - "reference": "PYTHON", - "secret_value": "secretPythonCode", - "key_type": "s", - } - ) - - # secret value as multi line string - cls.python_ssh_key = cls.Key.create( - { - "name": "Test Python SSH Key", - "reference": "test_python_ssh_key", - "key_type": "s", - "secret_value": """ - Python - much - key - """, - } - ) - - cls.secret_test_rsa_key = cls.Key.create( - { - "name": "test rsa", - "reference": "test_rsa", - "secret_value": """-----BEGIN RSA PRIVATE KEY----- -VeryMuchNiceKey ------END RSA PRIVATE KEY----- """, - "key_type": "s", - } - ) - # Command - cls.command_create_new_command = cls.Command.create( - { - "name": "Create new command", - "action": "python_code", - "code": """ -server_name = {{ tower.server.name }} -if server_name and #!cxtower.secret.FOLDER!# == "secretFolder": - # We don't actually create a new command because it will raise - # access error if user doesn't have access to 'create' operation. - # Instead we just return a dummy command result. - command = "new command" - result = {"exit_code": 0, "message": "New command was created"} -else: - result = {"exit_code": %s, "message": "error"} - """ - % GENERAL_ERROR, - } - ) - - cls.command_python_command_1 = cls.Command.create( - { - "name": "Python command with secret #1", - "action": "python_code", - "code": """ -result = { - "exit_code": 0, - "message": #!cxtower.secret.PYTHON!#, -} - """, - } - ) - - cls.command_python_command_2 = cls.Command.create( - { - "name": "Python command with secret #2", - "action": "python_code", - "code": """ -result = { - "exit_code": 0, - "message": 'We use #!cxtower.secret.PYTHON!#' , -} - """, - } - ) - - cls.command_python_command_3 = cls.Command.create( - { - "name": "Python command with secret #3", - "action": "python_code", - "code": """ -result = { - "exit_code": 0, - "message": ""#!cxtower.secret.test_rsa!#"" , -} - """, - } - ) - - cls.command_python_command_4 = cls.Command.create( - { - "name": "Python command with secret #4", - "action": "python_code", - "code": """ -top_secret = #!cxtower.secret.test_python_ssh_key!# -result = { - "exit_code": 0, - "message": top_secret , -} - """, - } - ) - cls.server = cls.Server.create( - { - "name": "Test Server", - "user_ids": [(6, 0, [cls.user.id])], - "manager_ids": [(6, 0, [cls.manager.id])], - "ssh_username": "test", - "ssh_password": "test", - "ip_v4_address": "127.0.0.1", - } - ) - - def _create_command(self, **kwargs): - """Helper to create a command record with default values.""" - vals = { - "name": "Test Command", - "access_level": "1", # override default - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - "server_ids": [(6, 0, [])], - } - if kwargs: - vals.update(kwargs) - return self.Command.create(vals) - - def test_user_read_access(self): - """ - For a user: - Read access is allowed if access_level == "1" and either the command's - own user_ids includes the user OR a related server (via server_ids) - includes the user in its user_ids. - """ - # Case 1: Command with access_level "1" and user in command.user_ids. - cmd1 = self._create_command( - **{ - "access_level": "1", - "user_ids": [(6, 0, [self.user.id])], - } - ) - recs1 = self.Command.with_user(self.user).search([("id", "=", cmd1.id)]) - self.assertIn( - cmd1, - recs1, - "User should see the command if in command.user_ids" - " and access_level == '1'.", - ) - - # Case 2: Command with access_level "1" and user not in command.user_ids - # but in a related server. - cmd2 = self._create_command( - **{ - "access_level": "1", - "user_ids": [(6, 0, [])], - "server_ids": [(6, 0, [self.server.id])], - } - ) - recs2 = self.Command.with_user(self.user).search([("id", "=", cmd2.id)]) - self.assertIn( - cmd2, - recs2, - "User should see the command if related server.user_ids includes the user.", - ) - - # Negative: If access_level is "1" but neither command.user_ids - # nor server_ids.user_ids includes the user. - cmd3 = self._create_command( - **{ - "access_level": "1", - "user_ids": [(6, 0, [])], - "server_ids": [(6, 0, [])], - } - ) - recs3 = self.Command.with_user(self.user).search([("id", "=", cmd3.id)]) - self.assertNotIn( - cmd3, - recs3, - "User should not see the command if not granted access.", - ) - - def test_manager_read_access(self): - """ - For a manager: - Allowed to read a command if access_level <= "2" AND - (either the command itself grants access via user_ids or manager_ids - OR there are no related servers OR a related server grants access via - its user_ids or manager_ids). - """ - # Case 1: Command with access_level "2" and command.manager_ids - # includes the manager but the server is not related to the command. - another_server = self.Server.create( - { - "name": "Another Server", - "ip_v4_address": "127.0.0.2", - "ssh_username": "test", - "ssh_password": "test", - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - } - ) - cmd1 = self._create_command( - **{ - "access_level": "2", - "manager_ids": [(6, 0, [self.manager.id])], - "server_ids": [(6, 0, [another_server.id])], - } - ) - recs1 = self.Command.with_user(self.manager).search([("id", "=", cmd1.id)]) - self.assertIn( - cmd1, - recs1, - "Manager should see the command if in command.manager_ids" - " and access_level <= '2'.", - ) - - # Case 2: Command with access_level "2" that does not grant access - # on the command itself, but a related server grants access via - # but a related server grants access via its manager_ids. - cmd2 = self._create_command( - **{ - "access_level": "2", - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - "server_ids": [(6, 0, [self.server.id])], - } - ) - recs2 = self.Command.with_user(self.manager).search([("id", "=", cmd2.id)]) - self.assertIn( - cmd2, - recs2, - "Manager should see the command if related server.manager_ids" - " includes the manager.", - ) - - # Positive: Command with access_level "2" without any granted access. - cmd3 = self._create_command( - **{ - "access_level": "2", - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - "server_ids": [(6, 0, [])], - } - ) - recs3 = self.Command.with_user(self.manager).search([("id", "=", cmd3.id)]) - self.assertIn( - cmd3, - recs3, - "Manager should see the command if not granted access " - "but not related to any server.", - ) - - # Case 3: Remove from manager in the cmd1. - # Should not see the command because it belongs to another server. - cmd1.manager_ids = [(3, self.manager.id)] - recs4 = self.Command.with_user(self.manager).search([("id", "=", cmd1.id)]) - self.assertNotIn( - cmd1, - recs4, - "Manager should not see the command if " - "removed from command.manager_ids." - " and command belongs to another server.", - ) - - def test_manager_write_create_access(self): - """ - For a manager: - Allowed to write and create a command if access_level <= "2" AND - the command's own manager_ids includes the manager. - """ - # Case: Command with access_level "2" and manager_ids includes the manager. - cmd1 = self._create_command( - **{ - "access_level": "2", - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - try: - cmd1.with_user(self.manager).write({"name": "Manager Updated Command"}) - except AccessError: - self.fail( - "Manager should be able to update the command " - "if in command.manager_ids." - ) - self.assertEqual(cmd1.with_user(self.manager).name, "Manager Updated Command") - - # Attempt to create a command as manager without including their ID - # in manager_ids should fail. - cmd_invalid_vals = { - "name": "Invalid Manager Create", - "access_level": "2", - "manager_ids": [(6, 0, [])], - "action": "python_code", - "code": "print('dummy')", - } - with self.assertRaises(AccessError): - self.Command.with_user(self.manager).create(cmd_invalid_vals) - - def test_manager_unlink_access(self): - """ - For a manager: - Allowed to delete a command if access_level <= "2", - the current user is the record creator, - AND the command's own manager_ids includes the manager. - """ - # Scenario 1: Command created by the manager with manager_ids - # including the manager. - cmd1 = self.Command.with_user(self.manager).create( - { - "name": "Manager Created Command", - "access_level": "2", - } - ) - try: - cmd1.unlink() - except AccessError: - self.fail( - "Manager should be able to delete a command " - "they created if in command.manager_ids." - ) - - # Scenario 2: Command created by someone else - # even if manager_ids includes the manager. - cmd2 = self._create_command( - **{ - "access_level": "2", - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - with self.assertRaises(AccessError): - cmd2.with_user(self.manager).unlink() - - def test_root_unrestricted_access(self): - """ - For a root user: - Unlimited access: root can read, write, create, and delete commands - regardless of access_level or related servers. - """ - cmd = self._create_command( - **{ - "access_level": "3", # above the threshold for managers - } - ) - recs = self.Command.with_user(self.root).search([("id", "=", cmd.id)]) - self.assertIn( - cmd, - recs, - "Root should see the command regardless of restrictions.", - ) - try: - cmd.with_user(self.root).write({"name": "Root Updated Command"}) - except AccessError: - self.fail( - "Root should be able to update the command " "without restrictions." - ) - self.assertEqual(cmd.with_user(self.root).name, "Root Updated Command") - cmd2 = self.Command.with_user(self.root).create( - { - "name": "Root Created Command", - "access_level": "3", - "action": "python_code", - "code": "print('root')", - } - ) - self.assertTrue( - cmd2, - "Root should be able to create a command " "without restrictions.", - ) - cmd2.with_user(self.root).unlink() - recs_after = self.Command.with_user(self.root).search([("id", "=", cmd2.id)]) - self.assertFalse( - recs_after, - "Root should be able to delete the command without restrictions.", - ) - - def test_ssh_command_prepare_method_without_path(self): - """Test ssh command preparation in different modes without path""" - - server = self.server_test_1 - - single_command = "ls -a /tmp" - multiple_commands = "ls -a /tmp && mkdir /tmp/test" - - sudo_mode = "p" - - # Prepare single command for sudo with password - cmd = server._prepare_ssh_command(single_command, path=None, sudo=sudo_mode) - self.assertEqual( - cmd, - [f"{self.sudo_prefix} {single_command}"], - msg=( - "Single command for sudo with password should be " - "equal to list with the original command" - "as an only element" - ), - ) - - # Prepare multiple commands for sudo with password - cmd = server._prepare_ssh_command(multiple_commands, path=None, sudo=sudo_mode) - self.assertEqual( - cmd, - [ - f"{self.sudo_prefix} ls -a /tmp", - f"{self.sudo_prefix} mkdir /tmp/test", - ], - msg=( - "Multiple commands with sudo with password should be " - "a list of separated commands from original line" - ), - ) - - sudo_mode = "n" - - # Prepare single command for sudo without password - cmd = server._prepare_ssh_command(single_command, path=None, sudo=sudo_mode) - self.assertEqual( - cmd, - f"{self.sudo_prefix} {single_command}", - msg=( - "Single command with sudo without password should be " - f'equal to the original command prefixed with "{self.sudo_prefix}"' - ), - ) - - # Prepare multiple commands for sudo without password - cmd = server._prepare_ssh_command(multiple_commands, path=None, sudo=sudo_mode) - self.assertEqual( - cmd, - f"{self.sudo_prefix} ls -a /tmp && {self.sudo_prefix} mkdir /tmp/test", - msg=( - "Multiple commands with sudo with password should be " - "a re-joined string from list of separated original " - f'each prefixed with "{self.sudo_prefix}"' - ), - ) - - # Prepare single command without sudo - cmd = server._prepare_ssh_command(single_command) - self.assertEqual( - cmd, - single_command, - msg=( - "Single command without sudo should be " - "equal to the original command " - ), - ) - - # Prepare multiple without sudo - cmd = server._prepare_ssh_command(multiple_commands) - self.assertEqual( - cmd, - multiple_commands, - msg=( - "Multiple commands without sudo should be " - "equal to the original line of commands" - ), - ) - - def test_ssh_command_prepare_method_with_path(self): - """Test command preparation in different modes without path""" - - server = self.server_test_1 - - single_command = "ls -a /tmp" - multiple_commands = "ls -a /tmp && mkdir /tmp/test" - path = "/home/doge" - - sudo_mode = "p" - - # Prepare single command for sudo with password - cmd = server._prepare_ssh_command(single_command, path=path, sudo=sudo_mode) - self.assertEqual( - cmd, - [f"cd {path}", f"{self.sudo_prefix} {single_command}"], - msg=( - "Single command for sudo with password should be " - "equal to list of two elements:" - " change directory and original command" - ), - ) - - # Prepare multiple commands for sudo with password - cmd = server._prepare_ssh_command(multiple_commands, path=path, sudo=sudo_mode) - self.assertEqual( - cmd, - [ - f"cd {path}", - f"{self.sudo_prefix} ls -a /tmp", - f"{self.sudo_prefix} mkdir /tmp/test", - ], - msg=( - "Multiple commands with sudo with password should be " - "a list of separated commands from original line" - ), - ) - - sudo_mode = "n" - - # Prepare single command for sudo without password - cmd = server._prepare_ssh_command(single_command, path=path, sudo=sudo_mode) - self.assertEqual( - cmd, - f"cd {path} && {self.sudo_prefix} {single_command}", - msg=( - "Single command with sudo without password should be " - f'equal to the original command prefixed with "{self.sudo_prefix}"' - ), - ) - - # Prepare multiple commands for sudo without password - cmd = server._prepare_ssh_command(multiple_commands, path=path, sudo=sudo_mode) - self.assertEqual( - cmd, - f"cd {path} && {self.sudo_prefix} ls -a /tmp && {self.sudo_prefix} mkdir /tmp/test", # noqa - msg=( - "Multiple commands with sudo with password should be " - "a re-joined string from list of separated original " - f'each prefixed with "{self.sudo_prefix}"' - ), - ) - - # Prepare single command without sudo - cmd = server._prepare_ssh_command(single_command, path=path) - self.assertEqual( - cmd, - f"cd {path} && {single_command}", - msg=( - "Single command for without sudo should be " - "equal to the the original command" - "with 'cd {{ path }} && ' prefix" - ), - ) - - # Prepare multiple commands without sudo - cmd = server._prepare_ssh_command(multiple_commands, path=path) - self.assertEqual( - cmd, - f"cd {path} && {multiple_commands}", # noqa - msg=( - "Multiple commands without sudo should be " - "original command with 'change directory' command prepended" - ), - ) - - def test_ssh_command_no_split_for_sudo_without_path(self): - """If no_split_for_sudo=True, even '&&' shouldn’t split into a list.""" - server = self.server_test_1 - cmd_line = "echo a && echo b" - sudo_mode = "p" - result = server._prepare_ssh_command( - cmd_line, sudo=sudo_mode, no_split_for_sudo=True - ) - expected = [f"{self.sudo_prefix} {cmd_line}"] - self.assertEqual( - result, expected, "With no_split_for_sudo, '&&' must not produce a list" - ) - - def test_ssh_command_no_split_for_sudo_with_path(self): - """Same, but with a custom cwd prefix.""" - server = self.server_test_1 - cmd_line = "echo a && echo b" - path = "/tmp" - sudo_mode = "p" - result = server._prepare_ssh_command( - cmd_line, path=path, sudo=sudo_mode, no_split_for_sudo=True - ) - expected = [f"cd {path}", f"{self.sudo_prefix} {cmd_line}"] - self.assertEqual( - result, - expected, - "With no_split_for_sudo and path, the entire '&&' string remains un-split", - ) - - def test_server_render_command(self): - """Test rendering command using `_render_command` method - of cx.tower.server - """ - - # -- 1 -- - # Test with default path - rendered_command = self.server_test_1._render_command(self.command_create_dir) - rendered_code_expected = "cd /opt/tower && mkdir test-odoo-1" - rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" - - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_expected, - "Rendered code doesn't match", - ) - self.assertEqual( - rendered_command["rendered_path"], - rendered_path_expected, - "Rendered path doesn't match", - ) - - # -- 2 -- - # Test with custom path - rendered_command = self.server_test_1._render_command( - self.command_create_dir, path="/such/much/path" - ) - rendered_code_expected = "cd /opt/tower && mkdir test-odoo-1" - rendered_path_expected = "/such/much/path" - - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_expected, - "Rendered code doesn't match", - ) - self.assertEqual( - rendered_command["rendered_path"], - rendered_path_expected, - "Rendered path doesn't match", - ) - - # -- 3 -- - # Set variable_path to None and check again - variable_value_path = self.server_test_1.variable_value_ids.filtered( - lambda var_val: var_val.variable_id.id == self.variable_path.id - ) - variable_value_path.value_char = None - rendered_command = self.server_test_1._render_command(self.command_create_dir) - rendered_code_expected = "cd False && mkdir test-odoo-1" - rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" - - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_expected, - "Rendered code doesn't match", - ) - self.assertEqual( - rendered_command["rendered_path"], - rendered_path_expected, - "Rendered path doesn't match", - ) - - # -- 4 -- - # Set both path and code to None - self.write_and_invalidate( - self.command_create_dir, **{"code": None, "path": None} - ) - rendered_command = self.server_test_1._render_command(self.command_create_dir) - - self.assertFalse( - rendered_command["rendered_code"], "Rendered code doesn't match" - ) - self.assertFalse( - rendered_command["rendered_path"], "Rendered path doesn't match" - ) - - def test_server_render_command_with_custom_variable_values(self): - """Test rendering command using `_render_command` method - of cx.tower.server with custom variable values - """ - self.write_and_invalidate( - self.server_test_1, - **{"user_ids": [(4, self.user.id)], "manager_ids": [(4, self.manager.id)]}, - ) - # -- 1 -- - # Set custom variable values - custom_variable_values = { - "test_path_": "/pepe/memes", - "other_path": "/etc/chad", - } - - # Modify command path - self.write_and_invalidate( - self.command_create_dir, - **{"path": "{{ other_path }}/{{ tower.server.username }}"}, - ) - - # Render command - rendered_command = self.server_test_1.with_user(self.manager)._render_command( - self.command_create_dir, custom_variable_values=custom_variable_values - ) - rendered_code_expected = "cd /pepe/memes && mkdir test-odoo-1" - rendered_path_expected = f"/etc/chad/{self.server_test_1.ssh_username}" - - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_expected, - "Rendered code doesn't match", - ) - self.assertEqual( - rendered_command["rendered_path"], - rendered_path_expected, - "Rendered path doesn't match", - ) - - # -- 2 -- - # Test with user who doesn't have access to the server - rendered_command = self.server_test_1.with_user(self.user)._render_command( - self.command_create_dir, custom_variable_values=custom_variable_values - ) - rendered_code_expected = "cd /opt/tower && mkdir test-odoo-1" - rendered_path_expected = f"None/{self.server_test_1.ssh_username}" - - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_expected, - "Rendered code doesn't match", - ) - self.assertEqual( - rendered_command["rendered_path"], - rendered_path_expected, - "Rendered path doesn't match", - ) - - def test_server_render_command_variable_with_value_modifier(self): - """Test rendering command using `_render_command` method - of cx.tower.server. - Use variable with value modifier for testing. - """ - - # -- 1 -- - # Set modifiers for variables - modifier_for_path = """ -if 'opt' in value: - result = value.replace('opt', 'home') -else: - result = value -""" - self.variable_path.applied_expression = modifier_for_path - - modifier_for_dir = """ -pattern = r'(?i)odoo' -replacement = 'sap' -result = re.sub(pattern, replacement, value) -""" - self.variable_dir.applied_expression = modifier_for_dir - - # -- 1 -- - # Test with default path - rendered_command = self.server_test_1._render_command(self.command_create_dir) - rendered_code_expected = "cd /home/tower && mkdir test-sap-1" - rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" - - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_expected, - "Rendered code doesn't match", - ) - self.assertEqual( - rendered_command["rendered_path"], - rendered_path_expected, - "Rendered path doesn't match", - ) - - # -- 2 -- - # Set invalid expression modifier - self.variable_path.applied_expression = "invalid" - with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_variable"): - rendered_command = self.server_test_1._render_command( - self.command_create_dir - ) - rendered_code_expected = "cd /opt/tower && mkdir test-sap-1" - rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" - - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_expected, - "Rendered code doesn't match", - ) - self.assertEqual( - rendered_command["rendered_path"], - rendered_path_expected, - "Rendered path doesn't match", - ) - - # -- 3 -- - # Test with variable in variable value - complex_variable = self.Variable.create( - { - "name": "Complex Variable", - "applied_expression": "result = value.replace('opt', 'meme')", - } - ) - # Create a complex variable value - self.VariableValue.create( - { - "variable_id": complex_variable.id, - "value_char": "{{ test_path_ }}/{{ test_dir }}", - } - ) - command_with_complex_variable = self.Command.create( - { - "name": "Command with complex variable", - "code": "cd {{ complex_variable }}", - "action": "ssh_command", - } - ) - with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_variable"): - rendered_command = self.server_test_1._render_command( - command_with_complex_variable - ) - rendered_code_expected = "cd /meme/tower/test-sap-1" - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_expected, - "Rendered code doesn't match", - ) - - # -- 4 -- - # Remove modifier from variable "Path" and check again - self.variable_dir.applied_expression = None - with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_variable"): - rendered_command = self.server_test_1._render_command( - command_with_complex_variable - ) - rendered_code_expected = "cd /meme/tower/test-odoo-1" - - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_expected, - "Rendered code doesn't match", - ) - - def test_render_code_generic(self): - """Test generic (aka ssh) code template direct rendering""" - - # Only 'test_path_' must be rendered - args = {"test_path_": "/tmp", "test_os": "debian"} - res = self.command_create_dir.render_code(**args) - rendered_code = res.get(self.command_create_dir.id) - rendered_code_expected = "cd /tmp && mkdir " - self.assertEqual( - rendered_code, - rendered_code_expected, - msg=f"Must be rendered as '{rendered_code_expected}'", - ) - - # 'test_path_' and 'dir' must be rendered - args = {"test_path_": "/tmp", "os": "debian", "test_dir": "odoo"} - res = self.command_create_dir.render_code(**args) - rendered_code = res.get(self.command_create_dir.id) - self.assertEqual( - rendered_code, - "cd /tmp && mkdir odoo", - msg="Must be rendered as 'cd /tmp && mkdir odoo'", - ) - - def test_run_command_with_variables(self): - """Test code execution using command log records""" - - x = 1 # Used to distinguish labels - - # Check with all available "sudo" option - for sudo in [False, "n", "p"]: - # Add label to track command log - self.server_test_1.use_sudo = sudo - command_label = f"Test Command {x}" - custom_values = {"log": {"label": command_label}} - - # Run command for Server 1 - self.server_test_1.run_command( - self.command_create_dir, sudo=sudo, **custom_values - ) - - # Expected rendered command code - rendered_code_expected = "cd /opt/tower && mkdir test-odoo-1" - - # Get command log - log_record = self.CommandLog.search([("label", "=", command_label)]) - - # Check log values - self.assertEqual(len(log_record), 1, msg="Must be a single log record") - self.assertEqual( - log_record.server_id.id, - self.server_test_1.id, - msg="Record must belong to Test 1", - ) - self.assertEqual( - log_record.command_id.id, - self.command_create_dir.id, - msg="Record must belong to command 'Create dir'", - ) - self.assertEqual( - log_record.code, - rendered_code_expected, - msg=f"Rendered code must be '{rendered_code_expected}'", - ) - self.assertEqual( - log_record.command_status, 0, msg="Command status must be equal to 0" - ) - self.assertEqual( - log_record.use_sudo, - sudo, - msg="'sudo' param in log doesn't match the command one", - ) - - # Increment label counter - x += 1 - - def test_run_command_with_keys(self): - """Test command with keys in code""" - - # Command - code = "cd {{ test_path_ }} && mkdir #!cxtower.secret.FOLDER!#" - command_with_keys = self.Command.create( - {"name": "Command with keys", "code": code} - ) - - # Parse command with key parser to ensure key is parsed correctly - code_parsed_expected = "cd {{ test_path_ }} && mkdir secretFolder" - code_parsed = self.Key._parse_code(code) - self.assertEqual( - code_parsed, - code_parsed_expected, - msg="Parsed code doesn't match expected one", - ) - - # Add label to track command log - command_label = "Test Command with keys" - custom_values = {"log": {"label": command_label}} - - # Run command for Server 1 - self.server_test_1.run_command(command_with_keys, **custom_values) - - # Expected rendered command code - rendered_code_expected = "cd /opt/tower && mkdir #!cxtower.secret.FOLDER!#" - - # Get command log - log_record = self.CommandLog.search([("label", "=", command_label)]) - - # Check log values - self.assertEqual(len(log_record), 1, msg="Must be a single log record") - self.assertEqual( - log_record.server_id.id, - self.server_test_1.id, - msg=("Record must belong %s", self.server_test_1.name), - ) - self.assertEqual( - log_record.command_id.id, - command_with_keys.id, - msg=("Record must belong to command %s", command_with_keys.name), - ) - self.assertEqual( - log_record.code, - rendered_code_expected, - msg=f"Rendered code must be '{rendered_code_expected}'", - ) - self.assertEqual( - log_record.command_status, 0, msg="Command status must be equal to 0" - ) - - def test_parse_ssh_command_result(self): - """Test ssh command result parsing""" - - placeholder = self.Key.SECRET_VALUE_PLACEHOLDER - # ------------------------------------------------------- - # Case 1: regular command execution result with no error - # We are testing secret value placeholder here - # ------------------------------------------------------- - status = 0 - response = ["Such much", f"Doge like SSH {placeholder}"] - error = [] - - ssh_command_result = self.Server._parse_command_results( - status, response, error, key_values=[f"{self.secret_2.secret_value}"] - ) - - # Get result - result_status = ssh_command_result["status"] - result_response = ssh_command_result["response"] - result_error = ssh_command_result["error"] - - self.assertEqual( - result_status, - result_status, - "Status in result must be the same as the initial one", - ) - self.assertEqual( - result_response, - f"Such muchDoge like SSH {placeholder}", - "Response in result doesn't match expected", - ) - self.assertIsNone(result_error, "Error in response must be set to None") - - # ------------------------------------------------------- - # Case 2: no response but an error - # ------------------------------------------------------- - status = 1 - response = [] - error = ["Ooops", "I did", "it again"] - - ssh_command_result = self.Server._parse_command_results(status, response, error) - - # Get result - result_status = ssh_command_result["status"] - result_response = ssh_command_result["response"] - result_error = ssh_command_result["error"] - - self.assertEqual( - result_status, - result_status, - "Status in result must be the same as the initial one", - ) - self.assertIsNone(result_response, "Response in response must be set to None") - self.assertEqual( - result_error, "OoopsI didit again", "Error in result doesn't match expected" - ) - - # ------------------------------------------------------- - # Case 3: several codes all 0, no response but an error - # ------------------------------------------------------- - status = [0, 0, 0] - response = [] - error = ["Ooops", "I did", "it again"] - - ssh_command_result = self.Server._parse_command_results(status, response, error) - - # Get result - result_status = ssh_command_result["status"] - result_response = ssh_command_result["response"] - result_error = ssh_command_result["error"] - - self.assertEqual( - result_status, 0, "Status in result doesn't match expected one" - ) - self.assertIsNone(result_response, "Response in response must be set to None") - self.assertEqual( - result_error, "OoopsI didit again", "Error in result doesn't match expected" - ) - - # ------------------------------------------------------- - # Case 4: codes [0,1,0,4,0], no response but an error - # ------------------------------------------------------- - status = [0, 1, 0, 4, 0] - response = [] - error = ["Ooops", "I did", "it again"] - - ssh_command_result = self.Server._parse_command_results(status, response, error) - - # Get result - result_status = ssh_command_result["status"] - result_response = ssh_command_result["response"] - result_error = ssh_command_result["error"] - - self.assertEqual( - result_status, 4, "Status in result doesn't match expected one" - ) - self.assertIsNone(result_response, "Response in response must be set to None") - self.assertEqual( - result_error, "OoopsI didit again", "Error in result doesn't match expected" - ) - - # ------------------------------------------------------- - # Case 5: regular command execution result with no error - # However the command result is saved in the "error" value. - # For example this happens in 'docker build'. - # ------------------------------------------------------- - status = 0 - error = ["Such much", f"Doge like SSH {placeholder}"] - response = [] - - ssh_command_result = self.Server._parse_command_results( - status, response, error, key_values=[f"{self.secret_2.secret_value}"] - ) - - # Get result - result_status = ssh_command_result["status"] - result_response = ssh_command_result["response"] - result_error = ssh_command_result["error"] - - self.assertEqual( - result_status, - result_status, - "Status in result must be the same as the initial one", - ) - self.assertEqual( - result_error, - f"Such muchDoge like SSH {placeholder}", - "Response in result doesn't match expected", - ) - self.assertIsNone(result_response, "Error in response must be set to None") - - def test_tower_command_action_file_using_template(self): - """ - Test action file using template for tower source - """ - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_server.CxTowerServer.upload_file", - return_value="ok", - ): - self.server_test_1.run_command( - self.command_create_file_with_template_tower_source - ) - - log_text_create_success = "File created and uploaded successfully" - log_text_file_exists = "An error occurred: File already exists on server." - - # Get command log - log_record = self.CommandLog.search( - [ - ("server_id", "=", self.server_test_1.id), - ( - "command_id", - "=", - self.command_create_file_with_template_tower_source.id, - ), - ("command_response", "=", log_text_create_success), - ] - ) - - self.assertEqual(len(log_record), 1, msg="Must be a single log record") - - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_server.CxTowerServer.upload_file", - return_value="ok", - ): - self.server_test_1.run_command( - self.command_create_file_with_template_tower_source - ) - - log_record_2 = self.CommandLog.search( - [ - ("server_id", "=", self.server_test_1.id), - ( - "command_id", - "=", - self.command_create_file_with_template_tower_source.id, - ), - ("command_error", "=", log_text_file_exists), - ] - ) - - self.assertEqual(len(log_record_2), 1, msg="Must be a single log record") - - def test_server_command_action_file_using_template(self): - """ - Test action file using template for server source - """ - self.assertFalse(self.template_file_server.file_ids) - - def download_file(this, remote_path): - return b"Hello, world!" - - cx_tower_server_obj = self.registry["cx.tower.server"] - - with patch.object(cx_tower_server_obj, "download_file", download_file): - self.server_test_1.run_command( - self.command_create_file_with_template_server_source - ) - - log_text_create_success = "File created and uploaded successfully" - log_text_file_exists = "An error occurred: File already exists on server." - - # Get command log - log_record = self.CommandLog.search( - [ - ("server_id", "=", self.server_test_1.id), - ( - "command_id", - "=", - self.command_create_file_with_template_server_source.id, - ), - ("command_response", "=", log_text_create_success), - ] - ) - - self.assertEqual(len(log_record), 1, msg="Must be a single log record") - self.assertEqual( - len(self.template_file_server.file_ids), 1, msg="Must be one file!" - ) - self.assertEqual( - self.template_file_server.file_ids.source, - "server", - msg="The File source must be 'server'", - ) - - with patch.object(cx_tower_server_obj, "download_file", download_file): - self.server_test_1.run_command( - self.command_create_file_with_template_server_source - ) - - log_record_2 = self.CommandLog.search( - [ - ("server_id", "=", self.server_test_1.id), - ( - "command_id", - "=", - self.command_create_file_with_template_server_source.id, - ), - ("command_error", "=", log_text_file_exists), - ] - ) - - self.assertEqual(len(log_record_2), 1, msg="Must be a single log record") - - def test_run_command_no_command_log(self): - """Run command without creating a log record. - Such commands return execution result directly. - """ - # Add label to track command log - command_label = "Test Command with keys" - custom_values = {"log": {"label": command_label}} - - # Run command for Server 1 - command_result = self.server_test_1.with_context( - no_command_log=True - ).run_command(self.command_create_dir, **custom_values) - self.assertEqual( - command_result["status"], 0, "Command status doesn't match expected one" - ) - self.assertEqual( - command_result["response"], - "ok", - "Command response doesn't match expected one", - ) - self.assertIsNone( - command_result["error"], "Command error doesn't match expected one" - ) - - def test_another_command_is_running(self): - """Test a case when another command is running on the same server""" - - # Remove all existing command logs - self.CommandLog.search([]).unlink() - - # Create a new command log - initial_command_log = self.CommandLog.create( - { - "server_id": self.server_test_1.id, - "command_id": self.command_create_new_command.id, - "start_date": Datetime.now(), - } - ) - - # Run the command without creating a log record - command_result = self.server_test_1.with_context( - no_command_log=True - ).run_command(self.command_create_new_command) - self.assertEqual(command_result["status"], ANOTHER_COMMAND_RUNNING) - - # Run the command with creating a log record - command_result = self.server_test_1.run_command(self.command_create_new_command) - - # Get the command log - command_log = self.CommandLog.search( - [ - ("server_id", "=", self.server_test_1.id), - ("command_id", "=", self.command_create_new_command.id), - ("id", "!=", initial_command_log.id), - ] - ) - self.assertEqual(len(command_log), 1, "Must be a single log record") - self.assertEqual(command_log.command_status, ANOTHER_COMMAND_RUNNING) - - def test_file_using_template_create_if_exists(self): - """Test uploading file using template if it exists on server.""" - - command = self.command_create_file_with_template_server_source - command.write({"if_file_exists": "skip"}) - - # Create file to make sure that it exists on the server - file_template = command.file_template_id - orig_file = file_template.create_file( - server=self.server_test_1, - server_dir=file_template.server_dir, - if_file_exists=command.if_file_exists, - ) - - self.assertTrue(orig_file, "File must be created on the server") - - # Test if file exists and command is set to "skip" - skipped_file = file_template.create_file( - server=self.server_test_1, - server_dir=file_template.server_dir, - if_file_exists=command.if_file_exists, - ) - self.assertEqual( - orig_file, - skipped_file, - "Skip should return the existing file, not create a new one", - ) - self.assertEqual( - self.env["cx.tower.file"].search_count( - [ - ("template_id", "=", file_template.id), - ("server_id", "=", self.server_test_1.id), - ] - ), - 1, - "There must be exactly one physical file record after skip", - ) - - # Change command to raise an error if file exists - command.write({"if_file_exists": "raise"}) - with self.assertRaisesRegex( - ValidationError, - "File already exists on server.", - ): - file_template.create_file( - server=self.server_test_1, - server_dir=file_template.server_dir, - if_file_exists=command.if_file_exists, - ) - # Change command to "overwrite" file if it exists - command.write({"if_file_exists": "overwrite"}) - # Run command again, it should overwrite the file - file_template.create_file( - server=self.server_test_1, - server_dir=file_template.server_dir, - if_file_exists=command.if_file_exists, - ) - self.assertEqual( - self.env["cx.tower.file"].search_count( - [ - ("template_id", "=", file_template.id), - ("server_id", "=", self.server_test_1.id), - ("server_dir", "=", file_template.server_dir), - ] - ), - 1, - "There must be exactly one physical file record after overwrite", - ) - self.assertEqual( - orig_file.code, - file_template.code, - "File code must match template after overwrite", - ) - self.assertEqual( - orig_file.name, - file_template.file_name, - "File name must match template after overwrite", - ) - self.assertEqual( - orig_file.source, - file_template.source, - "File source must match template after overwrite", - ) - - def test_is_file_disconnected_from_template(self): - """Test if file is disconnected from template after being created.""" - - initial_files = self.server_test_1.file_ids - command = self.command_create_file_with_template_server_source - - command.disconnect_file = True - self.server_test_1.run_command(command=command) - - new_files = self.server_test_1.file_ids - initial_files - self.assertEqual(len(new_files), 1, "Must be one new file created") - self.assertEqual( - new_files.code_on_server, - command.file_template_id.code, - "File code must match template", - ) - self.assertFalse( - new_files.template_id, "File must be disconnected from template" - ) - - # --------------------- - # ********************* - # Python commands - # ********************* - # --------------------- - - def test_render_code_python(self): - """Test Python code template direct rendering""" - - rendered_command = self.server_test_1._render_command( - self.command_create_new_command - ) - - # Note: this is rendered as for Server Test 1 - rendered_code_pythonic = ( - f""" -server_name = "{self.server_test_1.name}" -if server_name and #!cxtower.secret.FOLDER!# == "secretFolder": - # We don't actually create a new command because it will raise - # access error if user doesn't have access to 'create' operation. - # Instead we just return a dummy command result. - command = "new command" - result = {{"exit_code": 0, "message": "New command was created"}} -else: - result = {{"exit_code": %s, "message": "error"}} - """ - % GENERAL_ERROR - ) - - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_pythonic, - "Rendered code doesn't match", - ) - - def test_execute_python_command(self): - """ - Run command with python action. - """ - command_result = self.server_test_1.with_context( - no_command_log=True - ).run_command(self.command_create_new_command) - self.assertEqual( - command_result["status"], 0, "The command result status must be 0" - ) - self.assertEqual( - command_result["response"], - "New command was created", - "The response must be text", - ) - - # Check error is raises - self.secret_folder_key.secret_value = "not_a_secretFolder" - command_result = self.server_test_1.with_context( - no_command_log=True - ).run_command(self.command_create_new_command) - self.assertEqual( - command_result["status"], - GENERAL_ERROR, - "The command result status must be GENERAL_ERROR", - ) - self.assertEqual( - command_result["error"], - "error", - "The error response must be contain text - error", - ) - - def test_run_python_code_banned_keywords(self): - """ - Test that _run_python_code raises ValidationError when code contains - banned keywords (e.g. _set_secret_values, _get_secret_value, - _get_secret_values). - """ - banned_keywords = self.Command._get_banned_python_code_keywords() - for banned_keyword in banned_keywords: - with self.subTest(banned_keyword=banned_keyword): - code = f""" -result = {{"exit_code": 0, "message": "ok"}} -# Banned: {banned_keyword} -""" - with self.assertRaises(ValidationError) as cm: - self.server_test_1._run_python_code(code, raise_on_error=True) - self.assertIn( - banned_keyword, - str(cm.exception), - "ValidationError must mention the banned keyword", - ) - - def test_run_python_code(self): - """ - Test python execution code - """ - rendered_command = self.server_test_1._render_command( - self.command_create_new_command - ) - - command_result = self.server_test_1._run_python_code( - rendered_command["rendered_code"] - ) - self.assertEqual( - command_result["status"], 0, "The command result status must be 0" - ) - self.assertEqual( - command_result["response"], - "New command was created", - "The response must be text", - ) - self.assertIsNone( - command_result["error"], - "Error in command result must be set to None", - ) - - def test_run_command_without_set_server_status(self): - """ - Test command execution without setting server status - """ - # Set command access level to "user" - self.command_create_new_command.write({"access_level": "1"}) - - # Add user to command - self.write_and_invalidate( - self.server_test_1, **{"user_ids": [(4, self.user.id)]} - ) - - # Reset access rule cache - self.env["ir.rule"].invalidate_recordset() - - # Run command - server_status = self.server_test_1.status - - result = ( - self.server_test_1.with_context(no_command_log=True) - .with_user(self.user) - .run_command(self.command_create_new_command) - ) - - # Check command result - self.assertEqual(result["status"], 0, "Command status must be 0") - self.assertEqual( - self.server_test_1.status, server_status, "Server status must be 'running'" - ) - - def test_run_command_with_set_server_status(self): - """ - Test command execution with setting server status - """ - # Set server status to "down" - self.command_create_new_command.write({"server_status": "stopping"}) - - # Run command - self.server_test_1.with_context(no_command_log=True).run_command( - self.command_create_new_command - ) - - # Check command result - self.assertEqual( - self.server_test_1.status, "stopping", "Server status must be 'stopping'" - ) - - def test_run_python_code_with_secret(self): - """ - Test execution of Python code with a secret value. - This test ensures that a command is rendered and executed correctly, - and that the secret value is correctly handled and replaced in the output. - """ - - placeholder = self.Key.SECRET_VALUE_PLACEHOLDER - # Case 1 - # Render the command using server_test_1 - rendered_command = self.server_test_1._render_command( - self.command_python_command_1 - ) - - # Run the rendered Python code - command_result = self.server_test_1._run_python_code( - rendered_command["rendered_code"] - ) - - # Assert that the command execution status is 0 (indicating success) - self.assertEqual( - command_result["status"], 0, "The command result status must be 0" - ) - - # Assert that the response contains the secret spoiler text - self.assertEqual( - command_result["response"], - placeholder, - "The response must correctly include the secret value placeholder", - ) - - # Assert that no error occurred during execution (error should be None) - self.assertIsNone( - command_result["error"], - "The error in command result must be None", - ) - - # Case 2 - # Render the command using server_test_1 - rendered_command = self.server_test_1._render_command( - self.command_python_command_2 - ) - - # Run the rendered Python code - command_result = self.server_test_1._run_python_code( - rendered_command["rendered_code"] - ) - - # Assert that the command execution status is 0 (indicating success) - self.assertEqual( - command_result["status"], 0, "The command result status must be 0" - ) - - # Assert that the response contains the secret spoiler text - self.assertEqual( - command_result["response"], - f'We use "{placeholder}"', - "The response must correctly include the secret value placeholder", - ) - - # Assert that no error occurred during execution (error should be None) - self.assertIsNone( - command_result["error"], - "The error in command result must be None", - ) - - # Case 3 - # Render the command using server_test_1 - rendered_command = self.server_test_1._render_command( - self.command_python_command_3 - ) - - # Run the rendered Python code - command_result = self.server_test_1._run_python_code( - rendered_command["rendered_code"] - ) - - # Assert that the command execution status is 0 (indicating success) - self.assertEqual( - command_result["status"], 0, "The command result status must be 0" - ) - - # Assert that the response contains the secret spoiler text - self.assertEqual( - command_result["response"], - placeholder, - "The response must correctly include the secret value placeholder", - ) - - # Assert that no error occurred during execution (error should be None) - self.assertIsNone( - command_result["error"], - "The error in command result must be None", - ) - - # Case 4 - # Render the command using server_test_1 - rendered_command = self.server_test_1._render_command( - self.command_python_command_4 - ) - - # Run the rendered Python code - # SSH keys are not parsed inline, so we should raise a validation error - command_result = self.server_test_1._run_python_code( - rendered_command["rendered_code"] - ) - - # Assert that the command execution status is 0 (indicating success) - self.assertEqual( - command_result["status"], 0, "The command result status must be 0" - ) - - # Assert that the response contains the secret spoiler text - self.assertEqual( - command_result["response"], - placeholder, - "The response must correctly include the secret value placeholder", - ) - - # Assert that no error occurred during execution (error should be None) - self.assertIsNone( - command_result["error"], - "The error in command result must be None", - ) - - def test_command_with_secret(self): - """ - Test case to verify that when a command includes a secret reference, - the secret key is automatically linked with the command. - """ - - # Command with a secret reference - code = "cd {{ test_path_ }} && mkdir #!cxtower.secret.FOLDER!#" - - secrets = self.Command._extract_secret_ids(code) - secret_folder_key = self.secret_folder_key - self.assertIn( - secret_folder_key, - secrets, - msg=( - f"The expected secret ID #{secret_folder_key.id} " - "was not found in the provided code." - ), - ) - - command_with_keys = self.Command.create( - {"name": "Command with keys", "code": code} - ) - - # -- 1 -- - # Assert that the secret key is linked with the command - self.assertIn( - secret_folder_key, - command_with_keys.secret_ids, - msg="The secret key is not linked with the command.", - ) - - # -- 2 -- - # Update the command's code to remove the secret reference - updated_code = "cd {{ test_path_ }} && mkdir new_folder" - command_with_keys.code = updated_code - - self.assertFalse( - command_with_keys.secret_ids, - msg=( - "The secret_ids field should be empty after " - "removing the secret reference from command." - ), - ) - - # -- 3 -- - # Create a secret with the same reference but connected to another server - another_server = self.server_test_1.copy({"name": "another server"}) - another_secret = self.Key.create( - { - "name": "another secret", - "reference": secret_folder_key.reference, - "key_type": "s", - } - ) - another_secret_value = self.KeyValue.create( - { - "key_id": another_secret.id, - "server_id": another_server.id, - "secret_value": "another secret value", - } - ) - # Set original code again - command_with_keys.code = code - self.assertEqual( - len(command_with_keys.secret_ids), - 1, - msg="Must be only one secret", - ) - self.assertIn( - secret_folder_key, - command_with_keys.secret_ids, - msg="The secret key is not linked with the command.", - ) - self.assertNotIn( - another_secret, - command_with_keys.secret_ids, - msg="The another secret is linked with the command.", - ) - - # -- 4 -- - # Connect command to server and secret to another server - # and ensure it's unlinked from the command. - yet_one_more_server = self.server_test_1.copy({"name": "yet one more server"}) - - self.write_and_invalidate( - another_secret_value, **{"server_id": yet_one_more_server.id} - ) - self.write_and_invalidate( - command_with_keys, **{"server_ids": self.server_test_1} - ) - self.assertEqual( - len(command_with_keys.secret_ids), - 1, - msg="Must be one secret", - ) - - def test_check_zombie_commands(self): - """Test checking and marking zombie commands""" - # Create test commands - ssh_command = self.Command.create( - { - "name": "Test SSH Command", - "code": "ls -la", - "action": "ssh_command", - } - ) - python_command = self.Command.create( - { - "name": "Test Python Command", - "code": "print('test')", - "action": "python_code", - } - ) - plan_command = self.Command.create( - { - "name": "Test Plan Command", - "code": "test plan", - "action": "plan", - } - ) - - # Set command timeout to 10 seconds - self.env["ir.config_parameter"].sudo().set_param( - "cetmix_tower_server.command_timeout", "10" - ) - - # Create command logs with different start times - now = Datetime.now() - old_time = now - timedelta(seconds=20) # Older than timeout - recent_time = now - timedelta(seconds=5) # Within timeout - - # Create zombie SSH command log - zombie_ssh_log = self.CommandLog.create( - { - "command_id": ssh_command.id, - "server_id": self.server_test_1.id, - "start_date": old_time, - } - ) - - # Create zombie Python command log - zombie_python_log = self.CommandLog.create( - { - "command_id": python_command.id, - "server_id": self.server_test_1.id, - "start_date": old_time, - } - ) - - # Create non-zombie command logs - active_ssh_log = self.CommandLog.create( - { - "command_id": ssh_command.id, - "server_id": self.server_test_1.id, - "start_date": recent_time, - } - ) - - plan_log = self.CommandLog.create( - { - "command_id": plan_command.id, - "server_id": self.server_test_1.id, - "start_date": old_time, - } - ) - - # Test with timeout set - self.server_test_1._check_zombie_commands() - - # Check zombie commands are marked as finished - self.assertFalse( - zombie_ssh_log.is_running, "Zombie SSH command should be marked as finished" - ) - self.assertFalse( - zombie_python_log.is_running, - "Zombie Python command should be marked as finished", - ) - self.assertEqual( - zombie_ssh_log.command_status, - COMMAND_TIMED_OUT, - "Zombie SSH command should have timed out status", - ) - self.assertEqual( - zombie_python_log.command_error, - COMMAND_TIMED_OUT_MESSAGE, - "Zombie Python command should have timeout error message", - ) - - # Check non-zombie commands are still running - self.assertTrue( - active_ssh_log.is_running, "Recent command should still be running" - ) - self.assertTrue( - plan_log.is_running, "Plan command should not be affected by timeout" - ) - - # Test with timeout disabled - self.env["ir.config_parameter"].sudo().set_param( - "cetmix_tower_server.command_timeout", "0" - ) - - # Create new zombie command log - new_zombie_log = self.CommandLog.create( - { - "command_id": ssh_command.id, - "server_id": self.server_test_1.id, - "start_date": old_time, - } - ) - - self.server_test_1._check_zombie_commands() - self.assertNotEqual( - new_zombie_log.command_status, - COMMAND_TIMED_OUT, - "Commands should not be marked as timed out when timeout is disabled", - ) - - def test_command_with_malformed_code(self): - """Test rendering command using `_render_command` method - of cx.tower.server with malformed code - """ - - with self.assertRaises(ValidationError): - self.Command.create( - { - "name": "Test Malformed Command", - "code": "cd {{ !@238203 }} && mkdir #!cxtower.secret.FOLDER!#", - "action": "ssh_command", - } - ) - - def test_server_render_command_with_jet(self): - """Test rendering command using `_render_command` method - of cx.tower.server - """ - - # -- 1 -- - # Test with default path and jet - rendered_command = self.server_test_1._render_command( - command=self.command_create_dir, - jet_template=self.jet_template_sample, - jet=self.jet_sample, - ) - rendered_code_expected = "cd /jets/jet1 && mkdir jet_templates" - rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" - - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_expected, - "Rendered code doesn't match", - ) - self.assertEqual( - rendered_command["rendered_path"], - rendered_path_expected, - "Rendered path doesn't match", - ) - - # -- 2 -- - # Test with custom variable values - custom_variable_values = {"test_path_": "/such/much/jet"} - rendered_command = self.server_test_1._render_command( - command=self.command_create_dir, - jet_template=self.jet_template_sample, - jet=self.jet_sample, - custom_variable_values=custom_variable_values, - ) - rendered_code_expected = "cd /such/much/jet && mkdir jet_templates" - rendered_path_expected = f"/home/{self.server_test_1.ssh_username}" - - self.assertEqual( - rendered_command["rendered_code"], - rendered_code_expected, - "Rendered code doesn't match", - ) diff --git a/addons/cetmix_tower_server/tests/test_command_log.py b/addons/cetmix_tower_server/tests/test_command_log.py deleted file mode 100644 index 9c1ed86..0000000 --- a/addons/cetmix_tower_server/tests/test_command_log.py +++ /dev/null @@ -1,282 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import fields -from odoo.exceptions import AccessError - -from .common import TestTowerCommon - - -class TestTowerCommandLog(TestTowerCommon): - """Test the cx.tower.command.log model access rights.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create commands with different access levels - cls.command_level_1 = cls.Command.create( - { - "name": "Test Command L1", - "action": "ssh_command", - "access_level": "1", - } - ) - - cls.command_level_2 = cls.Command.create( - { - "name": "Test Command L2", - "action": "ssh_command", - "access_level": "2", - } - ) - - cls.command_level_3 = cls.Command.create( - { - "name": "Test Command L3", - "action": "ssh_command", - "access_level": "3", - } - ) - - # Create test command logs with specific users - cls.command_log_1 = ( - cls.CommandLog.with_user(cls.user) - .sudo() - .create( - { - "server_id": cls.server_test_1.id, - "command_id": cls.command_level_1.id, - "start_date": fields.Datetime.now(), - } - ) - ) - - cls.command_log_2 = ( - cls.CommandLog.with_user(cls.manager) - .sudo() - .create( - { - "server_id": cls.server_test_1.id, - "command_id": cls.command_level_1.id, - "start_date": fields.Datetime.now(), - } - ) - ) - - # Create additional server for testing - cls.server_2 = cls.Server.create( - { - "name": "Test Server 2", - "ip_v4_address": "localhost", - "ssh_username": "test2", - "ssh_password": "test2", - "ssh_port": 22, - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - } - ) - - def test_user_read_access(self): - """Test user read access to command logs""" - # Add user to server's user_ids to isolate creator check - self.server_test_1.write( - { - "user_ids": [(6, 0, [self.user.id])], - } - ) - - # Case 1: User should be able to read when: - # - access_level == "1" - # - created by user - # - user is in server's user_ids - recs = self.CommandLog.with_user(self.user).search( - [("id", "in", [self.command_log_1.id, self.command_log_2.id])] - ) - self.assertEqual( - len(recs), - 1, - "User should only be able to read their own logs", - ) - self.assertIn( - self.command_log_1, - recs, - "User should be able to read own logs when conditions are met", - ) - self.assertNotIn( - self.command_log_2, - recs, - "User should not be able to read logs created by others", - ) - - # Case 2: User should not be able to read when not in server's user_ids - self.server_test_1.write( - { - "user_ids": [(5, 0, 0)], # Remove all users - } - ) - recs = self.CommandLog.with_user(self.user).search( - [("id", "=", self.command_log_1.id)] - ) - self.assertNotIn( - self.command_log_1, - recs, - "User should not be able to read when not in server's user_ids", - ) - - # Case 3: User should not be able to read when access_level > "1" - self.server_test_1.write( - { - "user_ids": [(6, 0, [self.user.id])], - } - ) - high_access_log = ( - self.CommandLog.with_user(self.user) - .sudo() - .create( - { - "server_id": self.server_test_1.id, - "command_id": self.command_level_2.id, # Using command with access_level "2" # noqa: E501 - "start_date": fields.Datetime.now(), - } - ) - ) - recs = self.CommandLog.with_user(self.user).search( - [("id", "=", high_access_log.id)] - ) - self.assertNotIn( - high_access_log, - recs, - "User should not be able to read logs with access_level > '1'" - " even if created by them", - ) - - def test_manager_read_access(self): - """Test manager read access to command logs""" - # Case 1: Manager should be able to read when: - # - access_level <= "2" - # - manager is in server's manager_ids - self.server_test_1.write( - { - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - recs = self.CommandLog.with_user(self.manager).search( - [("id", "in", [self.command_log_1.id, self.command_log_2.id])] - ) - self.assertEqual( - len(recs), - 2, - "Manager should be able to read all logs when in server's manager_ids", - ) - - # Case 2: Manager should be able to read when in server's user_ids - self.server_test_1.write( - { - "manager_ids": [(5, 0, 0)], # Remove all managers - "user_ids": [(6, 0, [self.manager.id])], - } - ) - recs = self.CommandLog.with_user(self.manager).search( - [("id", "in", [self.command_log_1.id, self.command_log_2.id])] - ) - self.assertEqual( - len(recs), - 2, - "Manager should be able to read all logs when in server's user_ids", - ) - - # Case 3: Manager should not be able to read when access_level > "2" - high_access_log = ( - self.CommandLog.with_user(self.manager) - .sudo() - .create( - { - "server_id": self.server_test_1.id, - "command_id": self.command_level_3.id, # Using command with access_level "3" # noqa: E501 - "start_date": fields.Datetime.now(), - } - ) - ) - recs = self.CommandLog.with_user(self.manager).search( - [("id", "=", high_access_log.id)] - ) - self.assertNotIn( - high_access_log, - recs, - "Manager should not be able to read logs with access_level > '2'", - ) - - # Case 4: Manager should not be able to read when he is not - # in users_ids or manager_ids - self.server_test_1.write( - { - "user_ids": [(5, 0, 0)], - "manager_ids": [(5, 0, 0)], - } - ) - recs = self.CommandLog.with_user(self.manager).search( - [("id", "in", [self.command_log_1.id, self.command_log_2.id])] - ) - self.assertNotIn( - self.command_log_1, - recs, - "Manager should not be able to read logs when he is not" - " in users_ids or manager_ids", - ) - - def test_root_read_only_access(self): - """Root can read all command logs, but cannot create/modify/delete""" - # Create test logs with sudo() - test_logs = self.CommandLog.sudo().create( - [ - { - "server_id": self.server_2.id, - "command_id": command.id, - "start_date": fields.Datetime.now(), - } - for command in [ - self.command_level_1, - self.command_level_2, - self.command_level_3, - ] - ] - ) - # Root cannot create logs - with self.assertRaises(AccessError): - self.CommandLog.with_user(self.root).create( - { - "server_id": self.server_2.id, - "command_id": self.command_level_1.id, - "start_date": fields.Datetime.now(), - } - ) - - # Root cannot modify logs - with self.assertRaises(AccessError): - test_logs.with_user(self.root).write({"start_date": fields.Datetime.now()}) - - # Root cannot delete logs - with self.assertRaises(AccessError): - test_logs.with_user(self.root).unlink() - - # Root should be able to read all logs regardless of: - # - access_level - # - server relationships - # - who created them - recs = self.CommandLog.with_user(self.root).search( - [("id", "in", test_logs.ids)] - ) - self.assertEqual( - len(recs), - 3, - "Root should have unrestricted read access to all logs", - ) - - # Test read on all records - all_recs = self.CommandLog.with_user(self.root).search([]) - self.assertGreater( - len(all_recs), - 0, - "Root should be able to read all command logs", - ) diff --git a/addons/cetmix_tower_server/tests/test_command_wizard.py b/addons/cetmix_tower_server/tests/test_command_wizard.py deleted file mode 100644 index ca44338..0000000 --- a/addons/cetmix_tower_server/tests/test_command_wizard.py +++ /dev/null @@ -1,572 +0,0 @@ -# Copyright (C) 2022 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError, ValidationError - -from .common import TestTowerCommon - - -class TestTowerCommandWizard(TestTowerCommon): - """Test Tower Command Run Wizard""" - - def test_user_access_rules(self): - """Test user access rules""" - - # Add Bob to `root` group in order to create a wizard - self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") - - # Create new wizard - test_wizard = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.user_bob) - .create( - { - "server_ids": [self.server_test_1.id], - "command_id": self.command_create_dir.id, - } - ) - ).with_user(self.user_bob) - - # Force rendered code computation - test_wizard._compute_rendered_code() - - # Remove bob from all cxtower_server groups - self.remove_from_group( - self.user_bob, - [ - "cetmix_tower_server.group_user", - "cetmix_tower_server.group_manager", - "cetmix_tower_server.group_root", - ], - ) - # Ensure that regular user cannot execute command in wizard - with self.assertRaises(AccessError): - test_wizard.run_command_in_wizard() - - # Add bob back to `user` group and try again - self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") - with self.assertRaises(AccessError): - test_wizard.run_command_in_wizard() - - # Now promote bob to `manager` group and try again - self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") - test_wizard.run_command_in_wizard() - - def test_execute_code_without_a_command(self): - """Run command code without a command selected""" - - # Add Bob to `root` group in order to create a wizard - self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") - - # Create new wizard - test_wizard = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.user_bob) - .create( - { - "server_ids": [self.server_test_1.id], - } - ) - ).with_user(self.user_bob) - - # Should not allow to run command on server if no command is selected - with self.assertRaises(ValidationError): - test_wizard.run_command_on_server() - - def test_run_command_on_server_access_rights(self): - """Test access rights for executing command on server""" - - # Add Bob to `root` group - self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") - - # Create new wizard with Bob as a root user - test_wizard = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.user_bob) - .create( - { - "server_ids": [self.server_test_1.id], - "command_id": self.command_create_dir.id, - } - ) - ).with_user(self.user_bob) - - # Ensure command can be executed by root - test_wizard.run_command_on_server() - - # Remove Bob from all tower server groups - self.remove_from_group( - self.user_bob, - [ - "cetmix_tower_server.group_user", - "cetmix_tower_server.group_manager", - "cetmix_tower_server.group_root", - ], - ) - - # Ensure that regular user cannot execute command on server - with self.assertRaises(AccessError): - test_wizard.run_command_on_server() - - # Add Bob to `user` group and ensure he can execute commands - self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") - test_wizard.run_command_on_server() - # Ensure that Bob has access to path field but can't read its value - allowed_path = ( - self.user_bob.has_group("cetmix_tower_server.group_manager") - and test_wizard.path - ) - - self.assertEqual(allowed_path, False) - # Ensure that Bob can write to the path field as a member of `group_user` - # the result will be None - test_wizard.write({"path": "/new/invalid/path"}) - allowed_path = ( - test_wizard.path - if self.user_bob.has_group("cetmix_tower_server.group_manager") - and test_wizard.path - else None - ) - self.assertEqual(allowed_path, None) - - # Add Bob to `manager` group and ensure access to execute commands - self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") - test_wizard.run_command_on_server() - # Check that path access is valid for the manager - test_wizard.read(["path"]) - - def test_run_command_with_sensitive_vars_on_server_access_rights(self): - """Test access rights for executing command on server""" - # create new command - command = self.Command.create( - { - "name": "Create new command", - "action": "python_code", - "code": """ - properties = { - "Server Name": {{ tower.server.name }}, - "Server Reference": {{ tower.server.reference }}, - "SSH Username": {{ tower.server.username }}, - "IPv4 Address": {{ tower.server.ipv4 }}, - "IPv6 Address": {{ tower.server.ipv6 }}, - "Partner Name": {{ tower.server.partner_name }} - } - result = {"exit_code": 0, "message": properties} - """, - "access_level": "1", - } - ) - - # Add Bob to `root` group in order to create a wizard - self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") - - server = self.Server.with_user(self.user_bob).create( - { - "name": "Test 2", - "ip_v4_address": "localhost", - "ssh_username": "root", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - } - ) - - self.remove_from_group( - self.user_bob, - [ - "cetmix_tower_server.group_user", - "cetmix_tower_server.group_manager", - "cetmix_tower_server.group_root", - ], - ) - - # Add user bob to group user - self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") - - # Create new wizard with Bob - test_wizard = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.user_bob) - .create( - { - "server_ids": [server.id], - "command_id": command.id, - } - ) - ).with_user(self.user_bob) - - # Add Bob as a user to the command - command.write({"user_ids": [(4, self.user_bob.id)]}) - - # Ensure command can be executed by user - test_wizard.run_command_on_server() - - def test_run_command_in_wizard_multiple_servers(self): - """ - Test that raises an error when multiple servers are selected - """ - - # Add Bob to `root` group in order to create a wizard - - server_test_2 = self.Server.create( - { - "name": "Test 2", - "ip_v4_address": "localhost", - "ssh_username": "root", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - } - ) - - self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") - - # Create new wizard with multiple servers selected - test_wizard = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.user_bob) - .create( - { - "server_ids": [self.server_test_1.id, server_test_2.id], - "command_id": self.command_create_dir.id, - } - ) - ).with_user(self.user_bob) - - # Force rendered code computation - test_wizard._compute_rendered_code() - - # Ensure that executing command with multiple servers - # selected raises a ValidationError - with self.assertRaises( - ValidationError, - msg="You cannot run custom code on multiple servers at once.", - ): - test_wizard.run_command_in_wizard() - - # Now, test with a single server selected - test_wizard.server_ids = [self.server_test_1.id] - - # Ensure that executing command works with a single server selected - test_wizard.run_command_in_wizard() - self.assertTrue( - test_wizard.result, - msg="Command execution should succeed with a single server selected", - ) - - def test_custom_variable_values_creation(self): - """ - Test that custom variable values are created properly - when command has variables - """ - # Add manager as server user - self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) - - # Create variables that will be used in command - variable = self.Variable.create( - { - "name": "Test Variable", - "reference": "test_var", - "variable_type": "s", # string type - } - ) - option_variable = self.Variable.create( - { - "name": "Option Variable", - "reference": "opt_var", - "variable_type": "o", # option type - } - ) - option = self.VariableOption.create( - { - "name": "Test Option", - "value_char": "option_value", - "variable_id": option_variable.id, - } - ) - - # Add variable values to server - self.VariableValue.create( - [ - { - "variable_id": variable.id, - "server_id": self.server_test_1.id, - "value_char": "server value", - }, - { - "variable_id": option_variable.id, - "server_id": self.server_test_1.id, - "value_char": "option_value", - }, - ] - ) - - # Create command that uses these variables in its code - command = self.Command.create( - { - "name": "Test Command with Variables", - "action": "ssh_command", - "code": "echo {{ test_var }} && echo {{ opt_var }}", - } - ) - - # Create wizard - wizard = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.manager) - .create( - { - "server_ids": [self.server_test_1.id], - "command_id": command.id, - "action": "ssh_command", - } - ) - ) - - # Trigger onchange to generate custom_variable_values - wizard._onchange_command_variable_ids() - - # Check that custom variable values were created - self.assertEqual(len(wizard.custom_variable_value_ids), 2) - - # Check char variable value - char_value = wizard.custom_variable_value_ids.filtered( - lambda v: v.variable_id == variable - ) - self.assertTrue(char_value) - self.assertEqual(char_value.value_char, "server value") - - # Check option variable value - option_value = wizard.custom_variable_value_ids.filtered( - lambda v: v.variable_id == option_variable - ) - self.assertTrue(option_value) - self.assertEqual(option_value.value_char, "option_value") - self.assertEqual(option_value.option_id, option) - - # Try to change variable value when user doesn't have write access - char_value.value_char = "custom value" - - # Run command - wizard.run_command_on_server() - - # Get latest command log - command_log = self.env["cx.tower.command.log"].search( - [ - ("server_id", "=", self.server_test_1.id), - ("command_id", "=", command.id), - ], - order="create_date desc", - limit=1, - ) - - # Verify that original server values were used - self.assertEqual(command_log.code, "echo server value && echo option_value") - - def test_custom_variable_values_with_manager_access(self): - """ - Test that custom variable values are applied - when manager has write access - """ - # Add manager as server manager - self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) - - # Create variables that will be used in command - variable = self.Variable.create( - { - "name": "Test Variable", - "reference": "test_var", - "variable_type": "s", # string type - } - ) - - # Add variable value to server - self.VariableValue.create( - { - "variable_id": variable.id, - "server_id": self.server_test_1.id, - "value_char": "server value", - } - ) - - # Create command that uses the variable - command = self.Command.create( - { - "name": "Test Command with Variables", - "action": "ssh_command", - "code": "echo {{ test_var }}", - } - ) - - # Create wizard - wizard = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.manager) - .create( - { - "server_ids": [self.server_test_1.id], - "command_id": command.id, - "action": "ssh_command", - } - ) - ) - - # Trigger onchange to generate custom_variable_value_ids - wizard._onchange_command_variable_ids() - - # Modify variable value - wizard.custom_variable_value_ids.filtered( - lambda v: v.variable_id == variable - ).value_char = "manager value" - - # Run command - wizard.run_command_on_server() - - # Get latest command log - command_log = self.env["cx.tower.command.log"].search( - [ - ("server_id", "=", self.server_test_1.id), - ("command_id", "=", command.id), - ], - order="create_date desc", - limit=1, - ) - - # Verify that custom value was used - self.assertEqual(command_log.code, "echo manager value") - - def test_default_applicability_for_regular_and_manager(self): - """sets applicability='this' for regular users, keeps default for managers.""" - # Regular user (no special groups) - default_usr = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.user_bob) - .default_get(["applicability"]) - ) - self.assertEqual(default_usr.get("applicability"), "this") - - # Manager user should receive the original default ("shared") - self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") - default_mgr = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.user_bob) - .default_get(["applicability"]) - ) - self.assertEqual(default_mgr.get("applicability"), "shared") - - def test_compute_show_servers_behavior(self): - """Should enforce 'this' for regular users but preserve manager choice.""" - # Grant Bob the basic 'user' group so he can read servers and create the wizard - self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") - - # Ensure Bob has read access to the first server - self.server_test_1.write({"user_ids": [(4, self.user_bob.id)]}) - # Create a second server and grant Bob read access to it - srv2 = self.Server.create( - { - "name": "Server 2", - "ip_v4_address": "127.0.0.2", - "ssh_username": "root", - "ssh_password": "pwd", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - } - ) - srv2.write({"user_ids": [(4, self.user_bob.id)]}) - - # --- Regular user scenario --- - wiz_usr = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.user_bob) - .create({"server_ids": [self.server_test_1.id, srv2.id]}) - ) - # Compute show_servers under Bob; he should see both servers - wiz_usr._compute_show_servers() - self.assertTrue(wiz_usr.show_servers) - # Enforcement should set applicability to 'this' - self.assertEqual(wiz_usr.applicability, "this") - - # --- Manager user scenario --- - self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") - # Grant Bob manager access to both servers - self.server_test_1.write({"manager_ids": [(4, self.user_bob.id)]}) - srv2.write({"manager_ids": [(4, self.user_bob.id)]}) - - wiz_mgr = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.user_bob) - .create({"server_ids": [self.server_test_1.id, srv2.id]}) - ) - # Compute show_servers under Bob as manager - wiz_mgr._compute_show_servers() - # Manager should also see both servers - self.assertTrue(wiz_mgr.show_servers) - # Enforcement should not override manager's choice of 'shared' - self.assertEqual(wiz_mgr.applicability, "shared") - - def test_required_variable_validation(self): - """ - Wizard must block execution when a required variable is empty - and allow it after the value is provided. - """ - # Create a required variable - var = self.Variable.create( - { - "name": "Req Var", - "reference": "req_var", - "variable_type": "s", - } - ) - self.VariableValue.create( - { - "variable_id": var.id, - "server_id": self.server_test_1.id, - "required": True, - "value_char": "", - } - ) - - # Create command that uses this variable - cmd = self.Command.create( - { - "name": "Echo Req Var", - "action": "ssh_command", - "code": "echo {{ req_var }}", - "variable_ids": [(4, var.id)], - } - ) - - self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) - - # Create wizard as manager user - wiz = ( - self.env["cx.tower.command.run.wizard"] - .with_user(self.manager) - .create( - { - "server_ids": [self.server_test_1.id], - "command_id": cmd.id, - } - ) - ) - - # Create lines of configuration - wiz._onchange_command_variable_ids() - wiz._compute_has_missing_required_values() - - # Test blocking behavior - self.assertTrue(wiz.has_missing_required_values) - with self.assertRaises(ValidationError): - wiz.run_command_on_server() - - # Fill the value directly in the wizard line - wiz.custom_variable_value_ids.filtered( - lambda line: line.variable_id == var - ).value_char = "filled" - - # Recompute the flag - wiz._compute_has_missing_required_values() - self.assertFalse(wiz.has_missing_required_values) - - # Now the execution should pass - wiz.run_command_on_server() diff --git a/addons/cetmix_tower_server/tests/test_file.py b/addons/cetmix_tower_server/tests/test_file.py deleted file mode 100644 index f2feb6f..0000000 --- a/addons/cetmix_tower_server/tests/test_file.py +++ /dev/null @@ -1,482 +0,0 @@ -from odoo import exceptions -from odoo.exceptions import AccessError - -from .common import TestTowerCommon - - -class TestTowerFile(TestTowerCommon): - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.file_template = cls.FileTemplate.create( - { - "name": "Test", - "file_name": "test.txt", - "server_dir": "/var/tmp", - "code": "Hello, world!", - } - ) - cls.file = cls.File.create( - { - "name": "tower_demo_1.txt", - "source": "tower", - "template_id": cls.file_template.id, - "server_id": cls.server_test_1.id, - } - ) - cls.file_2 = cls.File.create( - { - "name": "test.txt", - "source": "server", - "server_id": cls.server_test_1.id, - "server_dir": "/var/tmp", - } - ) - - # Create a dummy Server record that will be referenced by file records. - cls.server = cls.Server.create( - { - "name": "Test Server", - "manager_ids": [(6, 0, [cls.manager.id])], - "user_ids": [(6, 0, [cls.user.id])], - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "skip_host_key": True, - "os_id": cls.os_debian_10.id, - "ip_v4_address": "localhost", - } - ) - - def test_user_read_access(self): - """ - Test that a user in the custom User group can read a file record - when their ID is in the related server's user_ids. - """ - file_record = self.File.create( - { - "name": "Test File", - "server_dir": "/tmp", - "file_type": "text", - "source": "tower", - "server_id": self.server.id, - } - ) - # As the user, the file record should be visible. - files_for_user = self.File.with_user(self.user).search( - [("id", "=", file_record.id)] - ) - self.assertTrue( - files_for_user, - "User should be able to read the file record " - "because they are in server.user_ids.", - ) - - # Remove user from server.user_ids. - self.server.write({"user_ids": [(3, self.user.id)]}) - files_for_user = self.File.with_user(self.user).search( - [("id", "=", file_record.id)] - ) - self.assertFalse( - files_for_user, - "User should not be able to read the file record " - "because he is not in server.user_ids.", - ) - - def test_manager_write_create_access(self): - """ - Test that a manager in the custom Manager group can create and write - file records when his ID is in the related server's manager_ids. - """ - # Test creation: the manager is in server.manager_ids. - file_record = self.File.with_user(self.manager).create( - { - "name": "Manager Created File", - "server_dir": "/tmp", - "file_type": "text", - "source": "tower", - "server_id": self.server.id, - } - ) - self.assertTrue( - file_record, - "Manager should be able to create a file record " - "because they are in server.manager_ids.", - ) - - # Test updating (write access). - try: - file_record.with_user(self.manager).write({"name": "Manager Updated File"}) - except AccessError: - self.fail( - "Manager should be able to update the file record " - "because he is in server.manager_ids." - ) - self.assertEqual( - file_record.with_user(self.manager).name, - "Manager Updated File", - "File record name should be updated by the manager.", - ) - - # Test that a manager who is not in the server's manager_ids - # cannot write or create. - # Remove manager from server.manager_ids. - self.server.write({"manager_ids": [(3, self.manager.id)]}) - # Create a file record on this server. - file_record2 = self.File.create( - { - "name": "File on Server Without Manager", - "server_dir": "/tmp", - "file_type": "text", - "source": "tower", - "server_id": self.server.id, - } - ) - with self.assertRaises(AccessError): - file_record2.with_user(self.manager).write({"name": "Should Not Update"}) - - # Test create access for a manager not in manager_ids. - with self.assertRaises(AccessError): - self.File.with_user(self.manager).create( - { - "name": "Invalid File", - "server_dir": "/tmp", - "file_type": "text", - "source": "tower", - "server_id": self.server.id, - } - ) - - def test_manager_unlink_access(self): - """ - Test that a manager in the custom Manager group can unlink (delete) a file - record only if he is in the related server's manager_ids - and they are the record's creator. - """ - # Scenario 1: Record created by the manager. - file_record = self.File.with_user(self.manager).create( - { - "name": "File to Delete", - "server_dir": "/tmp", - "file_type": "text", - "source": "tower", - "server_id": self.server.id, - } - ) - try: - file_record.with_user(self.manager).unlink() - except AccessError: - self.fail( - "Manager should be able to delete their own file" - " record when in server.manager_ids." - ) - - # Scenario 2: Record created by someone else (e.g., the admin). - file_record2 = self.File.create( - { - "name": "File Not Deletable by Manager", - "server_dir": "/tmp", - "file_type": "text", - "source": "tower", - "server_id": self.server.id, - } - ) - with self.assertRaises(AccessError): - file_record2.with_user(self.manager).unlink() - - def test_upload_file(self): - """ - Upload file from tower to server - """ - self.file.action_push_to_server() - self.assertEqual(self.file.server_response, "ok") - - def test_delete_file(self): - """ - Delete file remotely from server - """ - result = self.file.action_delete_from_server() - self.assertTrue(isinstance(result, dict)) - self.assertEqual(result["params"]["message"], "File deleted!") - - def test_delete_file_access(self): - """ - Test delete file access - """ - with self.assertRaises(exceptions.AccessError): - self.file.with_user(self.user_bob).delete(raise_error=True) - - def test_download_file(self): - """ - Download file from server to tower - """ - self.file_2.action_pull_from_server() - self.assertEqual(self.file_2.code, "ok") - - self.file_2.name = "binary.zip" - res = self.file_2.action_pull_from_server() - self.assertTrue( - isinstance(res, dict) and res["tag"] == "display_notification", - msg=( - "If file type is 'text', then the result must be a dict " - "representing the display_notification action." - ), - ) - - def test_get_current_server_code(self): - """ - Download file from server to tower - """ - self.file.action_push_to_server() - self.assertEqual(self.file.server_response, "ok") - - self.file.action_get_current_server_code() - self.assertEqual(self.file.code_on_server, "ok") - - def test_modify_template_code(self): - """Test how template code modification affects related files""" - code = "Pepe frog is happy as always" - self.file_template.code = code - - # Check file code before modifications - self.assertTrue( - self.file.code == code, - msg="File code must be the same " - "as template code before any modifications", - ) - # Check file rendered code before modifications - self.assertTrue( - self.file.rendered_code == code, - msg="File rendered code must be the same" - " as template code before any modifications", - ) - - # Make possible to modify file code - self.file.action_unlink_from_template() - - # Check if template was removed from file - self.assertFalse( - self.file.template_id, - msg="File template should be removed after modifying code.", - ) - - # Check if file code remains the same - self.assertTrue( - self.file.code == code, msg="File code should be the same as template." - ) - - def test_modify_template_related_files(self): - """ - Check that after change file template - all related files will update - """ - self.assertEqual(self.file_template.file_name, "test.txt") - # related files - self.assertTrue( - all(file.name == "test.txt" for file in self.file_template.file_ids) - ) - - # update file template name - self.file_template.file_name = "new_test.txt" - # Related files must updated - self.assertTrue( - all(file.name == "new_test.txt" for file in self.file_template.file_ids) - ) - - self.assertEqual(self.file_template.code, "Hello, world!") - # update file template code - self.file_template.code = "New code" - # Related files must updated - self.assertTrue( - all(file.code == "New code" for file in self.file_template.file_ids) - ) - - def test_create_file_with_template(self): - """ - Test if file is created with template code - """ - file_template = self.env["cx.tower.file.template"].create( - { - "name": "Test", - "file_name": "test.txt", - "server_dir": "/var/tmp", - "code": "Hello, world!", - } - ) - - file = file_template.create_file( - server=self.server_test_1, - server_dir=file_template.server_dir, - if_file_exists="overwrite", - ) - self.assertEqual(file.code, self.file_template.code) - self.assertEqual(file.template_id, file_template) - self.assertEqual(file.server_id, self.server_test_1) - self.assertEqual(file.source, "tower") - self.assertEqual(file.server_dir, self.file_template.server_dir) - - with self.assertRaises(exceptions.ValidationError): - file_template.create_file( - server=self.server_test_1, - server_dir=file_template.server_dir, - if_file_exists="raise", - ) - - another_file = file_template.create_file( - server=self.server_test_1, - server_dir=file_template.server_dir, - if_file_exists="skip", - ) - self.assertEqual(another_file, file) - - def test_create_file_with_template_custom_server_dir(self): - """ - Test if file is created with template code and custom server dir - """ - file_template = self.env["cx.tower.file.template"].create( - { - "name": "Test", - "file_name": "test.txt", - "server_dir": "/var/tmp", - "code": "Hello, world!", - } - ) - - file = file_template.create_file( - server=self.server_test_1, server_dir="/var/tmp/custom" - ) - self.assertEqual(file.code, self.file_template.code) - self.assertEqual(file.template_id, file_template) - self.assertEqual(file.server_id, self.server_test_1) - self.assertEqual(file.source, "tower") - self.assertEqual(file.server_dir, "/var/tmp/custom") - - with self.assertRaises(exceptions.ValidationError): - file_template.create_file( - server=self.server_test_1, - server_dir="/var/tmp/custom", - if_file_exists="raise", - ) - - another_file = file_template.create_file( - server=self.server_test_1, - server_dir="/var/tmp/custom", - if_file_exists="skip", - ) - self.assertEqual(another_file, file) - - def test_file_with_secret_key(self): - """ - Test case to verify that when a file includes a secret reference, - the secret key is automatically linked with the file. - """ - - # Create a secret key - secret_python_key = self.Key.create( - { - "name": "python", - "reference": "PYTHON", - "secret_value": "secretPythonCode", - "key_type": "s", - } - ) - - # Create a file template with a reference to the secret key - file_template = self.env["cx.tower.file.template"].create( - { - "name": "Test", - "file_name": "test.txt", - "server_dir": "/var/tmp", - "code": "Please use this secret #!cxtower.secret.PYTHON!#", - } - ) - - # Create a file from the file template - file = file_template.create_file( - server=self.server_test_1, server_dir="/var/tmp/custom" - ) - - # Assert that the file's code matches the file template's code - self.assertEqual( - file.code, - file_template.code, - msg="The file's code does not match the file template's code.", - ) - - # Assert that the secret key is associated with the file - self.assertIn( - secret_python_key, - file.secret_ids, - msg="The secret key is not associated with the file.", - ) - - # Update the file's code to remove the secret reference - file.code = "Only text" - - self.assertFalse( - file.secret_ids, - msg=( - "The secret_ids field should be empty after " - "removing the secret reference from file." - ), - ) - - def test_file_with_sensitive_variable(self): - """ - Test case to verify that user has access to use file with sensitive variables. - """ - # Create file with sensitive variable - file = self.File.create( - { - "source": "tower", - "name": "test.txt", - "server_id": self.server_test_1.id, - "code": "'IPv4 Address': {{ tower.server.ipv4 }}", - } - ) - # Remove user_bob from all cx_tower_server groups - self.remove_from_group( - self.user_bob, - [ - "cetmix_tower_server.group_user", - "cetmix_tower_server.group_manager", - "cetmix_tower_server.group_root", - ], - ) - # Add bob to user group - self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") - # Add bob as subscriber of the server to allow upload file - self.server_test_1.write({"user_ids": [(4, self.user_bob.id)]}) - # Upload file to server - self.assertTrue(file.server_response != "ok") - file.with_user(self.user_bob).action_push_to_server() - self.assertEqual(file.server_response, "ok") - - def test_sanitize_values(self): - """ - Test case to verify that the sanitize_values method works correctly. - """ - # 1. Root directory - values = self.File._sanitize_values({"server_dir": "/"}) - self.assertEqual(values["server_dir"], "/") - - # 2. Trailing slash - values = self.File._sanitize_values({"server_dir": "/var/tmp/"}) - self.assertEqual(values["server_dir"], "/var/tmp") - - # 3. Trailing whitespace - values = self.File._sanitize_values({"server_dir": "/var/tmp/ "}) - self.assertEqual(values["server_dir"], "/var/tmp") - - # 4. Leading whitespace - values = self.File._sanitize_values({"server_dir": " /var/tmp/"}) - self.assertEqual(values["server_dir"], "/var/tmp") - - # 5. Leading and trailing whitespace - values = self.File._sanitize_values({"server_dir": " /var/tmp/ "}) - self.assertEqual(values["server_dir"], "/var/tmp") - - # 6. Leading and trailing whitespace just one slash - values = self.File._sanitize_values({"server_dir": " / "}) - self.assertEqual(values["server_dir"], "/") diff --git a/addons/cetmix_tower_server/tests/test_file_template.py b/addons/cetmix_tower_server/tests/test_file_template.py deleted file mode 100644 index 7b4275a..0000000 --- a/addons/cetmix_tower_server/tests/test_file_template.py +++ /dev/null @@ -1,234 +0,0 @@ -from odoo.exceptions import AccessError - -from .common import TestTowerCommon - - -class TestCxTowerFileTemplateAccessRules(TestTowerCommon): - def test_user_no_access(self): - """ - Verify that a user in the User group has no access - to any file template records. - """ - # Create a file template record as admin. - record = self.FileTemplate.create( - { - "name": "Template 1", - "file_name": "template1.txt", - "code": "Sample code", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - } - ) - # As the user, search for the record – expect no records. - with self.assertRaises(AccessError): - self.FileTemplate.with_user(self.user).search([("id", "=", record.id)]) - - # Attempting to create a record as a user should raise an AccessError. - with self.assertRaises(AccessError): - self.FileTemplate.with_user(self.user).create( - { - "name": "Template 2", - "file_name": "user_template.txt", - "code": "User code", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - } - ) - - def test_manager_read_access(self): - """ - Verify that a manager can read file template records - if he is not in user_ids or manager_ids. - """ - # Create a record with the manager in manager_ids. - rec1 = self.FileTemplate.create( - { - "name": "Template 1", - "file_name": "template_manager.txt", - "code": "Manager code", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - # Create a record with the manager in user_ids. - rec2 = self.FileTemplate.create( - { - "name": "Template 2", - "file_name": "template_user.txt", - "code": "User code", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - "user_ids": [(6, 0, [self.manager.id])], - } - ) - # Create a record that does not include the manager. - rec3 = self.FileTemplate.create( - { - "name": "Template 3", - "file_name": "template_none.txt", - "code": "None code", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - } - ) - recs = self.FileTemplate.with_user(self.manager).search([]) - self.assertIn(rec1, recs, "Manager should read records if in manager_ids.") - self.assertIn(rec2, recs, "Manager should read records if in user_ids.") - self.assertNotIn( - rec3, - recs, - "Manager should not see records if not in user_ids or manager_ids.", - ) - - def test_manager_write_create_access(self): - """ - Verify that a manager can write and create file template records - only if he is in manager_ids. - """ - # Create a record with manager_ids including the manager. - rec = self.FileTemplate.create( - { - "name": "Template 1", - "file_name": "template_for_update.txt", - "code": "Initial code", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - # Manager should be able to update the record. - try: - rec.with_user(self.manager).write({"file_name": "template_updated.txt"}) - except AccessError: - self.fail( - "Manager should be able to update the record when in manager_ids." - ) - self.assertEqual(rec.with_user(self.manager).file_name, "template_updated.txt") - - # Manager should be able to create a record if included in manager_ids. - rec2 = self.FileTemplate.with_user(self.manager).create( - { - "name": "Template 2", - "file_name": "manager_created_template.txt", - "code": "Manager created", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - self.assertTrue( - rec2, - "Manager should be able to create a record when included in manager_ids.", - ) - - # Creating a record without including the manager should raise an AccessError. - with self.assertRaises(AccessError): - self.FileTemplate.with_user(self.manager).create( - { - "name": "Template 3", - "file_name": "invalid_template.txt", - "code": "Invalid", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - "manager_ids": [(5, 0, 0)], - } - ) - - def test_manager_unlink_access(self): - """ - Verify that a manager can delete a file template record only if - he is in manager_ids and is the creator. - """ - # Scenario 1: Record created by the manager. - rec = self.FileTemplate.with_user(self.manager).create( - { - "name": "Template 1", - "file_name": "template_to_delete.txt", - "code": "Code to delete", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - try: - rec.with_user(self.manager).unlink() - except AccessError: - self.fail( - "Manager should be able to delete a record " - "he created when in manager_ids." - ) - # Scenario 2: Record created by admin (or another user) - # even though manager_ids includes the manager. - rec2 = self.FileTemplate.create( - { - "name": "Template 2", - "file_name": "template_not_deletable.txt", - "code": "Admin created code", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - with self.assertRaises(AccessError): - rec2.with_user(self.manager).unlink() - - def test_root_unrestricted_access(self): - """ - Verify that a user in the Root group has unlimited access - to all file template records. - """ - # Create a file template record (with no particular restrictions). - rec = self.FileTemplate.create( - { - "name": "Template 1", - "file_name": "template_for_root.txt", - "code": "Root code", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - } - ) - # As the root user, the record should be visible. - recs = self.FileTemplate.with_user(self.root).search([("id", "=", rec.id)]) - self.assertTrue(recs, "Root should see the record regardless of restrictions.") - # Root should be able to update the record. - try: - rec.with_user(self.root).write({"file_name": "root_updated_template.txt"}) - except AccessError: - self.fail("Root should be able to update the record without restrictions.") - self.assertEqual( - rec.with_user(self.root).file_name, "root_updated_template.txt" - ) - # Root should be able to create a record. - rec2 = self.FileTemplate.with_user(self.root).create( - { - "name": "Template 2", - "file_name": "root_created_template.txt", - "code": "Created by root", - "server_dir": "/templates", - "file_type": "text", - "source": "tower", - } - ) - self.assertTrue( - rec2, "Root should be able to create a record without restrictions." - ) - # Root should be able to delete a record. - rec2.with_user(self.root).unlink() - recs_after = self.FileTemplate.with_user(self.root).search( - [("id", "=", rec2.id)] - ) - self.assertFalse( - recs_after, "Root should be able to delete the record without restrictions." - ) diff --git a/addons/cetmix_tower_server/tests/test_jet.py b/addons/cetmix_tower_server/tests/test_jet.py deleted file mode 100644 index f902399..0000000 --- a/addons/cetmix_tower_server/tests/test_jet.py +++ /dev/null @@ -1,1750 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from unittest.mock import patch - -from odoo import fields -from odoo.exceptions import AccessError, ValidationError -from odoo.tools import mute_logger - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJet(TestTowerJetsCommon): - """ - Test the Jet model functionality - """ - - # All jet-related test data is now inherited from TestTowerJetsCommon - - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # _on_is_available Tests - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - def test_on_is_available_explicit_request_marked_processing_before_dispatch(self): - """ - Regression: explicit request must be attached to the jet and set to - processing before transition dispatch starts. - - We patch _bring_to_state (the actual dispatch) rather than - _serve_jet_request so that _serve_jet_request runs for real and its - side-effects (served_jet_request_id, request.state) are observable. - A side_effect captures both values at the exact moment dispatch is - triggered, proving ordering rather than just eventual state. - """ - self.jet_test.write( - {"state_id": self.state_initial.id, "target_state_id": False} - ) - # Isolate the scenario: keep only the request created in this test. - preexisting_new_requests = self.env["cx.tower.jet.request"].search( - [("jet_id", "=", self.jet_test.id), ("state", "=", "new")] - ) - if preexisting_new_requests: - preexisting_new_requests.unlink() - request = self.env["cx.tower.jet.request"].create( - { - "server_id": self.server_test_1.id, - "jet_id": self.jet_test.id, - "jet_template_id": self.jet_test.jet_template_id.id, - "state_requested_id": self.state_running.id, - "state": "new", - } - ) - - # Capture the observable state of jet + request at dispatch time. - observed = {} - - def capture(jet_self, target_state): - jet_self.invalidate_recordset(["served_jet_request_id"]) - request.invalidate_recordset(["state"]) - observed["served_request_id"] = jet_self.served_jet_request_id.id - observed["request_state"] = request.state - - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._bring_to_state", - autospec=True, - side_effect=capture, - ): - self.jet_test._on_is_available() - - self.assertTrue( - observed, - "_bring_to_state must have been called; check that the request " - "targets a different state than the jet's current state", - ) - self.assertEqual( - observed["served_request_id"], - request.id, - "Request must be saved to served_jet_request_id before dispatch", - ) - self.assertEqual( - observed["request_state"], - "processing", - "Request must be set to 'processing' before _bring_to_state is called", - ) - - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # _compute_available_actions Tests - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - def test_compute_available_actions_no_state(self): - """ - Test _compute_available_actions when jet has no current state - """ - # Jet has template but no state - self.jet_test.state_id = False - - # action_available_ids should include only the create action - self.assertEqual( - len(self.jet_test.action_available_ids), - 1, - "Available actions should include create action when jet has no state", - ) - self.assertEqual( - {action.id for action in self.jet_test.action_available_ids}, - {self.action_create.id}, - "Available action should be the create action", - ) - - def test_compute_available_actions_with_state_running(self): - """ - Test _compute_available_actions when jet has state running. - Create action is not available (no state_from_id); destroy and - transition actions are available. - """ - self.jet_test.state_id = self.state_running - - expected_actions = ( - self.action_running_to_stopped - | self.action_running_to_error - | self.action_destroy - ) - actual_ids = {action.id for action in self.jet_test.action_available_ids} - - self.assertEqual( - len(actual_ids), - 3, - "Should have 3 available actions from running state", - ) - self.assertNotIn( - self.action_create.id, - actual_ids, - "Create action should not be available when jet has state", - ) - self.assertIn( - self.action_destroy.id, - actual_ids, - "Destroy action should be available", - ) - self.assertEqual( - actual_ids, - {action.id for action in expected_actions}, - "Should have exact set: running_to_stopped, running_to_error, destroy", - ) - - def test_compute_available_actions_complex_scenario(self): - """ - Test _compute_available_actions with complex scenario - """ - # Use common actions from setup - - # Test different states - test_cases = [ - (self.state_initial, [self.action_initial_to_running]), - ( - self.state_running, - [ - self.action_running_to_stopped, - self.action_running_to_error, - self.action_destroy, - ], - ), - (self.state_stopped, [self.action_stopped_to_running]), - (self.state_error, [self.action_error_to_running]), - ] - - for state, expected_actions in test_cases: - self.jet_test.state_id = state - actual_actions = self.jet_test.action_available_ids - expected_actions_set = {action.id for action in expected_actions} - actual_actions_set = {action.id for action in actual_actions} - - self.assertEqual( - actual_actions_set, - expected_actions_set, - f"State {state.name} should have correct available actions", - ) - - def test_compute_available_actions_dependencies(self): - """ - Test that _compute_available_actions has correct dependencies - """ - # Use existing action from common setup - action = self.action_running_to_stopped - - # Set initial state - self.jet_test.state_id = self.state_running - # Should have all actions from running state - expected_actions = ( - self.action_running_to_stopped - | self.action_running_to_error - | self.action_destroy - ) - self.assertEqual( - {action.id for action in self.jet_test.action_available_ids}, - {action.id for action in expected_actions}, - "Should have all actions from running state initially", - ) - - # Change action's state_from_id (this should trigger recomputation) - action.state_from_id = self.state_stopped - - # Jet should no longer have this specific action available - # but should still have other actions from running state - expected_remaining_actions = self.action_running_to_error | self.action_destroy - self.assertEqual( - {action.id for action in self.jet_test.action_available_ids}, - {action.id for action in expected_remaining_actions}, - "Should have remaining actions after changing one action's state_from_id", - ) - - # Change jet state to match action's new state_from_id - self.jet_test.state_id = self.state_stopped - - # Now the modified action should be available again, - # plus any other actions from stopped state - expected_actions = action | self.action_stopped_to_running - self.assertEqual( - {action.id for action in self.jet_test.action_available_ids}, - {action.id for action in expected_actions}, - "Should have the modified action plus other actions from stopped state", - ) - - def test_compute_available_actions_cross_template_isolation(self): - """ - Test that jets only see actions from their own template - """ - # Create action for Odoo template - odoo_action = self.JetAction.create( - { - "name": "Odoo Action", - "reference": "odoo_action", - "jet_template_id": self.jet_template_odoo.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Create action for WordPress template - wp_action = self.JetAction.create( - { - "name": "WordPress Action", - "reference": "wordpress_action", - "jet_template_id": self.jet_template_wordpress.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Set both jets to running state - self.jet_odoo.state_id = self.state_running - self.jet_wordpress.state_id = self.state_running - - # Each jet should only see its own template's actions - self.assertEqual( - {action.id for action in self.jet_odoo.action_available_ids}, - {odoo_action.id}, - "Odoo jet should only see Odoo actions", - ) - self.assertEqual( - {action.id for action in self.jet_wordpress.action_available_ids}, - {wp_action.id}, - "WordPress jet should only see WordPress actions", - ) - - # Odoo jet should not see WordPress actions - self.assertNotIn( - wp_action.id, - {action.id for action in self.jet_odoo.action_available_ids}, - "Odoo jet should not see WordPress actions", - ) - # WordPress jet should not see Odoo actions - self.assertNotIn( - odoo_action.id, - {action.id for action in self.jet_wordpress.action_available_ids}, - "WordPress jet should not see Odoo actions", - ) - - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # Complex Template Hierarchy Tests - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - def test_jet_template_domain_computation(self): - """ - Test _compute_jet_template_domain method - """ - # Test with server set - jet_with_server = self.Jet.create( - { - "name": "Jet With Server", - "reference": "jet_with_server", - "jet_template_id": self.jet_template_test.id, - "server_id": self.server_test_1.id, - } - ) - domain = jet_with_server.jet_template_domain - expected_domain = [("server_ids", "in", [self.server_test_1.id])] - self.assertEqual(domain, expected_domain, "Domain should include server filter") - - # Test domain computation with a different server - server_test_2 = self.Server.create( - { - "name": "Test Server 2", - "ip_v4_address": "192.168.1.2", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "host_key": "test_key_2", - "os_id": self.os_debian_10.id, - } - ) - jet_with_different_server = self.Jet.create( - { - "name": "Jet With Different Server", - "reference": "jet_with_different_server", - "jet_template_id": self.jet_template_test.id, - "server_id": server_test_2.id, - } - ) - domain = jet_with_different_server.jet_template_domain - expected_domain = [("server_ids", "in", [server_test_2.id])] - self.assertEqual( - domain, - expected_domain, - "Domain should include server filter for different server", - ) - - # Test the domain computation method directly to verify the else branch - # Create a temporary jet object to test the method without saving - temp_jet = self.Jet.new( - { - "name": "Temp Jet", - "jet_template_id": self.jet_template_test.id, - "server_id": False, - } - ) - temp_jet._compute_jet_template_domain() - self.assertEqual( - temp_jet.jet_template_domain, - [], - "Domain should be empty when server_id is False", - ) - - def test_jet_requires_ids_computation(self): - """ - Test _compute_jet_requires_ids method with complex dependencies - """ - # Test Odoo jet dependencies - odoo_deps = self.jet_odoo.jet_requires_ids - self.assertEqual( - len(odoo_deps), 2, "Odoo jet should have 2 direct dependencies" - ) - - # Check that dependencies are for postgres and nginx - dep_template_ids = odoo_deps.mapped( - "jet_template_dependency_id.template_required_id.id" - ) - expected_ids = {self.jet_template_postgres.id, self.jet_template_nginx.id} - self.assertEqual( - set(dep_template_ids), expected_ids, "Should depend on postgres and nginx" - ) - - # Test WooCommerce jet dependencies - # (should include both Odoo and WordPress deps) - woocommerce_deps = self.jet_woocommerce.jet_requires_ids - self.assertEqual( - len(woocommerce_deps), - 2, - "WooCommerce jet should have 2 direct dependencies", - ) - - # Check that dependencies are for wordpress and odoo - dep_template_ids = woocommerce_deps.mapped( - "jet_template_dependency_id.template_required_id.id" - ) - expected_ids = {self.jet_template_wordpress.id, self.jet_template_odoo.id} - self.assertEqual( - set(dep_template_ids), expected_ids, "Should depend on wordpress and odoo" - ) - - def test_jet_limit_per_server_same_server_rejected(self): - """Constraint rejects creating more jets than template limit per server.""" - template = self.JetTemplate.create( - { - "name": "Template With Limit", - "reference": "template_with_limit", - "limit_per_server": 1, - } - ) - self.Jet.create( - { - "name": "Limited Jet 1", - "reference": "limited_jet_1", - "jet_template_id": template.id, - "server_id": self.server_test_1.id, - } - ) - - with self.assertRaisesRegex(ValidationError, "Jet limit per server reached"): - self.Jet.create( - { - "name": "Limited Jet 2", - "reference": "limited_jet_2", - "jet_template_id": template.id, - "server_id": self.server_test_1.id, - } - ) - - def test_jet_limit_per_server_different_servers_allowed(self): - """ - Constraint allows same template on different servers - but within per-server limit. - """ - template = self.JetTemplate.create( - { - "name": "Template With Per-Server Limit", - "reference": "template_with_per_server_limit", - "limit_per_server": 1, - } - ) - server_test_2 = self.Server.create( - { - "name": "Jet Limit Test Server 2", - "ip_v4_address": "192.168.1.22", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "host_key": "jet_limit_test_server_2_key", - "os_id": self.os_debian_10.id, - } - ) - - jet_on_server_1 = self.Jet.create( - { - "name": "Limited Jet Server 1", - "reference": "limited_jet_server_1", - "jet_template_id": template.id, - "server_id": self.server_test_1.id, - } - ) - jet_on_server_2 = self.Jet.create( - { - "name": "Limited Jet Server 2", - "reference": "limited_jet_server_2", - "jet_template_id": template.id, - "server_id": server_test_2.id, - } - ) - - self.assertTrue( - jet_on_server_1.exists(), "Jet on first server should be created" - ) - self.assertTrue( - jet_on_server_2.exists(), "Jet on second server should be created" - ) - - def test_jet_requires_ids_template_change(self): - """ - Test _compute_jet_requires_ids for different templates - """ - # Create jets for different templates - jet_tower_core = self.Jet.create( - { - "name": "Tower Core Jet", - "reference": "tower_core_jet", - "jet_template_id": self.jet_template_tower_core.id, - "server_id": self.server_test_1.id, - } - ) - self.assertEqual( - len(jet_tower_core.jet_requires_ids), - 0, - "Tower core should have no dependencies", - ) - - jet_odoo = self.Jet.create( - { - "name": "Odoo Jet Test", - "reference": "odoo_jet_test", - "jet_template_id": self.jet_template_odoo.id, - "server_id": self.server_test_1.id, - } - ) - self.assertEqual( - len(jet_odoo.jet_requires_ids), 2, "Odoo should have 2 dependencies" - ) - - jet_woocommerce = self.Jet.create( - { - "name": "WooCommerce Jet Test", - "reference": "woocommerce_jet_test", - "jet_template_id": self.jet_template_woocommerce_odoo.id, - "server_id": self.server_test_1.id, - } - ) - self.assertEqual( - len(jet_woocommerce.jet_requires_ids), - 2, - "WooCommerce should have 2 dependencies", - ) - - def test_jet_requires_ids_dependency_removal(self): - """ - Test _compute_jet_requires_ids when template dependencies are removed - """ - # Create jet with Odoo template - jet_odoo = self.Jet.create( - { - "name": "Odoo Jet Test", - "reference": "odoo_jet_test", - "jet_template_id": self.jet_template_odoo.id, - "server_id": self.server_test_1.id, - } - ) - initial_deps = len(jet_odoo.jet_requires_ids) - self.assertEqual(initial_deps, 2, "Should have 2 dependencies initially") - - # Remove one dependency from template - postgres_dep = self.JetTemplateDependency.search( - [ - ("template_id", "=", self.jet_template_odoo.id), - ("template_required_id", "=", self.jet_template_postgres.id), - ] - ) - postgres_dep.unlink() - - # Jet dependencies should be updated - self.assertEqual( - len(jet_odoo.jet_requires_ids), 1, "Should have 1 dependency after removal" - ) - remaining_dep = jet_odoo.jet_requires_ids[0] - self.assertEqual( - remaining_dep.jet_template_dependency_id.template_required_id, - self.jet_template_nginx, - "Remaining dependency should be nginx", - ) - - def test_jet_requires_ids_dependency_addition(self): - """ - Test _compute_jet_requires_ids when template dependencies are added - """ - # Create jet with tower core (no dependencies) - jet_tower_core = self.Jet.create( - { - "name": "Tower Core Jet", - "reference": "tower_core_jet", - "jet_template_id": self.jet_template_tower_core.id, - "server_id": self.server_test_1.id, - } - ) - self.assertEqual( - len(jet_tower_core.jet_requires_ids), - 0, - "Should have no dependencies initially", - ) - - # Add dependency to tower core - # (use a template that won't create circular dependency) - new_dep = self.JetTemplateDependency.create( - { - "template_id": self.jet_template_tower_core.id, - "template_required_id": self.jet_template_test.id, - "state_required_id": self.state_running.id, - } - ) - - # Jet dependencies should be updated - self.assertEqual( - len(jet_tower_core.jet_requires_ids), - 1, - "Should have 1 dependency after addition", - ) - added_dep = jet_tower_core.jet_requires_ids[0] - self.assertEqual( - added_dep.jet_template_dependency_id, - new_dep, - "Added dependency should match the new dependency", - ) - - def test_jet_requires_ids_multiple_jets_same_template(self): - """ - Test _compute_jet_requires_ids with multiple jets using same template - """ - # Create another Odoo jet - jet_odoo_2 = self.Jet.create( - { - "name": "Odoo Jet 2", - "reference": "odoo_jet_2", - "jet_template_id": self.jet_template_odoo.id, - "server_id": self.server_test_1.id, - } - ) - - # Both jets should have same dependencies - deps_1 = self.jet_odoo.jet_requires_ids - deps_2 = jet_odoo_2.jet_requires_ids - - self.assertEqual( - len(deps_1), - len(deps_2), - "Both jets should have same number of dependencies", - ) - - # Check that dependencies are the same - deps_1_template_ids = deps_1.mapped( - "jet_template_dependency_id.template_required_id.id" - ) - deps_2_template_ids = deps_2.mapped( - "jet_template_dependency_id.template_required_id.id" - ) - self.assertEqual( - set(deps_1_template_ids), - set(deps_2_template_ids), - "Both jets should have same dependency templates", - ) - - def test_jet_requires_ids_consistency_with_template(self): - """ - Test that jet dependencies are consistent with template dependencies - """ - # Test with different templates - templates_to_test = [ - (self.jet_template_tower_core, 0), - (self.jet_template_docker, 1), - (self.jet_template_nginx, 1), - (self.jet_template_postgres, 1), - (self.jet_template_mariadb, 1), - (self.jet_template_odoo, 2), - (self.jet_template_wordpress, 2), - (self.jet_template_woocommerce_odoo, 2), - ] - - for template, expected_dep_count in templates_to_test: - # Create a jet with this template - test_jet = self.Jet.create( - { - "name": f"Test Jet for {template.name}", - "reference": f"test_jet_{template.reference}", - "jet_template_id": template.id, - "server_id": self.server_test_1.id, - } - ) - - # Check dependency count - actual_dep_count = len(test_jet.jet_requires_ids) - self.assertEqual( - actual_dep_count, - expected_dep_count, - f"{template.name} should have {expected_dep_count} " - f"dependencies, got {actual_dep_count}", - ) - - # Verify that all jet dependencies correspond to template dependencies - template_deps = template.template_requires_ids - jet_deps = test_jet.jet_requires_ids - - if template_deps: - self.assertEqual( - len(jet_deps), - len(template_deps), - "Jet dependencies count should match" - f" template dependencies for {template.name}", - ) - - # Check that each jet dependency corresponds to a template dependency - jet_dep_template_ids = jet_deps.mapped("jet_template_dependency_id.id") - template_dep_ids = template_deps.ids - self.assertEqual( - set(jet_dep_template_ids), - set(template_dep_ids), - "Jet dependencies should match template" - f" dependencies for {template.name}", - ) - - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # bring_to_state Tests - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - def test_bring_to_state_success_user_level(self): - """ - Test bring_to_state succeeds when user has sufficient access level. - User (level 1) can access state with level 1. - """ - # Use existing state and set it to User access level (1) - self.state_running.access_level = "1" - self.state_running.invalidate_recordset(["access_level"]) - - # Ensure user has access to the jet - self.jet_test.write({"user_ids": [(4, self.user.id)]}) - self.server_test_1.write({"user_ids": [(4, self.user.id)]}) - - # Set jet to initial state - self.jet_test.write({"state_id": self.state_initial.id}) - self.jet_test.invalidate_recordset(["state_id"]) - - # User should be able to bring jet to user-level state - self.jet_test.with_user(self.user).with_context( - cetmix_tower_no_commit=True - ).bring_to_state("test_running") - self.assertEqual( - self.jet_test.state_id, - self.state_running, - "Jet should be brought to user-level state by user", - ) - - def test_bring_to_state_success_manager_level(self): - """ - Test bring_to_state succeeds when manager has sufficient access level. - Manager (level 2) can access state with level 2. - """ - # Use existing state and set it to Manager access level (2) - self.state_stopped.access_level = "2" - self.state_stopped.invalidate_recordset(["access_level"]) - - # Ensure manager has access to the jet - self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) - self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) - - # Set jet to running state (which has action to stopped) - self.jet_test.write({"state_id": self.state_running.id}) - self.jet_test.invalidate_recordset(["state_id"]) - - # Manager should be able to bring jet to manager-level state - self.jet_test.with_user(self.manager).with_context( - cetmix_tower_no_commit=True - ).bring_to_state("test_stopped") - self.assertEqual( - self.jet_test.state_id, - self.state_stopped, - "Jet should be brought to manager-level state by manager", - ) - - def test_bring_to_state_success_root_level(self): - """ - Test bring_to_state succeeds when root has sufficient access level. - Root (level 3) can access state with level 3. - """ - # Use existing state and set it to Root access level (3) - self.state_error.access_level = "3" - self.state_error.invalidate_recordset(["access_level"]) - - # Root has full access, but ensure access for consistency - self.jet_test.write({"manager_ids": [(4, self.root.id)]}) - self.server_test_1.write({"manager_ids": [(4, self.root.id)]}) - - # Set jet to running state (which has action to error) - self.jet_test.write({"state_id": self.state_running.id}) - self.jet_test.invalidate_recordset(["state_id"]) - - # Root should be able to bring jet to root-level state - self.jet_test.with_user(self.root).with_context( - cetmix_tower_no_commit=True - ).bring_to_state("test_error") - self.assertEqual( - self.jet_test.state_id, - self.state_error, - "Jet should be brought to root-level state by root", - ) - - def test_bring_to_state_access_error_user_to_manager(self): - """ - Test bring_to_state raises AccessError when user (level 1) - tries to access manager-level state (level 2). - """ - # Use existing state and set it to Manager access level (2) - self.state_stopped.access_level = "2" - self.state_stopped.invalidate_recordset(["access_level"]) - - # Ensure user has access to the jet (for the access check to work) - self.jet_test.write({"user_ids": [(4, self.user.id)]}) - self.server_test_1.write({"user_ids": [(4, self.user.id)]}) - - # Set jet to running state (which has action to stopped) - self.jet_test.write({"state_id": self.state_running.id}) - self.jet_test.invalidate_recordset(["state_id"]) - - # User should not be able to bring jet to manager-level state - with self.assertRaises(AccessError) as context: - self.jet_test.with_user(self.user).with_context( - cetmix_tower_no_commit=True - ).bring_to_state("test_stopped") - - self.assertIn( - "You are not allowed to set the", - str(context.exception), - "Should raise AccessError with appropriate message", - ) - self.assertIn( - self.state_stopped.name, - str(context.exception), - "Error message should include state name", - ) - - def test_bring_to_state_access_error_user_to_root(self): - """ - Test bring_to_state raises AccessError when user (level 1) - tries to access root-level state (level 3). - """ - # Use existing state and set it to Root access level (3) - self.state_error.access_level = "3" - self.state_error.invalidate_recordset(["access_level"]) - - # Ensure user has access to the jet (for the access check to work) - self.jet_test.write({"user_ids": [(4, self.user.id)]}) - self.server_test_1.write({"user_ids": [(4, self.user.id)]}) - - # Set jet to running state (which has action to error) - self.jet_test.write({"state_id": self.state_running.id}) - self.jet_test.invalidate_recordset(["state_id"]) - - # User should not be able to bring jet to root-level state - with self.assertRaises(AccessError) as context: - self.jet_test.with_user(self.user).with_context( - cetmix_tower_no_commit=True - ).bring_to_state("test_error") - - self.assertIn( - "You are not allowed to set the", - str(context.exception), - "Should raise AccessError with appropriate message", - ) - self.assertIn( - self.state_error.name, - str(context.exception), - "Error message should include state name", - ) - - def test_bring_to_state_access_error_manager_to_root(self): - """ - Test bring_to_state raises AccessError when manager (level 2) - tries to access root-level state (level 3). - """ - # Use existing state and set it to Root access level (3) - self.state_error.access_level = "3" - self.state_error.invalidate_recordset(["access_level"]) - - # Ensure manager has access to the jet (for the access check to work) - self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) - self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) - - # Set jet to running state (which has action to error) - self.jet_test.write({"state_id": self.state_running.id}) - self.jet_test.invalidate_recordset(["state_id"]) - - # Manager should not be able to bring jet to root-level state - with self.assertRaises(AccessError) as context: - self.jet_test.with_user(self.manager).with_context( - cetmix_tower_no_commit=True - ).bring_to_state("test_error") - - self.assertIn( - "You are not allowed to set the", - str(context.exception), - "Should raise AccessError with appropriate message", - ) - self.assertIn( - self.state_error.name, - str(context.exception), - "Error message should include state name", - ) - - def test_bring_to_state_manager_can_access_user_level(self): - """ - Test bring_to_state succeeds when manager (level 2) who IS in manager_ids - accesses user-level state (level 1). - Higher access levels can access lower level states. - """ - # Use existing state and set it to User access level (1) - self.state_running.access_level = "1" - self.state_running.invalidate_recordset(["access_level"]) - - # Ensure manager has access to the jet - # Manager IS in manager_ids, so they keep their manager access level (2) - self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) - self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) - - # Set jet to initial state - self.jet_test.write({"state_id": self.state_initial.id}) - self.jet_test.invalidate_recordset(["state_id"]) - - # Manager should be able to bring jet to user-level state - self.jet_test.with_user(self.manager).with_context( - cetmix_tower_no_commit=True - ).bring_to_state("test_running") - self.assertEqual( - self.jet_test.state_id, - self.state_running, - "Manager should be able to access user-level state", - ) - - def test_bring_to_state_manager_not_in_manager_ids_treated_as_user(self): - """ - Test bring_to_state treats manager (level 2) who is NOT in manager_ids - as user (level 1). - Manager should be able to set user-level state but not manager-level state. - """ - # Use existing state and set it to User access level (1) - self.state_running.access_level = "1" - self.state_running.invalidate_recordset(["access_level"]) - - # Ensure manager has access to the jet via user_ids but NOT via manager_ids - self.jet_test.write({"user_ids": [(4, self.manager.id)]}) - self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) - # Explicitly ensure manager is NOT in manager_ids - self.jet_test.write({"manager_ids": [(5, 0, 0)]}) - - # Set jet to initial state - self.jet_test.write({"state_id": self.state_initial.id}) - self.jet_test.invalidate_recordset(["state_id"]) - - # Manager (treated as user) should be able to bring jet to user-level state - self.jet_test.with_user(self.manager).with_context( - cetmix_tower_no_commit=True - ).bring_to_state("test_running") - self.assertEqual( - self.jet_test.state_id, - self.state_running, - "Manager not in manager_ids should be able to access user-level state", - ) - - def test_bring_to_state_manager_not_in_manager_ids_cannot_access_manager_level( - self - ): - """ - Test bring_to_state raises AccessError when manager (level 2) who is NOT - in manager_ids tries to access manager-level state (level 2). - Manager should be treated as user (level 1) and cannot access level 2. - """ - # Use existing state and set it to Manager access level (2) - self.state_stopped.access_level = "2" - self.state_stopped.invalidate_recordset(["access_level"]) - - # Ensure manager has access to the jet via user_ids but NOT via manager_ids - self.jet_test.write({"user_ids": [(4, self.manager.id)]}) - self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) - # Explicitly ensure manager is NOT in manager_ids - self.jet_test.write({"manager_ids": [(5, 0, 0)]}) - - # Set jet to running state (which has action to stopped) - self.jet_test.write({"state_id": self.state_running.id}) - self.jet_test.invalidate_recordset(["state_id"]) - - # Manager (treated as user) should not be able to bring jet - # to manager-level state - with self.assertRaises(AccessError) as context: - self.jet_test.with_user(self.manager).with_context( - cetmix_tower_no_commit=True - ).bring_to_state("test_stopped") - - self.assertIn( - "You are not allowed to set the", - str(context.exception), - "Should raise AccessError with appropriate message", - ) - self.assertIn( - self.state_stopped.name, - str(context.exception), - "Error message should include state name", - ) - - def test_bring_to_state_root_can_access_manager_level(self): - """ - Test bring_to_state succeeds when root (level 3) - accesses manager-level state (level 2). - Higher access levels can access lower level states. - """ - # Use existing state and set it to Manager access level (2) - self.state_stopped.access_level = "2" - self.state_stopped.invalidate_recordset(["access_level"]) - - # Root has full access, but ensure access for consistency - self.jet_test.write({"manager_ids": [(4, self.root.id)]}) - self.server_test_1.write({"manager_ids": [(4, self.root.id)]}) - - # Set jet to running state (which has action to stopped) - self.jet_test.write({"state_id": self.state_running.id}) - self.jet_test.invalidate_recordset(["state_id"]) - - # Root should be able to bring jet to manager-level state - self.jet_test.with_user(self.root).with_context( - cetmix_tower_no_commit=True - ).bring_to_state("test_stopped") - self.assertEqual( - self.jet_test.state_id, - self.state_stopped, - "Root should be able to access manager-level state", - ) - - def test_bring_to_state_invalid_reference(self): - """ - Test bring_to_state raises ValidationError when state reference is invalid. - """ - # Set jet to initial state - self.jet_test.state_id = self.state_initial - - # Should raise ValidationError for invalid state reference - with self.assertRaises(ValidationError) as context: - self.jet_test.with_context(cetmix_tower_no_commit=True).bring_to_state( - "invalid_state_reference" - ) - - self.assertIn( - "State 'invalid_state_reference' not found", - str(context.exception), - "Should raise ValidationError with appropriate message", - ) - self.assertIn( - self.jet_test.display_name, - str(context.exception), - "Error message should include jet display name", - ) - - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # _get_user_effective_access_level Tests - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - def test_get_user_effective_access_level_user(self): - """ - Test _get_user_effective_access_level returns "1" for user. - """ - # Ensure user has access to the jet - self.jet_test.write({"user_ids": [(4, self.user.id)]}) - - # User should have effective access level "1" - effective_level = self.jet_test.with_user( - self.user - )._get_user_effective_access_level() - self.assertEqual( - effective_level, - "1", - "User should have effective access level 1", - ) - - def test_get_user_effective_access_level_manager_in_manager_ids(self): - """ - Test _get_user_effective_access_level returns "2" for manager - who IS in manager_ids. - """ - # Ensure manager has access to the jet and IS in manager_ids - self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) - - # Manager in manager_ids should have effective access level "2" - effective_level = self.jet_test.with_user( - self.manager - )._get_user_effective_access_level() - self.assertEqual( - effective_level, - "2", - "Manager in manager_ids should have effective access level 2", - ) - - def test_get_user_effective_access_level_manager_not_in_manager_ids(self): - """ - Test _get_user_effective_access_level returns "1" for manager - who is NOT in manager_ids (downgraded to user level). - """ - # Ensure manager has access to the jet via user_ids but NOT via manager_ids - self.jet_test.write({"user_ids": [(4, self.manager.id)]}) - # Explicitly ensure manager is NOT in manager_ids - self.jet_test.write({"manager_ids": [(5, 0, 0)]}) - - # Manager not in manager_ids should have effective access level "1" - effective_level = self.jet_test.with_user( - self.manager - )._get_user_effective_access_level() - self.assertEqual( - effective_level, - "1", - "Manager not in manager_ids should have effective access level 1", - ) - - def test_get_user_effective_access_level_root(self): - """ - Test _get_user_effective_access_level returns "3" for root. - """ - # Root should have effective access level "3" regardless of manager_ids - effective_level = self.jet_test.with_user( - self.root - )._get_user_effective_access_level() - self.assertEqual( - effective_level, - "3", - "Root should have effective access level 3", - ) - - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # unlink Tests - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - def test_unlink_deletable_jet_with_files(self): - """ - Test unlink succeeds when jet is deletable and has files. - Files should be unlinked after the jet is deleted. - """ - # Create a deletable jet (deletable defaults to True) - jet = self._create_jet( - "Deletable Jet", - "deletable_jet", - ) - - # Create files linked to the jet - file1 = self.File.create( - { - "name": "test_file_1.txt", - "source": "tower", - "server_id": self.server_test_1.id, - "server_dir": "/tmp", - "jet_id": jet.id, - "file_type": "text", - } - ) - file2 = self.File.create( - { - "name": "test_file_2.txt", - "source": "tower", - "server_id": self.server_test_1.id, - "server_dir": "/tmp", - "jet_id": jet.id, - "file_type": "text", - } - ) - - # Verify files exist - self.assertEqual(len(jet.file_ids), 2, "Jet should have 2 files") - self.assertIn(file1, jet.file_ids, "File 1 should be linked to jet") - self.assertIn(file2, jet.file_ids, "File 2 should be linked to jet") - - # Store file IDs before deletion - file_ids = {file1.id, file2.id} - - # Unlink the jet - jet.unlink() - - # Verify jet is deleted - self.assertFalse(jet.exists(), "Jet should be deleted") - - # Verify files are also deleted - remaining_files = self.File.browse(list(file_ids)) - self.assertFalse( - remaining_files.exists(), - "Files should be unlinked after jet deletion", - ) - - def test_unlink_deletable_jet_without_files(self): - """ - Test unlink succeeds when jet is deletable but has no files. - """ - # Create a deletable jet without files (deletable defaults to True) - jet = self._create_jet( - "Deletable Jet No Files", - "deletable_jet_no_files", - ) - - # Verify jet has no files - self.assertEqual(len(jet.file_ids), 0, "Jet should have no files") - - # Unlink the jet - jet.unlink() - - # Verify jet is deleted - self.assertFalse(jet.exists(), "Jet should be deleted") - - def test_unlink_not_deletable_jet_raises_error(self): - """ - Test unlink raises ValidationError when jet is not deletable. - """ - # Create a non-deletable jet - jet = self._create_jet( - "Not Deletable Jet", - "not_deletable_jet", - ) - jet.write({"deletable": False}) - - # Attempt to unlink should raise ValidationError - with self.assertRaises(ValidationError) as context: - jet.unlink() - - # Verify error message contains jet display name - self.assertIn( - "cannot be deleted", - str(context.exception), - "Error message should mention deletion restriction", - ) - self.assertIn( - jet.display_name, - str(context.exception), - "Error message should include jet display name", - ) - - # Verify jet still exists - self.assertTrue(jet.exists(), "Jet should not be deleted") - - def test_unlink_multiple_jets_mixed_deletable(self): - """ - Test unlink with multiple jets where some are deletable and some are not. - Should raise ValidationError listing non-deletable jets. - """ - # Create deletable jet (deletable defaults to True) - deletable_jet = self._create_jet( - "Deletable Jet", - "deletable_jet_multi", - ) - - # Create non-deletable jet - not_deletable_jet = self._create_jet( - "Not Deletable Jet", - "not_deletable_jet_multi", - ) - not_deletable_jet.write({"deletable": False}) - - # Attempt to unlink both should raise ValidationError - jets = deletable_jet | not_deletable_jet - with self.assertRaises(ValidationError) as context: - jets.unlink() - - # Verify error message contains non-deletable jet display name - self.assertIn( - "cannot be deleted", - str(context.exception), - "Error message should mention deletion restriction", - ) - self.assertIn( - not_deletable_jet.display_name, - str(context.exception), - "Error message should include non-deletable jet display name", - ) - - # Verify both jets still exist - self.assertTrue(deletable_jet.exists(), "Deletable jet should not be deleted") - self.assertTrue( - not_deletable_jet.exists(), "Non-deletable jet should not be deleted" - ) - - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # create_waypoint Tests - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - def test_create_waypoint_with_record_template(self): - """ - Test create_waypoint with waypoint template record - """ - # Get the default name from the helper function - default_vals = self.jet_test._prepare_waypoint_values( - self.waypoint_template, name=None - ) - expected_default_name = default_vals["name"] - - # Create waypoint using template record - waypoint = self.jet_test.create_waypoint(self.waypoint_template) - - # Should return a waypoint record - self.assertTrue(waypoint, "Should return a waypoint record") - self.assertTrue(waypoint.exists(), "Waypoint should exist") - self.assertEqual( - waypoint.jet_id.id, - self.jet_test.id, - "Waypoint should belong to the jet", - ) - self.assertEqual( - waypoint.waypoint_template_id.id, - self.waypoint_template.id, - "Waypoint should use the correct template", - ) - self.assertEqual( - waypoint.name, - expected_default_name, - "Waypoint should have default name from helper function", - ) - # Reference is auto-generated, so just verify it exists and is not empty - self.assertTrue( - waypoint.reference, - "Waypoint should have an auto-generated reference", - ) - - def test_create_waypoint_with_string_reference(self): - """ - Test create_waypoint with waypoint template string reference - """ - # Use the template's reference (mandatory field, always present) - template_reference = self.waypoint_template.reference - - # Create waypoint using string reference - waypoint = self.jet_test.create_waypoint(template_reference) - - # Should return a waypoint record - self.assertTrue(waypoint, "Should return a waypoint record") - self.assertTrue(waypoint.exists(), "Waypoint should exist") - self.assertEqual( - waypoint.waypoint_template_id.id, - self.waypoint_template.id, - "Waypoint should use the correct template from reference", - ) - # Reference is auto-generated, so just verify it exists and is not empty - self.assertTrue( - waypoint.reference, - "Waypoint should have an auto-generated reference", - ) - - def test_create_waypoint_with_name(self): - """ - Test create_waypoint with custom name - """ - # Create waypoint with custom name - waypoint = self.jet_test.create_waypoint( - self.waypoint_template, name="Custom Waypoint Name" - ) - - # Should return a waypoint record with custom name - self.assertTrue(waypoint, "Should return a waypoint record") - self.assertEqual( - waypoint.name, - "Custom Waypoint Name", - "Waypoint should have the custom name", - ) - # Reference is auto-generated, so just verify it exists and is not empty - self.assertTrue( - waypoint.reference, - "Waypoint should have an auto-generated reference", - ) - - def test_create_waypoint_with_fly_here(self): - """ - Test create_waypoint with fly_here parameter - Note: fly_here should set is_destination=True, and after prepare() - the waypoint should automatically fly to if is_destination is True - """ - # Create waypoint with fly_here=True - waypoint = self.jet_test.create_waypoint(self.waypoint_template, fly_here=True) - - # Should return a waypoint record - self.assertTrue(waypoint, "Should return a waypoint record") - self.assertTrue(waypoint.exists(), "Waypoint should exist") - - # Verify that the waypoint flew to - # (state should be "current" in synchronous tests) - self.assertEqual( - waypoint.state, - "current", - "Waypoint should have flown to and " - "become current (tests run synchronously)", - ) - - # Verify jet's waypoint_id was updated - self.assertEqual( - self.jet_test.waypoint_id.id, - waypoint.id, - "Jet's waypoint_id should be updated to the flown-to waypoint", - ) - - @mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet") - def test_create_waypoint_jet_busy(self): - """ - Test create_waypoint when jet is busy (has target_state_id) - """ - # Set jet to busy state (has target_state_id) - self.jet_test.target_state_id = self.state_running - - # Try to create waypoint - with self.assertRaises(ValidationError): - self.jet_test.create_waypoint(self.waypoint_template) - - @mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet") - def test_create_waypoint_template_not_found(self): - """ - Test create_waypoint with non-existent template reference - """ - # Mute logger error for this test - with self.assertRaises(ValidationError): - self.jet_test.create_waypoint("non_existent_reference") - - @mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet") - def test_create_waypoint_template_wrong_jet_template(self): - """ - Test create_waypoint with template from different jet template - """ - # Create a waypoint template for a different jet template - other_jet_template = self.JetTemplate.create( - { - "name": "Other Jet Template", - "reference": "other_jet_template", - } - ) - other_waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Other Waypoint Template", - "jet_template_id": other_jet_template.id, - } - ) - - # Mute logger error for this test - with self.assertRaises(ValidationError): - # Try to create waypoint with template from different jet template - self.jet_test.create_waypoint(other_waypoint_template) - - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # Create a Waypoint command (flight plan) tests - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - def test_create_waypoint_command_success_fly_here_false(self): - """Create a Waypoint command from flight plan: waypoint created, log - finished by callback.""" - command = self.Command.create( - { - "name": "Create waypoint command", - "action": "create_waypoint", - "waypoint_template_id": self.waypoint_template.id, - "fly_here": False, - } - ) - plan = self.Plan.create({"name": "Plan create waypoint"}) - self.plan_line.create( - { - "plan_id": plan.id, - "sequence": 10, - "command_id": command.id, - } - ) - initial_waypoint_count = len(self.jet_test.waypoint_ids) - plan_log = self.server_test_1.sudo().run_flight_plan(plan, jet=self.jet_test) - self.assertTrue(plan_log, "Plan log should be created") - command_logs = plan_log.command_log_ids.filtered( - lambda log: log.command_id == command - ) - self.assertEqual( - len(command_logs), 1, "Exactly one command log for create_waypoint" - ) - log_record = command_logs[0] - self.assertTrue( - log_record.finish_date, - "Command log should be finished by waypoint callback", - ) - self.assertEqual( - log_record.command_status, - 0, - "Command should finish with success", - ) - self.assertEqual( - len(self.jet_test.waypoint_ids), - initial_waypoint_count + 1, - "One new waypoint should be created", - ) - new_waypoint = self.jet_test.waypoint_ids.filtered( - lambda w: w.created_from_command_log_id == log_record - ) - self.assertEqual(len(new_waypoint), 1, "One waypoint linked to command log") - new_waypoint = new_waypoint[0] - self.assertEqual( - new_waypoint.state, - "ready", - "Waypoint should be in ready state (fly_here=False)", - ) - self.assertEqual( - new_waypoint.created_from_command_log_id, - log_record, - "Waypoint should reference the command log", - ) - - def test_create_waypoint_command_success_fly_here_true(self): - """Create a Waypoint command with fly_here: waypoint becomes current.""" - command = self.Command.create( - { - "name": "Create waypoint fly here", - "action": "create_waypoint", - "waypoint_template_id": self.waypoint_template.id, - "fly_here": True, - } - ) - plan = self.Plan.create({"name": "Plan create waypoint fly here"}) - self.plan_line.create( - { - "plan_id": plan.id, - "sequence": 10, - "command_id": command.id, - } - ) - plan_log = self.server_test_1.sudo().run_flight_plan(plan, jet=self.jet_test) - command_logs = plan_log.command_log_ids.filtered( - lambda log: log.command_id == command - ) - log_record = command_logs[0] - self.assertTrue(log_record.finish_date, "Command log should be finished") - self.assertEqual(log_record.command_status, 0, "Command should succeed") - waypoints_with_log = self.jet_test.waypoint_ids.filtered( - lambda w: w.created_from_command_log_id == log_record - ) - self.assertEqual( - len(waypoints_with_log), - 1, - "One waypoint created from command", - ) - self.assertEqual( - waypoints_with_log.state, - "current", - "Waypoint should be current when fly_here=True", - ) - self.assertEqual( - self.jet_test.waypoint_id, - waypoints_with_log, - "Jet waypoint_id should point to the new waypoint", - ) - - def test_create_waypoint_command_no_jet(self): - """Create a Waypoint command run without jet: command log finished - with JET_NOT_FOUND.""" - from ..models.constants import JET_NOT_FOUND - - command = self.Command.create( - { - "name": "Create waypoint no jet", - "action": "create_waypoint", - "waypoint_template_id": self.waypoint_template.id, - "fly_here": False, - } - ) - plan = self.Plan.create({"name": "Plan no jet"}) - self.plan_line.create( - {"plan_id": plan.id, "sequence": 10, "command_id": command.id} - ) - plan_log = self.server_test_1.sudo().run_flight_plan(plan) - command_logs = plan_log.command_log_ids.filtered( - lambda log: log.command_id == command - ) - self.assertEqual(len(command_logs), 1) - self.assertEqual( - command_logs.command_status, - JET_NOT_FOUND, - "Should finish with JET_NOT_FOUND when no jet in plan", - ) - self.assertTrue(command_logs.finish_date) - - def test_create_waypoint_command_no_template(self): - """Create a Waypoint command without waypoint template: - WAYPOINT_TEMPLATE_NOT_FOUND.""" - from ..models.constants import WAYPOINT_TEMPLATE_NOT_FOUND - - command = self.Command.create( - { - "name": "Create waypoint no template", - "action": "create_waypoint", - "fly_here": False, - } - ) - plan = self.Plan.create({"name": "Plan no template"}) - self.plan_line.create( - {"plan_id": plan.id, "sequence": 10, "command_id": command.id} - ) - plan_log = self.server_test_1.sudo().run_flight_plan(plan, jet=self.jet_test) - command_logs = plan_log.command_log_ids.filtered( - lambda log: log.command_id == command - ) - self.assertEqual(len(command_logs), 1) - self.assertEqual( - command_logs.command_status, - WAYPOINT_TEMPLATE_NOT_FOUND, - "Should finish with WAYPOINT_TEMPLATE_NOT_FOUND", - ) - - def test_create_waypoint_command_jet_busy(self): - """ - Create a Waypoint when jet is busy (e.g. from flight plan): - ignore_busy=True, waypoint created, log success. - """ - self.jet_test.target_state_id = self.state_running - command = self.Command.create( - { - "name": "Create waypoint jet busy", - "action": "create_waypoint", - "waypoint_template_id": self.waypoint_template.id, - "fly_here": False, - } - ) - plan = self.Plan.create({"name": "Plan jet busy"}) - self.plan_line.create( - {"plan_id": plan.id, "sequence": 10, "command_id": command.id} - ) - initial_waypoint_count = len(self.jet_test.waypoint_ids) - with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet"): - plan_log = self.server_test_1.sudo().run_flight_plan( - plan, jet=self.jet_test - ) - command_logs = plan_log.command_log_ids.filtered( - lambda log: log.command_id == command - ) - self.assertEqual(len(command_logs), 1) - self.assertTrue( - command_logs.finish_date, - "Command log should be finished by waypoint callback when jet busy", - ) - self.assertEqual( - command_logs.command_status, - 0, - "Create waypoint command should succeed when jet is busy " - "(ignore_busy=True)", - ) - self.assertEqual( - len(self.jet_test.waypoint_ids), - initial_waypoint_count + 1, - "One new waypoint should be created despite jet busy", - ) - self.jet_test.target_state_id = False - - def test_create_waypoint_command_wrong_jet_template(self): - """Create a Waypoint with template for another jet template: False - and WAYPOINT_CREATE_FAILED.""" - from ..models.constants import WAYPOINT_CREATE_FAILED - - other_jet_template = self.JetTemplate.create( - { - "name": "Other Jet Template", - "reference": "other_jet_template_cmd", - } - ) - other_waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Other Waypoint Template", - "jet_template_id": other_jet_template.id, - } - ) - command = self.Command.create( - { - "name": "Create waypoint wrong template", - "action": "create_waypoint", - "waypoint_template_id": other_waypoint_template.id, - "fly_here": False, - } - ) - plan = self.Plan.create({"name": "Plan wrong template"}) - self.plan_line.create( - {"plan_id": plan.id, "sequence": 10, "command_id": command.id} - ) - with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet"): - plan_log = self.server_test_1.sudo().run_flight_plan( - plan, jet=self.jet_test - ) - command_logs = plan_log.command_log_ids.filtered( - lambda log: log.command_id == command - ) - self.assertEqual(len(command_logs), 1) - self.assertEqual( - command_logs.command_status, - WAYPOINT_CREATE_FAILED, - "Should finish with WAYPOINT_CREATE_FAILED when template is " - "for another jet template", - ) - self.assertTrue(command_logs.finish_date) - - def test_create_waypoint_command_waypoint_reaches_error(self): - """Create plan fails: waypoint goes to error, callback finishes - command log with error.""" - from ..models.constants import WAYPOINT_CREATE_FAILED - - fail_command = self.Command.create( - { - "name": "Fail command", - "action": "python_code", - "code": "result = {'exit_code': 1, 'message': 'fail'}", - } - ) - fail_plan = self.Plan.create({"name": "Plan that fails"}) - self.plan_line.create( - { - "plan_id": fail_plan.id, - "sequence": 10, - "command_id": fail_command.id, - } - ) - waypoint_template_with_failing_plan = self.JetWaypointTemplate.create( - { - "name": "Waypoint template with failing create plan", - "jet_template_id": self.jet_template_test.id, - "plan_create_id": fail_plan.id, - } - ) - command = self.Command.create( - { - "name": "Create waypoint with failing plan", - "action": "create_waypoint", - "waypoint_template_id": waypoint_template_with_failing_plan.id, - "fly_here": False, - } - ) - plan = self.Plan.create({"name": "Plan create waypoint error"}) - self.plan_line.create( - {"plan_id": plan.id, "sequence": 10, "command_id": command.id} - ) - plan_log = self.server_test_1.sudo().run_flight_plan(plan, jet=self.jet_test) - command_logs = plan_log.command_log_ids.filtered( - lambda log: log.command_id == command - ) - self.assertEqual(len(command_logs), 1) - log_record = command_logs[0] - self.assertTrue( - log_record.finish_date, - "Command log should be finished by waypoint callback when " - "waypoint reaches error", - ) - self.assertNotEqual( - log_record.command_status, - 0, - "Command should finish with error status", - ) - self.assertEqual( - log_record.command_status, - WAYPOINT_CREATE_FAILED, - "Callback should use WAYPOINT_CREATE_FAILED when plan fails", - ) - waypoints_with_log = self.jet_test.waypoint_ids.filtered( - lambda w: w.created_from_command_log_id == log_record - ) - self.assertEqual(len(waypoints_with_log), 1) - self.assertEqual( - waypoints_with_log.state, - "error", - "Waypoint should be in error state after create plan fails", - ) - - def test_finalize_create_waypoint_command_log_double_finish_guard(self): - """Calling _finalize_create_waypoint_command_log twice does not - double-finish.""" - waypoint = self.jet_test.create_waypoint( - self.waypoint_template, - created_from_command_log=None, - ) - log_record = self.CommandLog.create( - { - "server_id": self.server_test_1.id, - "command_id": self.Command.create( - { - "name": "Dummy create waypoint", - "action": "create_waypoint", - "waypoint_template_id": self.waypoint_template.id, - } - ).id, - "start_date": fields.Datetime.now(), - } - ) - waypoint.created_from_command_log_id = log_record - self.assertFalse(log_record.finish_date, "Log should not be finished yet") - waypoint._finalize_create_waypoint_command_log(success=True) - self.assertTrue(log_record.finish_date, "Log should be finished once") - finish_date_first = log_record.finish_date - waypoint._finalize_create_waypoint_command_log(success=True) - self.assertEqual( - log_record.finish_date, - finish_date_first, - "Second call should not change finish_date (guard)", - ) diff --git a/addons/cetmix_tower_server/tests/test_jet_access.py b/addons/cetmix_tower_server/tests/test_jet_access.py deleted file mode 100644 index 942f0ba..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_access.py +++ /dev/null @@ -1,442 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetAccess(TestTowerJetsCommon): - """ - Test access rules for Jet model - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create additional manager for multi-manager tests - cls.manager2 = cls.Users.create( - { - "name": "Test Manager 2", - "login": "test_manager_2", - "email": "test_manager_2@example.com", - "groups_id": [(6, 0, [cls.group_manager.id])], - } - ) - - # Create additional server for testing - cls.server_test_2 = cls.Server.create( - { - "name": "Test Server 2", - "ip_v4_address": "127.0.0.3", - "ssh_username": "test", - "ssh_password": "test", - "user_ids": [(5, 0, 0)], - "manager_ids": [(5, 0, 0)], - } - ) - - # ====================== - # User Read Access Tests - # ====================== - - def test_user_read_access_jet_user_server_user(self): - """Test User: Read when user in jet user_ids AND server user_ids""" - jet = self._create_jet( - "User Jet", - "user_jet", - user_ids=[(4, self.user.id)], - server_user_ids=[(4, self.user.id)], - ) - - records = self.Jet.with_user(self.user).search([("id", "=", jet.id)]) - self.assertIn( - jet, - records, - "User should read when in jet user_ids AND server user_ids", - ) - - def test_user_read_no_access_jet_user_only(self): - """Test User: No read when user in jet user_ids but NOT in server user_ids""" - jet = self._create_jet( - "User Jet No Server", - "user_jet_no_server", - user_ids=[(4, self.user.id)], - server_user_ids=[(5, 0, 0)], - ) - - records = self.Jet.with_user(self.user).search([("id", "=", jet.id)]) - self.assertEqual( - len(records), - 0, - "User should not read when not in server user_ids", - ) - - def test_user_read_no_access_server_user_only(self): - """Test User: No read when user in server user_ids but NOT in jet user_ids""" - jet = self._create_jet( - "Server User No Jet", - "server_user_no_jet", - user_ids=[(5, 0, 0)], - server_user_ids=[(4, self.user.id)], - ) - - records = self.Jet.with_user(self.user).search([("id", "=", jet.id)]) - self.assertEqual( - len(records), - 0, - "User should not read when not in jet user_ids", - ) - - def test_user_write_forbidden(self): - """Test User: Cannot write/create/delete records""" - jet = self._create_jet( - "User Jet", - "user_jet", - user_ids=[(4, self.user.id)], - server_user_ids=[(4, self.user.id)], - ) - - # User should not be able to write - with self.assertRaises(AccessError): - jet.with_user(self.user).write({"name": "Updated Name"}) - - # User should not be able to create - with self.assertRaises(AccessError): - self.Jet.with_user(self.user).create( - { - "name": "New Jet", - "reference": "new_jet", - "jet_template_id": self.jet_template_test.id, - "server_id": self.server_test_1.id, - } - ) - - # User should not be able to delete - # Jet is deletable by default, so this tests access control - with self.assertRaises(AccessError): - jet.with_user(self.user).unlink() - - # ====================== - # Manager Read Access Tests - # ====================== - - def test_manager_read_access_jet_user_server_user(self): - """Test Manager: Read when in jet user_ids AND server user_ids""" - jet = self._create_jet( - "Manager Jet User", - "manager_jet_user", - user_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - ) - - records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) - self.assertIn( - jet, - records, - "Manager should read when in jet user_ids AND server user_ids", - ) - - def test_manager_read_access_jet_manager_server_manager(self): - """Test Manager: Read when in jet manager_ids AND server manager_ids""" - jet = self._create_jet( - "Manager Jet Manager", - "manager_jet_manager", - manager_ids=[(4, self.manager.id)], - server_manager_ids=[(4, self.manager.id)], - ) - - records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) - self.assertIn( - jet, - records, - "Manager should read when in jet manager_ids AND server manager_ids", - ) - - def test_manager_read_access_jet_user_server_manager(self): - """Test Manager: Read when in jet user_ids AND server manager_ids""" - jet = self._create_jet( - "Manager Jet User Server Manager", - "manager_jet_user_server_manager", - user_ids=[(4, self.manager.id)], - server_manager_ids=[(4, self.manager.id)], - ) - - records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) - self.assertIn( - jet, - records, - "Manager should read when in jet user_ids AND server manager_ids", - ) - - def test_manager_read_access_jet_manager_server_user(self): - """Test Manager: Read when in jet manager_ids AND server user_ids""" - jet = self._create_jet( - "Manager Jet Manager Server User", - "manager_jet_manager_server_user", - manager_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - ) - - records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) - self.assertIn( - jet, - records, - "Manager should read when in jet manager_ids AND server user_ids", - ) - - def test_manager_read_no_access_jet_only(self): - """Test Manager: No read when in jet but NOT in server""" - jet = self._create_jet( - "Manager Jet No Server", - "manager_jet_no_server", - user_ids=[(4, self.manager.id)], - server_user_ids=[(5, 0, 0)], - server_manager_ids=[(5, 0, 0)], - ) - - records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) - self.assertEqual( - len(records), - 0, - "Manager should not read when not in server user_ids or manager_ids", - ) - - def test_manager_read_no_access_server_only(self): - """Test Manager: No read when in server but NOT in jet""" - jet = self._create_jet( - "Manager Server No Jet", - "manager_server_no_jet", - user_ids=[(5, 0, 0)], - manager_ids=[(5, 0, 0)], - server_user_ids=[(4, self.manager.id)], - ) - - records = self.Jet.with_user(self.manager).search([("id", "=", jet.id)]) - self.assertEqual( - len(records), - 0, - "Manager should not read when not in jet user_ids or manager_ids", - ) - - # ====================== - # Manager Write/Create Access Tests - # ====================== - - def test_manager_write_access_jet_manager_server_user(self): - """Test Manager: Write when in jet manager_ids AND server user_ids""" - jet = self._create_jet( - "Manager Write Jet", - "manager_write_jet", - manager_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - ) - - try: - jet.with_user(self.manager).write({"name": "Updated Name"}) - jet.invalidate_recordset() - self.assertEqual( - jet.name, "Updated Name", "Manager should be able to update" - ) - except AccessError: - self.fail( - "Manager should be able to update when in jet" - " manager_ids AND server user_ids.", - ) - - def test_manager_write_access_jet_manager_server_manager(self): - """Test Manager: Write when in jet manager_ids AND server manager_ids""" - jet = self._create_jet( - "Manager Write Jet Manager", - "manager_write_jet_manager", - manager_ids=[(4, self.manager.id)], - server_manager_ids=[(4, self.manager.id)], - ) - - try: - jet.with_user(self.manager).write({"name": "Updated"}) - except AccessError: - self.fail( - "Manager should be able to write when in jet" - " manager_ids AND server manager_ids.", - ) - - def test_manager_write_forbidden_not_in_jet_manager_ids(self): - """Test Manager: No write when NOT in jet manager_ids""" - jet = self._create_jet( - "Manager No Write Jet", - "manager_no_write_jet", - user_ids=[(4, self.manager.id)], # Only in user_ids, not manager_ids - server_user_ids=[(4, self.manager.id)], - ) - - with self.assertRaises(AccessError): - jet.with_user(self.manager).write({"name": "Should Fail"}) - - def test_manager_write_forbidden_not_in_server(self): - """Test Manager: No write when in jet manager_ids but NOT in server""" - jet = self._create_jet( - "Manager No Write Server", - "manager_no_write_server", - manager_ids=[(4, self.manager.id)], - server_user_ids=[(5, 0, 0)], - server_manager_ids=[(5, 0, 0)], - ) - - with self.assertRaises(AccessError): - jet.with_user(self.manager).write({"name": "Should Fail"}) - - def test_manager_create_access(self): - """ - Test Manager: - Create when in jet manager_ids AND server user_ids or manager_ids. - """ - # Create with manager in jet manager_ids and server user_ids - should succeed - try: - jet = self._create_jet( - "Create Success", - "create_success", - user_ids=[(5, 0, 0)], - manager_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - with_user=self.manager, - ) - records = self.Jet.search([("id", "=", jet.id)]) - self.assertIn(jet, records, "Manager should be able to create") - except AccessError: - self.fail("Manager should be able to create when in jet manager_ids") - - def test_manager_create_forbidden_not_in_manager_ids(self): - """Test Manager: Cannot create when not in jet manager_ids""" - # Configure server access first (required, but jet manager_ids check will fail) - self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) - - with self.assertRaises(AccessError): - self.Jet.with_user(self.manager).create( - { - "name": "Create Fail", - "reference": "create_fail", - "jet_template_id": self.jet_template_test.id, - "server_id": self.server_test_1.id, - "user_ids": [ - (4, self.manager.id) - ], # Only user_ids, not manager_ids - "manager_ids": [(5, 0, 0)], - } - ) - - # ====================== - # Manager Delete Access Tests - # ====================== - - def test_manager_delete_own_record(self): - """Test Manager: Delete own record when in jet manager_ids AND server""" - # Create as manager to ensure create_uid is set correctly - jet = self._create_jet( - "My Jet", - "my_jet", - manager_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - with_user=self.manager, - ) - # Jet is deletable by default, so manager can delete it - try: - jet.with_user(self.manager).unlink() - records = self.Jet.search([("id", "=", jet.id)]) - self.assertEqual( - len(records), 0, "Manager should be able to delete own record" - ) - except AccessError: - self.fail("Manager should be able to delete own record") - - def test_manager_delete_not_creator(self): - """Test Manager: Cannot delete record created by another user""" - jet = self._create_jet( - "Other's Jet", - "others_jet", - manager_ids=[(4, self.manager.id), (4, self.manager2.id)], - server_user_ids=[(4, self.manager.id), (4, self.manager2.id)], - with_user=self.manager2, - ) - - # Manager1 cannot delete Manager2's record - # Jet is deletable by default, so this tests access control - with self.assertRaises(AccessError): - jet.with_user(self.manager).unlink() - - def test_manager_delete_not_in_manager_ids(self): - """Test Manager: Cannot delete when not in jet manager_ids""" - jet = self._create_jet( - "Removed Manager", - "removed_manager", - manager_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - with_user=self.manager, - ) - # Remove from manager_ids - jet.write({"manager_ids": [(5, 0, 0)]}) - - # Cannot delete anymore - # Jet is deletable by default, so this tests access control - with self.assertRaises(AccessError): - jet.with_user(self.manager).unlink() - - def test_manager_delete_not_in_server(self): - """Test Manager: Cannot delete when in jet manager_ids but NOT in server""" - jet = self._create_jet( - "Manager Jet", - "manager_jet", - manager_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - with_user=self.manager, - ) - # Remove server access - self.server_test_1.write({"user_ids": [(5, 0, 0)], "manager_ids": [(5, 0, 0)]}) - - # Cannot delete anymore - # Jet is deletable by default, so this tests access control - with self.assertRaises(AccessError): - jet.with_user(self.manager).unlink() - - # ====================== - # Root Access Tests - # ====================== - - def test_root_full_access(self): - """Test Root: Full CRUD access regardless of access restrictions""" - # Test Root can create - jet = self.Jet.create( - { - "name": "Root Jet", - "reference": "root_jet", - "jet_template_id": self.jet_template_test.id, - "server_id": self.server_test_1.id, - "user_ids": [(5, 0, 0)], - "manager_ids": [(5, 0, 0)], - } - ) - - # Root can read - records = self.Jet.search([("id", "=", jet.id)]) - self.assertIn(jet, records, "Root should be able to read") - - # Root can write - jet.write({"name": "Root Updated Jet"}) - jet.invalidate_recordset() - self.assertEqual(jet.name, "Root Updated Jet", "Root should be able to update") - - # Test Root can delete records created by other users - manager_jet = self._create_jet( - "Manager's Jet", - "managers_jet", - manager_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - with_user=self.manager, - ) - # Jet is deletable by default, so root can delete it - manager_jet.unlink() - records = self.Jet.search([("id", "=", manager_jet.id)]) - self.assertEqual( - len(records), 0, "Root should be able to delete records from any creator" - ) diff --git a/addons/cetmix_tower_server/tests/test_jet_action_access.py b/addons/cetmix_tower_server/tests/test_jet_action_access.py deleted file mode 100644 index 87942c6..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_action_access.py +++ /dev/null @@ -1,647 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetActionAccess(TestTowerJetsCommon): - """ - Test access rules for Jet Action model (cx.tower.jet.action) - """ - - # ====================== - # User Read Access - # ====================== - - def test_user_read_access_level_user_and_template_user(self): - """ - User: can read when action access_level is User - (1) AND template access_level is User (1) - """ - template = self.JetTemplate.create( - { - "name": "User Level Template", - "reference": "user_level_template", - "access_level": "1", # User level - "user_ids": False, - "manager_ids": False, - } - ) - action = self.JetAction.create( - { - "name": "Action U", - "reference": "action_u", - "access_level": "1", # User level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - records = self.JetAction.with_user(self.user).search([("id", "=", action.id)]) - self.assertEqual( - len(records), - 1, - "User should read when action and template access_level are User", - ) - - def test_user_read_when_in_template_users(self): - """ - User: can read when action access_level is User (1) - AND user is added to template Users - """ - template = self.JetTemplate.create( - { - "name": "Manager Level Template (user granted)", - "reference": "manager_level_template_user", - "access_level": "2", # Manager level - "user_ids": [(4, self.user.id)], - "manager_ids": False, - } - ) - action = self.JetAction.create( - { - "name": "Action TU", - "reference": "action_tu", - "access_level": "1", # User level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - records = self.JetAction.with_user(self.user).search([("id", "=", action.id)]) - self.assertEqual( - len(records), - 1, - "User should read when action access_level is" - " User and user in template Users", - ) - - def test_user_read_when_in_jet_users(self): - """ - User: can read when action access_level is - User (1) AND user is added to Jet Users - """ - template = self.JetTemplate.create( - { - "name": "Manager Level Template", - "reference": "manager_level_template_jet", - "access_level": "2", # Manager level - "user_ids": False, - "manager_ids": False, - } - ) - # Add server to template's server_ids for jet creation - template.write({"server_ids": [(4, self.server_test_1.id)]}) - self._create_jet( - name="Test Jet from Template", - reference="test_jet_from_template", - template=template, - server=self.server_test_1, - user_ids=[(4, self.user.id)], # Add user to Jet's user_ids - server_user_ids=[(4, self.user.id)], # Also add to server for jet access - ) - action = self.JetAction.create( - { - "name": "Action JU", - "reference": "action_ju", - "access_level": "1", # User level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - records = self.JetAction.with_user(self.user).search([("id", "=", action.id)]) - self.assertEqual( - len(records), - 1, - "User should read when action access_level is User and user in Jet Users", - ) - - def test_user_read_no_access_action_not_user_level(self): - """User: cannot read when action access_level is NOT User (1)""" - template = self.JetTemplate.create( - { - "name": "User Level Template", - "reference": "user_level_template_no_access", - "access_level": "1", # User level - "user_ids": False, - "manager_ids": False, - } - ) - action = self.JetAction.create( - { - "name": "Action M", - "reference": "action_m", - "access_level": "2", # Manager level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - records = self.JetAction.with_user(self.user).search([("id", "=", action.id)]) - self.assertEqual( - len(records), - 0, - "User should not read when action access_level is not User", - ) - - def test_user_read_no_access_template_conditions_not_met(self): - """ - User: cannot read when action access_level is User (1) - and template conditions not met - """ - template = self.JetTemplate.create( - { - "name": "Manager Level Template", - "reference": "manager_level_template_no_access", - "access_level": "2", # Manager level - "user_ids": False, # User not in template Users - "manager_ids": False, - } - ) - # Don't create any jets with user in user_ids - action = self.JetAction.create( - { - "name": "Action NA", - "reference": "action_na", - "access_level": "1", # User level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - records = self.JetAction.with_user(self.user).search([("id", "=", action.id)]) - self.assertEqual( - len(records), - 0, - "User should not read when action is User level" - " and template conditions not met", - ) - - def test_user_write_forbidden(self): - """User: cannot write/create/delete records""" - template = self.JetTemplate.create( - { - "name": "User Level Template", - "reference": "user_level_template_write", - "access_level": "1", - "user_ids": [(4, self.user.id)], - } - ) - action = self.JetAction.create( - { - "name": "Action W", - "reference": "action_w_user", - "access_level": "1", - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - # Write forbidden - with self.assertRaises(AccessError): - self.JetAction.with_user(self.user).browse(action.id).write({"priority": 5}) - - # Create forbidden - with self.assertRaises(AccessError): - self.JetAction.with_user(self.user).create( - { - "name": "Action Created", - "reference": "action_created_user", - "access_level": "1", - "jet_template_id": template.id, - "state_from_id": self.state_stopped.id, - "state_to_id": self.state_running.id, - "state_transit_id": self.state_starting.id, - } - ) - - # Delete forbidden - with self.assertRaises(AccessError): - self.JetAction.with_user(self.user).browse(action.id).unlink() - - # ====================== - # Manager Read Access - # ====================== - - def test_manager_read_access_level_manager_or_less(self): - """ - Manager: can read when action access_level <= Manager (2) - AND template access_level <= Manager (2) - """ - template = self.JetTemplate.create( - { - "name": "Manager Level Template", - "reference": "manager_level_template", - "access_level": "2", - } - ) - action = self.JetAction.create( - { - "name": "Action R", - "reference": "action_r", - "access_level": "2", # Manager level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - records = self.JetAction.with_user(self.manager).search( - [("id", "=", action.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when action and template level <= Manager", - ) - - def test_manager_read_when_in_template_users(self): - """ - Manager: can read when action access_level <= Manager (2) - AND user is added to template Users - even if template access_level is Root (3) - """ - template = self.JetTemplate.create( - { - "name": "Root Level Template (user granted)", - "reference": "root_level_template_user", - "access_level": "3", - "user_ids": [(4, self.manager.id)], - } - ) - action = self.JetAction.create( - { - "name": "Action RU", - "reference": "action_ru", - "access_level": "2", # Manager level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - records = self.JetAction.with_user(self.manager).search( - [("id", "=", action.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when action level <= Manager and in template Users", - ) - - def test_manager_read_when_in_template_managers(self): - """ - Manager: can read when action access_level <= Manager (2) - AND user is added to template Managers - even if template access_level is Root (3) - """ - template = self.JetTemplate.create( - { - "name": "Root Level Template (manager)", - "reference": "root_level_template_manager", - "access_level": "3", - "manager_ids": [(4, self.manager.id)], - } - ) - action = self.JetAction.create( - { - "name": "Action RM", - "reference": "action_rm", - "access_level": "2", # Manager level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - records = self.JetAction.with_user(self.manager).search( - [("id", "=", action.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when action level <= Manager and in template Managers", - ) - - def test_manager_read_no_access_action_root_level(self): - """ - Manager: cannot read when action access_level is Root (3) - even if template conditions are met - """ - template = self.JetTemplate.create( - { - "name": "Manager Level Template", - "reference": "manager_level_template_no_access", - "access_level": "2", - "manager_ids": [(4, self.manager.id)], - } - ) - action = self.JetAction.create( - { - "name": "Action Root", - "reference": "action_root", - "access_level": "3", # Root level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - records = self.JetAction.with_user(self.manager).search( - [("id", "=", action.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read when action access_level is Root", - ) - - # ====================== - # Manager Write/Create/Delete - # ====================== - - def test_manager_write_when_in_template_managers(self): - """ - Manager: can write when action access_level <= Manager (2) - AND user is in template Managers - """ - template = self.JetTemplate.create( - { - "name": "Template For Write", - "reference": "template_for_write", - "manager_ids": [(4, self.manager.id)], - } - ) - action = self.JetAction.create( - { - "name": "Action W", - "reference": "action_w", - "access_level": "2", # Manager level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - # Write - self.JetAction.with_user(self.manager).browse(action.id).write({"priority": 99}) - action.invalidate_recordset() - self.assertEqual( - action.priority, - 99, - "Manager should be able to write when action level" - " <= Manager and in Managers", - ) - - # Create - created = self.JetAction.with_user(self.manager).create( - { - "name": "Action W Created", - "reference": "action_w_created", - "access_level": "2", # Manager level - "jet_template_id": template.id, - "state_from_id": self.state_stopped.id, - "state_to_id": self.state_running.id, - "state_transit_id": self.state_starting.id, - } - ) - self.assertTrue( - created, - "Manager should be able to create when action level " - "<= Manager and in Managers", - ) - - # Delete - self.JetAction.with_user(self.manager).browse(created.id).unlink() - after = self.JetAction.search([("id", "=", created.id)]) - self.assertEqual( - len(after), - 0, - "Manager should be able to delete when action level " - "<= Manager and in Managers", - ) - - def test_manager_write_forbidden_when_not_in_template_managers(self): - """ - Manager: cannot write/create/delete if NOT in template Managers - even if action access_level <= Manager (2) - """ - template = self.JetTemplate.create( - { - "name": "Template No Write", - "reference": "template_no_write", - } - ) - action = self.JetAction.create( - { - "name": "Action NW", - "reference": "action_nw", - "access_level": "2", # Manager level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - # Write forbidden - with self.assertRaises(AccessError): - self.JetAction.with_user(self.manager).browse(action.id).write( - {"priority": 5} - ) - - # Create forbidden - with self.assertRaises(AccessError): - self.JetAction.with_user(self.manager).create( - { - "name": "Action NW Created", - "reference": "action_nw_created", - "access_level": "2", # Manager level - "jet_template_id": template.id, - "state_from_id": self.state_stopped.id, - "state_to_id": self.state_running.id, - "state_transit_id": self.state_starting.id, - } - ) - - # Delete forbidden - with self.assertRaises(AccessError): - self.JetAction.with_user(self.manager).browse(action.id).unlink() - - def test_manager_write_forbidden_when_action_root_level(self): - """ - Manager: cannot write/create/delete when action access_level is Root (3) - even if user is in template Managers - """ - template = self.JetTemplate.create( - { - "name": "Template For Write", - "reference": "template_for_write_root", - "manager_ids": [(4, self.manager.id)], - } - ) - action = self.JetAction.create( - { - "name": "Action Root W", - "reference": "action_root_w", - "access_level": "3", # Root level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - # Write forbidden - with self.assertRaises(AccessError): - self.JetAction.with_user(self.manager).browse(action.id).write( - {"priority": 5} - ) - - # Create forbidden - with self.assertRaises(AccessError): - self.JetAction.with_user(self.manager).create( - { - "name": "Action Root Created", - "reference": "action_root_created", - "access_level": "3", # Root level - "jet_template_id": template.id, - "state_from_id": self.state_stopped.id, - "state_to_id": self.state_running.id, - "state_transit_id": self.state_starting.id, - } - ) - - # Delete forbidden - with self.assertRaises(AccessError): - self.JetAction.with_user(self.manager).browse(action.id).unlink() - - def test_manager_write_on_root_level_template_when_in_managers(self): - """ - Manager: can write/create/delete on Root-level template - when action access_level <= Manager (2) AND user is in Managers - """ - template = self.JetTemplate.create( - { - "name": "Root Level Template For Write", - "reference": "root_level_template_for_write", - "access_level": "3", - "manager_ids": [(4, self.manager.id)], - } - ) - action = self.JetAction.create( - { - "name": "Action RW", - "reference": "action_rw", - "access_level": "2", # Manager level - "jet_template_id": template.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, - "state_transit_id": self.state_stopping.id, - } - ) - - # Write - self.JetAction.with_user(self.manager).browse(action.id).write({"priority": 42}) - action.invalidate_recordset() - self.assertEqual( - action.priority, - 42, - "Manager should write on Root-level template when action level " - "<= Manager and in Managers", - ) - - # Create - created = self.JetAction.with_user(self.manager).create( - { - "name": "Action RW Created", - "reference": "action_rw_created", - "access_level": "2", # Manager level - "jet_template_id": template.id, - "state_from_id": self.state_stopped.id, - "state_to_id": self.state_running.id, - "state_transit_id": self.state_starting.id, - } - ) - self.assertTrue( - created, - "Manager should create on Root-level template when action level " - "<= Manager and in Managers", - ) - - # Delete - self.JetAction.with_user(self.manager).browse(created.id).unlink() - after = self.JetAction.search([("id", "=", created.id)]) - self.assertEqual( - len(after), - 0, - "Manager should delete on Root-level template when action level " - "<= Manager and in Managers", - ) - - # ====================== - # Root Access - # ====================== - - def test_root_full_access(self): - """Root: full CRUD access for any record""" - template = self.JetTemplate.with_user(self.root).create( - { - "name": "Root Template", - "reference": "root_template", - "access_level": "3", - } - ) - - # Create - action = self.JetAction.with_user(self.root).create( - { - "name": "Root Action", - "reference": "root_action", - "jet_template_id": template.id, - "state_from_id": self.state_initial.id, - "state_to_id": self.state_running.id, - "state_transit_id": self.state_starting.id, - } - ) - - # Read - records = self.JetAction.with_user(self.root).search([("id", "=", action.id)]) - self.assertEqual(len(records), 1, "Root should read any record") - - # Write - action.with_user(self.root).write({"priority": 7}) - action.invalidate_recordset() - self.assertEqual(action.priority, 7, "Root should update any record") - - # Delete - action.with_user(self.root).unlink() - self.assertEqual( - len( - self.JetAction.with_user(self.root).search( - [("reference", "=", "root_action")] - ) - ), - 0, - "Root should delete any record", - ) diff --git a/addons/cetmix_tower_server/tests/test_jet_create_wizard.py b/addons/cetmix_tower_server/tests/test_jet_create_wizard.py deleted file mode 100644 index 98646d8..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_create_wizard.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from .common_jets import TestTowerJetsCommon - - -class TestJetCreateWizard(TestTowerJetsCommon): - """Tests for `cx.tower.jet.create.wizard`""" - - def test_action_confirm_creates_jet(self): - """ - Ensure that the wizard creates a new jet using the selected template. - """ - wizard_model = self.env["cx.tower.jet.create.wizard"] - - wizard = wizard_model.create( - { - "name_type": "m", - "name": "Wizard Jet", - "jet_template_id": self.jet_template_test.id, - "server_id": self.server_test_1.id, - } - ) - - action = wizard.action_confirm() - - jet = self.Jet.browse(action["res_id"]) - self.assertTrue(jet.exists(), "Wizard action should return the created jet") - self.assertEqual(jet.name, "Wizard Jet") - self.assertEqual(jet.server_id, self.server_test_1) - self.assertEqual(jet.jet_template_id, self.jet_template_test) - - def test_action_confirm_sets_custom_variables(self): - """ - Ensure custom variable values from the wizard are stored on the created jet. - """ - wizard_model = self.env["cx.tower.jet.create.wizard"] - custom_variable = self.Variable.create( - { - "name": "Wizard Custom Variable", - } - ) - custom_value = "custom value" - - wizard = wizard_model.create( - { - "name_type": "m", - "name": "Wizard Jet With Variables", - "jet_template_id": self.jet_template_test.id, - "server_id": self.server_test_1.id, - "use_custom_variables": "y", - "line_ids": [ - ( - 0, - 0, - { - "variable_id": custom_variable.id, - "value_char": custom_value, - }, - ) - ], - } - ) - - action = wizard.action_confirm() - jet = self.Jet.browse(action["res_id"]) - custom_lines = jet.variable_value_ids.filtered( - lambda line: line.variable_id == custom_variable - ) - - self.assertEqual(len(custom_lines), 1, "Custom variable should be stored once") - self.assertEqual( - custom_lines.variable_id, - custom_variable, - "Custom variable record should be linked to the expected variable", - ) - self.assertEqual( - custom_lines.value_char, - custom_value, - "Created jet should keep custom variable values from the wizard", - ) diff --git a/addons/cetmix_tower_server/tests/test_jet_dependency_access.py b/addons/cetmix_tower_server/tests/test_jet_dependency_access.py deleted file mode 100644 index 43c5489..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_dependency_access.py +++ /dev/null @@ -1,420 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetDependencyAccess(TestTowerJetsCommon): - """ - Test access rules for Jet Dependency model - """ - - # ====================== - # Manager Read Access Tests - # ====================== - - def test_manager_read_access_both_user_ids(self): - """Test Manager: Read when in user_ids of both jets""" - _, _, dependency = self._create_jet_dependency( - "Jet 1", - "jet_1", - "Jet 2", - "jet_2", - jet_user_ids=[(4, self.manager.id)], - depends_on_user_ids=[(4, self.manager.id)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetDependency.with_user(self.manager).search( - [("id", "=", dependency.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in user_ids of both jets", - ) - self.assertIn( - dependency, - records, - "Manager should get exactly the dependency record we searched for", - ) - - def test_manager_read_access_both_manager_ids(self): - """Test Manager: Read when in manager_ids of both jets""" - _, _, dependency = self._create_jet_dependency( - "Jet Manager 1", - "jet_manager_1", - "Jet Manager 2", - "jet_manager_2", - jet_manager_ids=[(4, self.manager.id)], - depends_on_manager_ids=[(4, self.manager.id)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetDependency.with_user(self.manager).search( - [("id", "=", dependency.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in manager_ids of both jets", - ) - self.assertIn( - dependency, - records, - "Manager should get exactly the dependency record we searched for", - ) - - def test_manager_read_access_jet_user_depends_manager(self): - """Test Manager: Read when in user_ids of jet and manager_ids of depends""" - _, _, dependency = self._create_jet_dependency( - "Jet User", - "jet_user", - "Depends Manager", - "depends_manager", - jet_user_ids=[(4, self.manager.id)], - depends_on_manager_ids=[(4, self.manager.id)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetDependency.with_user(self.manager).search( - [("id", "=", dependency.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in user_ids of jet and manager_ids of depends", - ) - self.assertIn( - dependency, - records, - "Manager should get exactly the dependency record we searched for", - ) - - def test_manager_read_access_jet_manager_depends_user(self): - """Test Manager: Read when in manager_ids of jet and user_ids of depends""" - _, _, dependency = self._create_jet_dependency( - "Jet Manager", - "jet_manager", - "Depends User", - "depends_user", - jet_manager_ids=[(4, self.manager.id)], - depends_on_user_ids=[(4, self.manager.id)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetDependency.with_user(self.manager).search( - [("id", "=", dependency.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in manager_ids of jet and user_ids of depends", - ) - self.assertIn( - dependency, - records, - "Manager should get exactly the dependency record we searched for", - ) - - def test_manager_read_no_access_jet_only(self): - """Test Manager: No read when in jet but NOT in depends on jet""" - _, _, dependency = self._create_jet_dependency( - "Jet Has Access", - "jet_has_access", - "Depends No Access", - "depends_no_access", - jet_user_ids=[(4, self.manager.id)], - depends_on_user_ids=[(5, 0, 0)], - depends_on_manager_ids=[(5, 0, 0)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetDependency.with_user(self.manager).search( - [("id", "=", dependency.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read when not in depends_on" - " jet user_ids or manager_ids", - ) - - def test_manager_read_no_access_depends_only(self): - """Test Manager: No read when in depends on jet but NOT in jet""" - _, _, dependency = self._create_jet_dependency( - "Jet No Access", - "jet_no_access", - "Depends Has Access", - "depends_has_access", - jet_user_ids=[(5, 0, 0)], - jet_manager_ids=[(5, 0, 0)], - depends_on_user_ids=[(4, self.manager.id)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetDependency.with_user(self.manager).search( - [("id", "=", dependency.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read when not in jet user_ids or manager_ids", - ) - - # ====================== - # Manager CRUD Access Tests - # ====================== - - def test_manager_write_access(self): - """ - Test Manager: - Write access when in manager_ids of jet AND user_ids - or manager_ids of depends. - """ - # Test with depends_on user_ids (same conditions as create test, - # but tests write access on existing record) - _, _, dependency1 = self._create_jet_dependency( - "Write Jet Manager", - "write_jet_manager", - "Depends User", - "depends_user", - jet_manager_ids=[(4, self.manager.id)], - depends_on_user_ids=[(4, self.manager.id)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - ) - - # Verify manager can access the dependency (write permissions allow read access) - try: - dependency1.invalidate_recordset() - dependency1.with_user(self.manager).read(["jet_id", "jet_depends_on_id"]) - # Perform an actual write: switch to an alternative valid depends_on jet - depends_on_jet_alt = self._create_jet( - "Depends User Alt", - "depends_user_alt", - template=self.jet_template_tower_core, - user_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - ) - dependency1.with_user(self.manager).write( - {"jet_depends_on_id": depends_on_jet_alt.id} - ) - except AccessError: - self.fail( - "Manager should be able to write when in jet manager_ids " - "AND depends_on user_ids" - ) - - # Test with depends_on manager_ids - use different templates - # to avoid duplicate template dependency - _, _, dependency2 = self._create_jet_dependency( - "Write Jet Manager 2", - "write_jet_manager_2", - "Depends Manager", - "depends_manager", - jet_manager_ids=[(4, self.manager.id)], - depends_on_manager_ids=[(4, self.manager.id)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - jet_template=self.jet_template_nginx, - # Use different template to avoid duplicate - depends_on_template=self.jet_template_docker, - ) - - try: - dependency2.invalidate_recordset() - dependency2.with_user(self.manager).read(["jet_id", "jet_depends_on_id"]) - # Perform an actual write: switch to an alternative valid depends_on jet - depends_on_jet_alt2 = self._create_jet( - "Depends Manager Alt", - "depends_manager_alt", - template=self.jet_template_docker, - manager_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - ) - dependency2.with_user(self.manager).write( - {"jet_depends_on_id": depends_on_jet_alt2.id} - ) - except AccessError: - self.fail( - "Manager should be able to write when in jet manager_ids" - " AND depends_on manager_ids" - ) - - def test_manager_create_access(self): - """ - Test Manager: Create when in manager_ids of jet AND user_ids - or manager_ids of depends. - """ - # Try to create dependency as manager - # (helper ensures proper template dependency) - try: - _, _, dependency = self._create_jet_dependency( - "Create Jet", - "create_jet", - "Create Depends", - "create_depends", - jet_manager_ids=[(4, self.manager.id)], - depends_on_user_ids=[(4, self.manager.id)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - with_user=self.manager, - jet_template=self.jet_template_test, - depends_on_template=self.jet_template_tower_core, - ) - records = self.JetDependency.search([("id", "=", dependency.id)]) - self.assertIn( - dependency, - records, - "Manager should be able to create dependency", - ) - except AccessError: - self.fail("Manager should be able to create when in jet manager_ids") - - def test_manager_create_forbidden_not_in_jet_manager_ids(self): - """Test Manager: Cannot create when not in jet manager_ids""" - # Should not be able to create (manager not in jet manager_ids) - self.assertRaises( - AccessError, - lambda: self._create_jet_dependency( - "No Create Jet", - "no_create_jet", - "No Create Depends", - "no_create_depends", - jet_user_ids=[(4, self.manager.id)], - depends_on_user_ids=[(4, self.manager.id)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - with_user=self.manager, - jet_template=self.jet_template_test, - depends_on_template=self.jet_template_tower_core, - ), - ) - - def test_manager_create_forbidden_not_in_depends(self): - """ - Test Manager: Cannot create when in jet manager_ids but NOT in depends. - """ - # Should not be able to create (manager has no access to depends) - self.assertRaises( - AccessError, - lambda: self._create_jet_dependency( - "Create Jet", - "create_jet", - "No Depends Access", - "no_depends_access", - jet_manager_ids=[(4, self.manager.id)], - depends_on_user_ids=[(5, 0, 0)], - depends_on_manager_ids=[(5, 0, 0)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - with_user=self.manager, - jet_template=self.jet_template_test, - depends_on_template=self.jet_template_tower_core, - ), - ) - - def test_manager_unlink_access(self): - """ - Test Manager: Delete when in manager_ids of jet AND user_ids - or manager_ids of depends. - """ - _, _, dependency = self._create_jet_dependency( - "Delete Jet", - "delete_jet", - "Delete Depends", - "delete_depends", - jet_manager_ids=[(4, self.manager.id)], - depends_on_user_ids=[(4, self.manager.id)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - with_user=self.manager, - ) - - # Refresh dependency in manager context to ensure access - dependency.invalidate_recordset() - dependency = dependency.with_user(self.manager) - - try: - dependency.unlink() - records = self.JetDependency.search([("id", "=", dependency.id)]) - self.assertEqual( - len(records), - 0, - "Manager should be able to delete dependency", - ) - except AccessError: - self.fail("Manager should be able to delete dependency") - - def test_manager_unlink_forbidden_not_in_jet_manager_ids(self): - """Test Manager: Cannot delete when not in jet manager_ids""" - _, _, dependency = self._create_jet_dependency( - "No Delete Jet", - "no_delete_jet", - "No Delete Depends", - "no_delete_depends", - jet_user_ids=[(4, self.manager.id)], - depends_on_user_ids=[(4, self.manager.id)], - jet_server_user_ids=[(4, self.manager.id)], - depends_on_server_user_ids=[(4, self.manager.id)], - ) - - self.assertRaises(AccessError, dependency.with_user(self.manager).unlink) - - # ====================== - # Root Access Tests - # ====================== - - def test_root_full_access(self): - """Test Root: Full CRUD access regardless of access restrictions""" - # Root can create dependency via helper regardless of access - _, _, dependency = self._create_jet_dependency( - "Root Jet", - "root_jet", - "Root Depends", - "root_depends", - jet_user_ids=[(5, 0, 0)], - jet_manager_ids=[(5, 0, 0)], - depends_on_user_ids=[(5, 0, 0)], - depends_on_manager_ids=[(5, 0, 0)], - with_user=self.root, - jet_template=self.jet_template_test, - depends_on_template=self.jet_template_tower_core, - ) - - # Root can read - records = self.JetDependency.with_user(self.root).search( - [("id", "=", dependency.id)] - ) - self.assertIn(dependency, records, "Root should be able to read") - - # Root can write: switch depends_on to another valid jet - depends_on_jet_alt = self._create_jet( - "Root Depends Alt", - "root_depends_alt", - template=self.jet_template_tower_core, - ) - dependency.invalidate_recordset() - dependency.with_user(self.root).write( - {"jet_depends_on_id": depends_on_jet_alt.id} - ) - - # Root can delete - dependency.with_user(self.root).unlink() - records = self.JetDependency.with_user(self.root).search( - [("id", "=", dependency.id)] - ) - self.assertEqual( - len(records), - 0, - "Root should be able to delete dependency", - ) diff --git a/addons/cetmix_tower_server/tests/test_jet_state.py b/addons/cetmix_tower_server/tests/test_jet_state.py deleted file mode 100644 index bb3e1fc..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_state.py +++ /dev/null @@ -1,522 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError, ValidationError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetState(TestTowerJetsCommon): - """ - Test the Jet State model functionality - """ - - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # set_state Tests - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - def test_set_state_success_user_level(self): - """ - Test set_state succeeds when user has sufficient access level. - User (level 1) can set state with level 1. - """ - # Use existing state and set it to User access level (1) - self.state_running.access_level = "1" - self.state_running.invalidate_recordset(["access_level"]) - - # Ensure user has access to the jet and server - self.jet_test.write({"user_ids": [(4, self.user.id)]}) - self.server_test_1.write({"user_ids": [(4, self.user.id)]}) - - # Set jet to initial state - self.jet_test.state_id = self.state_initial - - # User should be able to set state - self.state_running.with_user(self.user).with_context( - cetmix_tower_no_commit=True - ).set_state(self.jet_test) - self.assertEqual( - self.jet_test.state_id, - self.state_running, - "Jet should be set to user-level state by user", - ) - - def test_set_state_success_manager_level(self): - """ - Test set_state succeeds when manager has sufficient access level. - Manager (level 2) can set state with level 2. - """ - # Use existing state and set it to Manager access level (2) - self.state_stopped.access_level = "2" - self.state_stopped.invalidate_recordset(["access_level"]) - - # Ensure manager has access to the jet and server - self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) - self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) - - # Set jet to running state (which has action to stopped) - self.jet_test.state_id = self.state_running - - # Manager should be able to set state - self.state_stopped.with_user(self.manager).with_context( - cetmix_tower_no_commit=True - ).set_state(self.jet_test) - self.assertEqual( - self.jet_test.state_id, - self.state_stopped, - "Jet should be set to manager-level state by manager", - ) - - def test_set_state_success_root_level(self): - """ - Test set_state succeeds when root has sufficient access level. - Root (level 3) can set state with level 3. - """ - # Use existing state and set it to Root access level (3) - self.state_error.access_level = "3" - self.state_error.invalidate_recordset(["access_level"]) - - # Set jet to running state (which has action to error) - self.jet_test.state_id = self.state_running - - # Root should be able to set state - self.state_error.with_user(self.root).with_context( - cetmix_tower_no_commit=True - ).set_state(self.jet_test) - self.assertEqual( - self.jet_test.state_id, - self.state_error, - "Jet should be set to root-level state by root", - ) - - def test_set_state_access_error_user_to_manager(self): - """ - Test set_state raises AccessError when user (level 1) - tries to set manager-level state (level 2). - """ - # Use existing state and set it to Manager access level (2) - self.state_stopped.access_level = "2" - self.state_stopped.invalidate_recordset(["access_level"]) - - # Ensure user has access to the jet and server (for the access check to work) - self.jet_test.write({"user_ids": [(4, self.user.id)]}) - self.server_test_1.write({"user_ids": [(4, self.user.id)]}) - - # Set jet to running state (which has action to stopped) - self.jet_test.state_id = self.state_running - - # User should not be able to set manager-level state - with self.assertRaises(AccessError) as context: - self.state_stopped.with_user(self.user).with_context( - cetmix_tower_no_commit=True - ).set_state(self.jet_test) - - self.assertIn( - "You are not allowed to set the", - str(context.exception), - "Should raise AccessError with appropriate message", - ) - self.assertIn( - self.state_stopped.name, - str(context.exception), - "Error message should include state name", - ) - - def test_set_state_access_error_user_to_root(self): - """ - Test set_state raises AccessError when user (level 1) - tries to set root-level state (level 3). - """ - # Use existing state and set it to Root access level (3) - self.state_error.access_level = "3" - self.state_error.invalidate_recordset(["access_level"]) - - # Ensure user has access to the jet and server (for the access check to work) - self.jet_test.write({"user_ids": [(4, self.user.id)]}) - self.server_test_1.write({"user_ids": [(4, self.user.id)]}) - - # Set jet to running state (which has action to error) - self.jet_test.state_id = self.state_running - - # User should not be able to set root-level state - with self.assertRaises(AccessError) as context: - self.state_error.with_user(self.user).with_context( - cetmix_tower_no_commit=True - ).set_state(self.jet_test) - - self.assertIn( - "You are not allowed to set the", - str(context.exception), - "Should raise AccessError with appropriate message", - ) - self.assertIn( - self.state_error.name, - str(context.exception), - "Error message should include state name", - ) - - def test_set_state_access_error_manager_to_root(self): - """ - Test set_state raises AccessError when manager (level 2) - tries to set root-level state (level 3). - """ - # Use existing state and set it to Root access level (3) - self.state_error.access_level = "3" - self.state_error.invalidate_recordset(["access_level"]) - - # Ensure manager has access to the jet and server (for the access check to work) - self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) - self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) - - # Set jet to running state (which has action to error) - self.jet_test.state_id = self.state_running - - # Manager should not be able to set root-level state - with self.assertRaises(AccessError) as context: - self.state_error.with_user(self.manager).with_context( - cetmix_tower_no_commit=True - ).set_state(self.jet_test) - - self.assertIn( - "You are not allowed to set the", - str(context.exception), - "Should raise AccessError with appropriate message", - ) - self.assertIn( - self.state_error.name, - str(context.exception), - "Error message should include state name", - ) - - def test_set_state_manager_can_access_user_level(self): - """ - Test set_state succeeds when manager (level 2) who IS in manager_ids - accesses user-level state (level 1). - Higher access levels can access lower level states. - """ - # Use existing state and set it to User access level (1) - self.state_running.access_level = "1" - self.state_running.invalidate_recordset(["access_level"]) - - # Ensure manager has access to the jet and server - # Manager IS in manager_ids, so they keep their manager access level (2) - self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) - self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) - - # Set jet to initial state - self.jet_test.state_id = self.state_initial - - # Manager should be able to set user-level state - self.state_running.with_user(self.manager).with_context( - cetmix_tower_no_commit=True - ).set_state(self.jet_test) - self.assertEqual( - self.jet_test.state_id, - self.state_running, - "Manager should be able to set user-level state", - ) - - def test_set_state_manager_not_in_manager_ids_treated_as_user(self): - """ - Test set_state treats manager (level 2) who is NOT in manager_ids - as user (level 1). - Manager should be able to set user-level state but not manager-level state. - """ - # Use existing state and set it to User access level (1) - self.state_running.access_level = "1" - self.state_running.invalidate_recordset(["access_level"]) - - # Ensure manager has access to the jet and server via user_ids - # but NOT via manager_ids - self.jet_test.write({"user_ids": [(4, self.manager.id)]}) - self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) - # Explicitly ensure manager is NOT in manager_ids - self.jet_test.write({"manager_ids": [(5, 0, 0)]}) - - # Set jet to initial state - self.jet_test.state_id = self.state_initial - - # Manager (treated as user) should be able to set user-level state - self.state_running.with_user(self.manager).with_context( - cetmix_tower_no_commit=True - ).set_state(self.jet_test) - self.assertEqual( - self.jet_test.state_id, - self.state_running, - "Manager not in manager_ids should be able to set user-level state", - ) - - def test_set_state_manager_not_in_manager_ids_cannot_access_manager_level(self): - """ - Test set_state raises AccessError when manager (level 2) who is NOT - in manager_ids tries to set manager-level state (level 2). - Manager should be treated as user (level 1) and cannot access level 2. - """ - # Use existing state and set it to Manager access level (2) - self.state_stopped.access_level = "2" - self.state_stopped.invalidate_recordset(["access_level"]) - - # Ensure manager has access to the jet and server via user_ids - # but NOT via manager_ids - self.jet_test.write({"user_ids": [(4, self.manager.id)]}) - self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) - # Explicitly ensure manager is NOT in manager_ids - self.jet_test.write({"manager_ids": [(5, 0, 0)]}) - - # Set jet to running state (which has action to stopped) - self.jet_test.state_id = self.state_running - - # Manager (treated as user) should not be able to set manager-level state - with self.assertRaises(AccessError) as context: - self.state_stopped.with_user(self.manager).with_context( - cetmix_tower_no_commit=True - ).set_state(self.jet_test) - - self.assertIn( - "You are not allowed to set the", - str(context.exception), - "Should raise AccessError with appropriate message", - ) - self.assertIn( - self.state_stopped.name, - str(context.exception), - "Error message should include state name", - ) - - def test_set_state_root_can_access_manager_level(self): - """ - Test set_state succeeds when root (level 3) - accesses manager-level state (level 2). - Higher access levels can access lower level states. - """ - # Use existing state and set it to Manager access level (2) - self.state_stopped.access_level = "2" - self.state_stopped.invalidate_recordset(["access_level"]) - - # Set jet to running state (which has action to stopped) - self.jet_test.state_id = self.state_running - - # Root should be able to set manager-level state - self.state_stopped.with_user(self.root).with_context( - cetmix_tower_no_commit=True - ).set_state(self.jet_test) - self.assertEqual( - self.jet_test.state_id, - self.state_stopped, - "Root should be able to set manager-level state", - ) - - def test_set_state_with_context_jet_id(self): - """ - Test set_state retrieves jet from context when jet parameter is None. - """ - # Use existing state and set it to User access level (1) - self.state_running.access_level = "1" - self.state_running.invalidate_recordset(["access_level"]) - - # Ensure user has access to the jet and server - self.jet_test.write({"user_ids": [(4, self.user.id)]}) - self.server_test_1.write({"user_ids": [(4, self.user.id)]}) - - # Set jet to initial state - self.jet_test.state_id = self.state_initial - - # Set state using context instead of direct parameter - self.state_running.with_user(self.user).with_context( - jet_id=self.jet_test.id, - cetmix_tower_no_commit=True, - ).set_state() - self.assertEqual( - self.jet_test.state_id, - self.state_running, - "Jet should be set to state using context jet_id", - ) - - def test_set_state_no_jet_in_context_returns_silently(self): - """ - Test set_state returns silently when no jet_id in context - and jet parameter is None. - """ - # Use existing state - self.state_running.access_level = "1" - self.state_running.invalidate_recordset(["access_level"]) - - # Call set_state without jet parameter and without context - # Should return silently without raising exception - result = ( - self.state_running.with_user(self.user) - .with_context(cetmix_tower_no_commit=True) - .set_state() - ) - self.assertIsNone(result, "Should return None when no jet in context") - - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # unlink Tests - # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - def test_unlink_success_when_not_used_in_action(self): - """ - Test unlink succeeds when state is not used in any action. - """ - # Create a state that is not used in any action - unused_state = self.JetState.create( - { - "name": "Unused State", - "reference": "unused_state", - "sequence": 100, - } - ) - state_id = unused_state.id - - # Unlink should succeed - unused_state.unlink() - - # Verify state is deleted - self.assertFalse( - self.JetState.search([("id", "=", state_id)]), - "State should be deleted when not used in any action", - ) - - def test_unlink_fails_when_used_as_state_from(self): - """ - Test unlink raises ValidationError when state is used as state_from_id - in an action. - """ - # state_running is used as state_from_id in action_running_to_stopped - with self.assertRaises(ValidationError) as context: - self.state_running.unlink() - - error_message = str(context.exception) - self.assertIn( - "Some states are still used in the following actions", - error_message, - "Should raise ValidationError with appropriate message", - ) - self.assertIn( - self.action_running_to_stopped.name, - error_message, - "Error message should include action name", - ) - self.assertIn( - self.jet_template_test.name, - error_message, - "Error message should include template name", - ) - - def test_unlink_fails_when_used_as_state_to(self): - """ - Test unlink raises ValidationError when state is used as state_to_id - in an action. - """ - # state_stopped is used as state_to_id in action_running_to_stopped - with self.assertRaises(ValidationError) as context: - self.state_stopped.unlink() - - error_message = str(context.exception) - self.assertIn( - "Some states are still used in the following actions", - error_message, - "Should raise ValidationError with appropriate message", - ) - self.assertIn( - self.action_running_to_stopped.name, - error_message, - "Error message should include action name", - ) - self.assertIn( - self.jet_template_test.name, - error_message, - "Error message should include template name", - ) - - def test_unlink_fails_when_used_as_state_transit(self): - """ - Test unlink raises ValidationError when state is used as state_transit_id - in an action. - """ - # state_stopping is used as state_transit_id in action_running_to_stopped - with self.assertRaises(ValidationError) as context: - self.state_stopping.unlink() - - error_message = str(context.exception) - self.assertIn( - "Some states are still used in the following actions", - error_message, - "Should raise ValidationError with appropriate message", - ) - self.assertIn( - self.action_running_to_stopped.name, - error_message, - "Error message should include action name", - ) - self.assertIn( - self.jet_template_test.name, - error_message, - "Error message should include template name", - ) - - def test_unlink_fails_with_multiple_actions(self): - """ - Test unlink raises ValidationError with multiple actions when state - is used in multiple actions. - """ - # state_running is used in multiple actions: - # - action_running_to_stopped (state_from_id) - # - action_stopped_to_running (state_to_id) - # - action_running_to_error (state_from_id) - # - action_initial_to_running (state_to_id) - with self.assertRaises(ValidationError) as context: - self.state_running.unlink() - - error_message = str(context.exception) - self.assertIn( - "Some states are still used in the following actions", - error_message, - "Should raise ValidationError with appropriate message", - ) - # Verify multiple actions are mentioned - self.assertIn( - self.action_running_to_stopped.name, - error_message, - "Error message should include first action name", - ) - self.assertIn( - self.jet_template_test.name, - error_message, - "Error message should include template name", - ) - - def test_unlink_fails_with_multiple_states(self): - """ - Test unlink raises ValidationError when trying to unlink multiple states - where at least one is used in an action. - """ - # Create an unused state - unused_state = self.JetState.create( - { - "name": "Another Unused State", - "reference": "another_unused_state", - "sequence": 101, - } - ) - - # Try to unlink both unused_state and state_running (which is used) - states_to_unlink = unused_state | self.state_running - with self.assertRaises(ValidationError) as context: - states_to_unlink.unlink() - - error_message = str(context.exception) - self.assertIn( - "Some states are still used in the following actions", - error_message, - "Should raise ValidationError with appropriate message", - ) - # Verify that neither state was deleted - self.assertTrue( - unused_state.exists(), - "Unused state should not be deleted when another state fails", - ) - self.assertTrue( - self.state_running.exists(), - "Used state should not be deleted", - ) diff --git a/addons/cetmix_tower_server/tests/test_jet_template.py b/addons/cetmix_tower_server/tests/test_jet_template.py deleted file mode 100644 index e82929d..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_template.py +++ /dev/null @@ -1,3226 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import ValidationError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetTemplate(TestTowerJetsCommon): - """ - Test the jet template model - """ - - # All jet-related test data is now inherited from TestTowerJetsCommon - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create additional servers for multi-server tests - cls.server_test_2 = cls.Server.create( - { - "name": "Test Server 2", - "reference": "test_server_2", - "ip_v4_address": "192.168.1.102", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": cls.os_debian_10.id, - } - ) - cls.server_test_3 = cls.Server.create( - { - "name": "Test Server 3", - "reference": "test_server_3", - "ip_v4_address": "192.168.1.103", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": cls.os_debian_10.id, - } - ) - - def test_compute_border_actions_no_actions(self): - """ - Test _compute_border_actions with no actions defined - """ - # Create a jet template with no actions - template = self.JetTemplate.create( - { - "name": "No Actions Template", - "reference": "no_actions_template", - "server_ids": [(4, self.server_test_1.id)], - } - ) - - # Both border actions should be False - self.assertFalse( - template.action_create_id, - "Create action should be False when no actions exist", - ) - self.assertFalse( - template.action_destroy_id, - "Destroy action should be False when no actions exist", - ) - - def test_compute_border_actions_both_valid_actions(self): - """ - Test _compute_border_actions with both valid create and destroy actions - """ - # Use common actions from class setup - create_action = self.action_create - destroy_action = self.action_destroy - - # Both actions should be set - self.assertEqual( - self.jet_template_test.action_create_id, - create_action, - "Create action should be set to the valid action", - ) - self.assertEqual( - self.jet_template_test.action_destroy_id, - destroy_action, - "Destroy action should be set to the valid action", - ) - - def test_compute_border_actions_invalid_create_action_with_initial_state(self): - """ - Test _compute_border_actions with invalid create action (has initial state) - """ - # Create an invalid create action (has state_from_id) - invalid_create_action = self.JetAction.create( - { - "name": "Invalid Create Action", - "reference": "invalid_create_action", - "jet_template_id": self.jet_template_test.id, - "state_from_id": self.state_initial.id, # Invalid for create - "state_to_id": self.state_running.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Since action_create_id is readonly=False, we can set it directly - # but the compute method won't be triggered automatically - self.jet_template_test.action_create_id = invalid_create_action - - # The action should remain set because compute method wasn't triggered - self.assertEqual( - self.jet_template_test.action_create_id, - invalid_create_action, - "Create action should remain set when directly assigned (readonly=False)", - ) - - # Now trigger the compute method manually to test the logic - self.jet_template_test._compute_border_actions() - - # Create action should be cleared because it's invalid - self.assertFalse( - self.jet_template_test.action_create_id, - "Create action should be cleared when it has an initial state", - ) - - def test_compute_border_actions_invalid_create_action_no_final_state(self): - """ - Test _compute_border_actions with invalid create action (no final state) - """ - # Create an invalid create action (no state_to_id) - invalid_create_action = self.JetAction.create( - { - "name": "Invalid Create Action", - "reference": "invalid_create_action", - "jet_template_id": self.jet_template_test.id, - "state_from_id": False, - "state_to_id": False, # No final state - invalid for create - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Since action_create_id is readonly=False, we can set it directly - # but the compute method won't be triggered automatically - self.jet_template_test.action_create_id = invalid_create_action - - # The action should remain set because compute method wasn't triggered - self.assertEqual( - self.jet_template_test.action_create_id, - invalid_create_action, - "Create action should remain set when directly assigned (readonly=False)", - ) - - # Now trigger the compute method manually to test the logic - self.jet_template_test._compute_border_actions() - - # Create action should be cleared because it's invalid - self.assertFalse( - self.jet_template_test.action_create_id, - "Create action should be cleared when it has no final state", - ) - - def test_compute_border_actions_invalid_destroy_action_with_final_state(self): - """ - Test _compute_border_actions with invalid destroy action (has final state) - """ - # Create an invalid destroy action (has state_to_id) - invalid_destroy_action = self.JetAction.create( - { - "name": "Invalid Destroy Action", - "reference": "invalid_destroy_action", - "jet_template_id": self.jet_template_test.id, - "state_from_id": self.state_running.id, - "state_to_id": self.state_stopped.id, # Invalid for destroy - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Since action_destroy_id is readonly=False, we can set it directly - # but the compute method won't be triggered automatically - self.jet_template_test.action_destroy_id = invalid_destroy_action - - # The action should remain set because compute method wasn't triggered - self.assertEqual( - self.jet_template_test.action_destroy_id, - invalid_destroy_action, - "Destroy action should remain set when directly assigned (readonly=False)", - ) - - # Now trigger the compute method manually to test the logic - self.jet_template_test._compute_border_actions() - - # Destroy action should be cleared because it's invalid - self.assertFalse( - self.jet_template_test.action_destroy_id, - "Destroy action should be cleared when it has a final state", - ) - - def test_compute_border_actions_multiple_actions_priority(self): - """ - Test _compute_border_actions with multiple actions, checking priority order - """ - # Clear existing border actions to force recomputation - self.jet_template_test.action_create_id = False - self.jet_template_test.action_destroy_id = False - - # Create multiple create actions with different priorities - # Use priority 0 to ensure they have higher priority - # than common actions (priority 1) - self.JetAction.create( - { - "name": "Create Action 1", - "reference": "create_action_1", - "jet_template_id": self.jet_template_test.id, - "state_from_id": False, - "state_to_id": self.state_running.id, - "state_transit_id": self.state_starting.id, - "priority": 2, # Higher priority number (lower priority) - } - ) - - create_action_2 = self.JetAction.create( - { - "name": "Create Action 2", - "reference": "create_action_2", - "jet_template_id": self.jet_template_test.id, - "state_from_id": False, - "state_to_id": self.state_running.id, - "state_transit_id": self.state_starting.id, - "priority": 0, # Lower priority number (higher priority) - } - ) - - # Create multiple destroy actions with different priorities - self.JetAction.create( - { - "name": "Destroy Action 1", - "reference": "destroy_action_1", - "jet_template_id": self.jet_template_test.id, - "state_from_id": self.state_running.id, - "state_to_id": False, - "state_transit_id": self.state_stopping.id, - "priority": 2, # Higher priority number (lower priority) - } - ) - - destroy_action_2 = self.JetAction.create( - { - "name": "Destroy Action 2", - "reference": "destroy_action_2", - "jet_template_id": self.jet_template_test.id, - "state_from_id": self.state_running.id, - "state_to_id": False, - "state_transit_id": self.state_stopping.id, - "priority": 0, # Lower priority number (higher priority) - } - ) - - # Trigger recomputation of border actions to ensure - # the new actions are considered - self.jet_template_test._compute_border_actions() - - # Should select the actions with higher priority (lower priority number) - self.assertEqual( - self.jet_template_test.action_create_id, - create_action_2, - "Create action should be the one with higher priority", - ) - self.assertEqual( - self.jet_template_test.action_destroy_id, - destroy_action_2, - "Destroy action should be the one with higher priority", - ) - - def test_compute_border_actions_action_updates(self): - """ - Test _compute_border_actions when actions are updated - """ - # Use common actions from class setup - create_action = self.action_create - destroy_action = self.action_destroy - - # Both actions should be set initially - self.assertEqual(self.jet_template_test.action_create_id, create_action) - self.assertEqual(self.jet_template_test.action_destroy_id, destroy_action) - - # Update create action to make it invalid (add initial state) - create_action.write({"state_from_id": self.state_initial.id}) - - # Create action should be cleared, destroy action should remain - self.assertFalse( - self.jet_template_test.action_create_id, - "Create action should be cleared after becoming invalid", - ) - self.assertEqual( - self.jet_template_test.action_destroy_id, - destroy_action, - "Destroy action should remain unchanged", - ) - - # Update destroy action to make it invalid (add final state) - destroy_action.write({"state_to_id": self.state_stopped.id}) - - # Both actions should be cleared - self.assertFalse( - self.jet_template_test.action_create_id, - "Create action should remain cleared", - ) - self.assertFalse( - self.jet_template_test.action_destroy_id, - "Destroy action should be cleared after becoming invalid", - ) - - def test_find_action_path_bfs_multiple_paths_shortest(self): - """ - Test _find_action_path_bfs finds the shortest path when multiple paths exist - """ - # Create actions for multiple paths - # Short path: A -> C - action_ac = self.JetAction.create( - { - "name": "Action A to C (short)", - "reference": "action_ac", - "jet_template_id": self.jet_template_test.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - # Long path: A -> B -> D -> C - action_ab = self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.jet_template_test.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - action_bd = self.JetAction.create( - { - "name": "Action B to D", - "reference": "action_bd", - "jet_template_id": self.jet_template_test.id, - "state_from_id": self.state_b.id, - "state_to_id": self.state_d.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - action_dc = self.JetAction.create( - { - "name": "Action D to C", - "reference": "action_dc", - "jet_template_id": self.jet_template_test.id, - "state_from_id": self.state_d.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Create adjacency with multiple paths - adjacency = { - self.state_a: [ - (self.state_c, action_ac), - (self.state_b, action_ab), - ], # Short and long path - self.state_b: [(self.state_d, action_bd)], - self.state_d: [(self.state_c, action_dc)], - } - - # Test that shortest path is found - result = self.jet_template_test._find_action_path_bfs( - self.state_a, self.state_c, adjacency - ) - expected_path = [action_ac] # Shortest path - self.assertEqual( - result, - expected_path, - "Should return shortest path when multiple paths exist", - ) - - def test_find_action_path_bfs_empty_adjacency(self): - """ - Test _find_action_path_bfs with empty adjacency list - """ - # Empty adjacency - adjacency = {} - - # Test with empty adjacency - result = self.jet_template_test._find_action_path_bfs( - self.state_a, self.state_b, adjacency - ) - self.assertIsNone(result, "Should return None with empty adjacency") - - def test_find_action_path_bfs_cyclic_graph(self): - """ - Test _find_action_path_bfs with cyclic graph - """ - # Create actions for cyclic graph - action_ab = self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.jet_template_test.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - action_bc = self.JetAction.create( - { - "name": "Action B to C", - "reference": "action_bc", - "jet_template_id": self.jet_template_test.id, - "state_from_id": self.state_b.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - action_ca = self.JetAction.create( - { - "name": "Action C to A", - "reference": "action_ca", - "jet_template_id": self.jet_template_test.id, - "state_from_id": self.state_c.id, - "state_to_id": self.state_a.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Create cyclic adjacency: A -> B -> C -> A - adjacency = { - self.state_a: [(self.state_b, action_ab)], - self.state_b: [(self.state_c, action_bc)], - self.state_c: [(self.state_a, action_ca)], - } - - # Test path from A to C (should find path despite cycle) - result = self.jet_template_test._find_action_path_bfs( - self.state_a, self.state_c, adjacency - ) - expected_path = [action_ab, action_bc] - self.assertEqual(result, expected_path, "Should find path in cyclic graph") - - def test_find_action_path_bfs_disconnected_states(self): - """ - Test _find_action_path_bfs with disconnected states - """ - # Create adjacency with disconnected components - adjacency = { - self.state_a: [(self.state_b, "action_ab")], # A and B connected - # state_c is isolated - } - - # Test path from A to C (disconnected) - result = self.jet_template_test._find_action_path_bfs( - self.state_a, self.state_c, adjacency - ) - self.assertIsNone(result, "Should return None for disconnected states") - - def test_find_action_path_bfs_with_get_action_adjacency(self): - """ - Test _find_action_path_bfs using the actual _get_action_adjacency method - """ - # Create actions that will be used by _get_action_adjacency - action_ab = self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - action_bc = self.JetAction.create( - { - "name": "Action B to C", - "reference": "action_bc", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_b.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Get adjacency using the actual method - adjacency = self.clean_template._get_action_adjacency() - - # Test path from A to C - result = self.clean_template._find_action_path_bfs( - self.state_a, self.state_c, adjacency - ) - expected_path = [action_ab, action_bc] - self.assertEqual( - result, expected_path, "Should work with _get_action_adjacency method" - ) - - def test_get_action_adjacency_no_actions(self): - """ - Test _get_action_adjacency with no actions - """ - # Create a template with no actions - template = self.JetTemplate.create( - { - "name": "No Actions Template", - "reference": "no_actions_template", - "server_ids": [(4, self.server_test_1.id)], - } - ) - - # Get adjacency - adjacency = template._get_action_adjacency() - - # Should return empty dict - self.assertEqual( - adjacency, {}, "Should return empty dict when no actions exist" - ) - - def test_get_action_adjacency_single_action(self): - """ - Test _get_action_adjacency with a single valid action - """ - # Create action - action_ab = self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Get adjacency - adjacency = self.clean_template._get_action_adjacency() - - # Should have one entry - self.assertIn(self.state_a, adjacency, "Should include state_a in adjacency") - self.assertEqual( - len(adjacency[self.state_a]), 1, "Should have one transition from state_a" - ) - self.assertEqual( - adjacency[self.state_a][0], - (self.state_b, action_ab), - "Should map to state_b with action_ab", - ) - - def test_get_action_adjacency_multiple_actions_from_same_state(self): - """ - Test _get_action_adjacency with multiple actions from the same state - """ - # Create multiple actions from state_a - action_ab = self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - action_ac = self.JetAction.create( - { - "name": "Action A to C", - "reference": "action_ac", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 20, - } - ) - - # Get adjacency - adjacency = self.clean_template._get_action_adjacency() - - # Should have multiple transitions from state_a - self.assertIn(self.state_a, adjacency, "Should include state_a in adjacency") - self.assertEqual( - len(adjacency[self.state_a]), 2, "Should have two transitions from state_a" - ) - - # Check that both transitions are present - transitions = adjacency[self.state_a] - expected_transitions = [(self.state_b, action_ab), (self.state_c, action_ac)] - for expected in expected_transitions: - self.assertIn( - expected, transitions, f"Should include transition {expected}" - ) - - def test_get_action_adjacency_actions_without_from_state(self): - """ - Test _get_action_adjacency with actions that have no state_from_id - """ - # Create action without state_from_id (create action) - self.JetAction.create( - { - "name": "Create Action", - "reference": "create_action", - "jet_template_id": self.clean_template.id, - "state_from_id": False, # No initial state - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Get adjacency - adjacency = self.clean_template._get_action_adjacency() - - # Should be empty because action has no state_from_id - self.assertEqual( - adjacency, {}, "Should return empty dict for actions without state_from_id" - ) - - def test_get_action_adjacency_actions_without_to_state(self): - """ - Test _get_action_adjacency with actions that have no state_to_id - """ - # Create action without state_to_id (destroy action) - self.JetAction.create( - { - "name": "Destroy Action", - "reference": "destroy_action", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": False, # No final state - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Get adjacency - adjacency = self.clean_template._get_action_adjacency() - - # Should be empty because action has no state_to_id - self.assertEqual( - adjacency, {}, "Should return empty dict for actions without state_to_id" - ) - - def test_get_action_adjacency_complex_graph(self): - """ - Test _get_action_adjacency with a complex graph structure - """ - # Create complex action graph - action_ab = self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - action_ac = self.JetAction.create( - { - "name": "Action A to C", - "reference": "action_ac", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 20, - } - ) - action_bd = self.JetAction.create( - { - "name": "Action B to D", - "reference": "action_bd", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_b.id, - "state_to_id": self.state_d.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - action_cd = self.JetAction.create( - { - "name": "Action C to D", - "reference": "action_cd", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_c.id, - "state_to_id": self.state_d.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Get adjacency - adjacency = self.clean_template._get_action_adjacency() - - # Check structure - self.assertIn(self.state_a, adjacency, "Should include state_a") - self.assertIn(self.state_b, adjacency, "Should include state_b") - self.assertIn(self.state_c, adjacency, "Should include state_c") - self.assertNotIn( - self.state_d, adjacency, "Should not include state_d (no outgoing edges)" - ) - - # Check transitions from state_a - self.assertEqual( - len(adjacency[self.state_a]), - 2, - "State A should have 2 outgoing transitions", - ) - expected_from_a = [(self.state_b, action_ab), (self.state_c, action_ac)] - for expected in expected_from_a: - self.assertIn( - expected, - adjacency[self.state_a], - f"State A should have transition {expected}", - ) - - # Check transitions from state_b - self.assertEqual( - len(adjacency[self.state_b]), 1, "State B should have 1 outgoing transition" - ) - self.assertEqual( - adjacency[self.state_b][0], - (self.state_d, action_bd), - "State B should transition to D", - ) - - # Check transitions from state_c - self.assertEqual( - len(adjacency[self.state_c]), 1, "State C should have 1 outgoing transition" - ) - self.assertEqual( - adjacency[self.state_c][0], - (self.state_d, action_cd), - "State C should transition to D", - ) - - def test_get_action_adjacency_mixed_valid_invalid_actions(self): - """ - Test _get_action_adjacency with mix of valid and invalid actions - """ - # Create valid action - valid_action = self.JetAction.create( - { - "name": "Valid Action", - "reference": "valid_action", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Create invalid actions (should be ignored) - self.JetAction.create( - { - "name": "Invalid Action 1", - "reference": "invalid_action_1", - "jet_template_id": self.clean_template.id, - "state_from_id": False, # No initial state - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - self.JetAction.create( - { - "name": "Invalid Action 2", - "reference": "invalid_action_2", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": False, # No final state - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Get adjacency - adjacency = self.clean_template._get_action_adjacency() - - # Should only include the valid action - self.assertIn(self.state_a, adjacency, "Should include state_a") - self.assertEqual( - len(adjacency[self.state_a]), 1, "Should have only one valid transition" - ) - self.assertEqual( - adjacency[self.state_a][0], - (self.state_b, valid_action), - "Should include only valid action", - ) - - def test_get_action_adjacency_self_loop(self): - """ - Test _get_action_adjacency with self-loop actions - """ - # Create self-loop action - self_loop_action = self.JetAction.create( - { - "name": "Self Loop Action", - "reference": "self_loop_action", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_a.id, # Same state - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Get adjacency - adjacency = self.clean_template._get_action_adjacency() - - # Should include self-loop - self.assertIn(self.state_a, adjacency, "Should include state_a") - self.assertEqual( - len(adjacency[self.state_a]), 1, "Should have one self-loop transition" - ) - self.assertEqual( - adjacency[self.state_a][0], - (self.state_a, self_loop_action), - "Should include self-loop action", - ) - - def test_get_action_path_no_create_destroy_actions(self): - """ - Test _get_action_path when no create or destroy actions are set - """ - # Create a template with no border actions - template = self.JetTemplate.create( - { - "name": "No Border Actions Template", - "reference": "no_border_actions_template", - "server_ids": [(4, self.server_test_1.id)], - } - ) - - # Test path without state_from and state_to - result = template._get_action_path() - self.assertEqual( - result, [], "Should return empty list when no create action exists" - ) - - # Test path with state_from but no state_to - result = template._get_action_path(state_from=self.state_a) - self.assertEqual( - result, [], "Should return empty list when no destroy action exists" - ) - - def test_get_action_path_both_parameters_provided(self): - """ - Test _get_action_path when both state_from and state_to are provided - """ - # Create action - action_ab = self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Test path with both parameters provided - result = self.clean_template._get_action_path( - state_from=self.state_a, state_to=self.state_b - ) - self.assertEqual( - result, - [action_ab], - "Should return action path when both parameters provided", - ) - - def test_get_action_path_requires_at_least_one_parameter(self): - """ - Test _get_action_path behavior when no parameters are provided - """ - # Create a template with no border actions - template = self.JetTemplate.create( - { - "name": "No Border Actions Template", - "reference": "no_border_actions_template", - "server_ids": [(4, self.server_test_1.id)], - } - ) - - # Test with no parameters - should return empty list - result = template._get_action_path() - self.assertEqual( - result, - [], - "Should return empty list when no parameters and no border actions", - ) - - # Test with only state_from - result = template._get_action_path(state_from=self.state_a) - self.assertEqual( - result, - [], - "Should return empty list when only state_from provided", - ) - - # Test with only state_to - result = template._get_action_path(state_to=self.state_b) - self.assertEqual( - result, - [], - "Should return empty list when only state_to provided and no create action", - ) - - def test_get_action_path_with_create_action_only(self): - """ - Test _get_action_path with only create action set - """ - # Create create action - create_action = self.JetAction.create( - { - "name": "Create Action", - "reference": "create_action", - "jet_template_id": self.clean_template.id, - "state_from_id": False, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Set create action - self.clean_template.action_create_id = create_action - - # Test path without state_from (should return empty because no destroy action) - result = self.clean_template._get_action_path() - self.assertEqual( - result, [], "Should return empty list when no destroy action provided" - ) - - # Test path with state_from (should not use create action) - result = self.clean_template._get_action_path(state_from=self.state_b) - self.assertEqual( - result, - [], - "Should return empty list when state_from provided and no path exists", - ) - - def test_build_dependency_graph_simple_dependency(self): - """Test _build_dependency_graph with simple dependency chain""" - # Use the existing dependency hierarchy - - graph = self.jet_template_odoo._build_dependency_graph() - - # Verify all templates are in the graph - expected_template_ids = [ - self.jet_template_odoo.id, - self.jet_template_postgres.id, - self.jet_template_nginx.id, - self.jet_template_docker.id, - self.jet_template_tower_core.id, - ] - self.assertEqual( - set(graph.keys()), - set(expected_template_ids), - "All templates should be in the graph", - ) - - # Verify Odoo template info - odoo_info = graph[self.jet_template_odoo.id] - self.assertEqual(odoo_info["template"], self.jet_template_odoo) - self.assertEqual(odoo_info["name"], "Odoo") - self.assertEqual(odoo_info["reference"], "odoo") - self.assertEqual(odoo_info["level"], 0) # Root template - self.assertEqual( - len(odoo_info["dependencies"]), 2 - ) # Depends on Postgres and Nginx - - # Verify Odoo dependencies - odoo_dep_ids = [dep["template_id"] for dep in odoo_info["dependencies"]] - self.assertIn(self.jet_template_postgres.id, odoo_dep_ids) - self.assertIn(self.jet_template_nginx.id, odoo_dep_ids) - - # Verify Postgres template info - postgres_info = graph[self.jet_template_postgres.id] - self.assertEqual(postgres_info["template"], self.jet_template_postgres) - self.assertEqual(postgres_info["name"], "Postgres") - self.assertEqual(postgres_info["reference"], "postgres") - self.assertEqual(postgres_info["level"], 1) # One level from root - self.assertEqual(len(postgres_info["dependencies"]), 1) # Depends on Docker - - # Verify Postgres dependencies - postgres_dep_ids = [dep["template_id"] for dep in postgres_info["dependencies"]] - self.assertIn(self.jet_template_docker.id, postgres_dep_ids) - - # Verify Nginx template info - nginx_info = graph[self.jet_template_nginx.id] - self.assertEqual(nginx_info["template"], self.jet_template_nginx) - self.assertEqual(nginx_info["name"], "Nginx") - self.assertEqual(nginx_info["reference"], "nginx") - self.assertEqual(nginx_info["level"], 1) # One level from root - self.assertEqual(len(nginx_info["dependencies"]), 1) # Depends on Docker - - # Verify Nginx dependencies - nginx_dep_ids = [dep["template_id"] for dep in nginx_info["dependencies"]] - self.assertIn(self.jet_template_docker.id, nginx_dep_ids) - - # Verify Docker template info - docker_info = graph[self.jet_template_docker.id] - self.assertEqual(docker_info["template"], self.jet_template_docker) - self.assertEqual(docker_info["name"], "Docker") - self.assertEqual(docker_info["reference"], "docker") - self.assertEqual(docker_info["level"], 2) # Two levels from root - self.assertEqual(len(docker_info["dependencies"]), 1) # Depends on Tower Core - - # Verify Docker dependencies - docker_dep_ids = [dep["template_id"] for dep in docker_info["dependencies"]] - self.assertIn(self.jet_template_tower_core.id, docker_dep_ids) - - # Verify Tower Core template info - tower_core_info = graph[self.jet_template_tower_core.id] - self.assertEqual(tower_core_info["template"], self.jet_template_tower_core) - self.assertEqual(tower_core_info["name"], "Tower Core") - self.assertEqual(tower_core_info["reference"], "tower_core") - self.assertEqual(tower_core_info["level"], 3) # Three levels from root - self.assertEqual(len(tower_core_info["dependencies"]), 0) # No dependencies - - def test_build_dependency_graph_circular_dependency(self): - """ - Test circular dependency detection in constraint validation. - - This test verifies that circular dependency detection correctly includes - the new dependency being created, not just existing ones from the database. - - Scenario: - A->B, B->C exist, trying to create C->A should be detected as circular. - """ - - # Create a circular dependency: A -> B -> C -> A - template_a = self.JetTemplate.create( - { - "name": "Template A", - "reference": "template_a", - } - ) - template_b = self.JetTemplate.create( - { - "name": "Template B", - "reference": "template_b", - } - ) - template_c = self.JetTemplate.create( - { - "name": "Template C", - "reference": "template_c", - } - ) - - # Create first two dependencies (A -> B -> C) - self.JetTemplateDependency.create( - { - "template_id": template_a.id, - "template_required_id": template_b.id, - "state_required_id": self.state_running.id, - } - ) - self.JetTemplateDependency.create( - { - "template_id": template_b.id, - "template_required_id": template_c.id, - "state_required_id": self.state_running.id, - } - ) - - # The third dependency (C -> A) should raise a ValidationError - with self.assertRaises(ValidationError) as context: - self.JetTemplateDependency.create( - { - "template_id": template_c.id, - "template_required_id": template_a.id, - "state_required_id": self.state_running.id, - } - ) - - # Verify the error message mentions circular reference - error_message = str(context.exception) - self.assertIn("circular reference", error_message.lower()) - self.assertIn("Template C", error_message) - - def test_build_dependency_graph_with_state_requirements(self): - """Test _build_dependency_graph with state requirements""" - # pylint: disable=protected-access - # Create a template with state requirements - template_with_state = self.JetTemplate.create( - { - "name": "Template With State", - "reference": "template_with_state", - } - ) - - # Create dependency with state requirement - self.JetTemplateDependency.create( - { - "template_id": template_with_state.id, - "template_required_id": self.jet_template_tower_core.id, - "state_required_id": self.state_running.id, - } - ) - - graph = template_with_state._build_dependency_graph() - - # Verify the dependency includes state information - template_info = graph[template_with_state.id] - self.assertEqual(len(template_info["dependencies"]), 1) - - dep_info = template_info["dependencies"][0] - self.assertEqual(dep_info["template_id"], self.jet_template_tower_core.id) - self.assertEqual(dep_info["template_name"], "Tower Core") - self.assertEqual(dep_info["template_reference"], "tower_core") - self.assertEqual(dep_info["required_state_id"], self.state_running.id) - self.assertEqual(dep_info["required_state_name"], "Test Running") - - def test_build_dependency_graph_complex_hierarchy(self): - """Test _build_dependency_graph with complex dependency hierarchy""" - # pylint: disable=protected-access - # Create a more complex hierarchy: E -> D, C -> B -> A - template_a = self.JetTemplate.create( - { - "name": "Template A", - "reference": "template_a", - } - ) - template_b = self.JetTemplate.create( - { - "name": "Template B", - "reference": "template_b", - } - ) - template_c = self.JetTemplate.create( - { - "name": "Template C", - "reference": "template_c", - } - ) - template_d = self.JetTemplate.create( - { - "name": "Template D", - "reference": "template_d", - } - ) - template_e = self.JetTemplate.create( - { - "name": "Template E", - "reference": "template_e", - } - ) - - # Create dependencies: E -> D, A -> B -> C - self.JetTemplateDependency.create( - { - "template_id": template_e.id, - "template_required_id": template_d.id, - "state_required_id": self.state_running.id, - } - ) - self.JetTemplateDependency.create( - { - "template_id": template_a.id, - "template_required_id": template_b.id, - "state_required_id": self.state_running.id, - } - ) - self.JetTemplateDependency.create( - { - "template_id": template_b.id, - "template_required_id": template_c.id, - "state_required_id": self.state_running.id, - } - ) - - # Test from template E - graph = template_e._build_dependency_graph() - - # Should contain E and D - expected_template_ids = [template_e.id, template_d.id] - self.assertEqual( - set(graph.keys()), - set(expected_template_ids), - "Should contain E and its dependencies", - ) - - # Verify levels - self.assertEqual(graph[template_e.id]["level"], 0) # Root - self.assertEqual(graph[template_d.id]["level"], 1) # One level down - - # Test from template C - graph = template_c._build_dependency_graph() - - # Should contain only C (C has no dependencies) - expected_template_ids = [template_c.id] - self.assertEqual( - set(graph.keys()), set(expected_template_ids), "Should contain only C" - ) - - # Verify levels - self.assertEqual(graph[template_c.id]["level"], 0) # Root - self.assertEqual( - len(graph[template_c.id]["dependencies"]), 0 - ) # No dependencies - - # Test from template A - should include A, B, and C - # because A depends on B, and B depends on C - graph = template_a._build_dependency_graph() - - # Should contain A, B, and C (A needs B, B needs C) - expected_template_ids = [template_a.id, template_b.id, template_c.id] - - # Check that all expected templates are in the graph - for expected_id in expected_template_ids: - self.assertIn( - expected_id, graph, f"Template {expected_id} should be in the graph" - ) - - # Check that the graph contains at least the expected templates - # (it might contain more due to other templates in the test database) - self.assertTrue( - all(template_id in graph for template_id in expected_template_ids), - f"Graph should contain at least {expected_template_ids}", - ) - - # Verify levels for the expected templates - self.assertEqual(graph[template_a.id]["level"], 0) # Root - self.assertEqual(graph[template_b.id]["level"], 1) # One level down - self.assertEqual(graph[template_c.id]["level"], 2) # Two levels down - - def test_build_dependency_graph_self_dependency(self): - """Test _build_dependency_graph with self-dependency""" - - # Create a template that depends on itself - template_self = self.JetTemplate.create( - { - "name": "Self Dependent Template", - "reference": "self_dependent_template", - } - ) - - # Creating self-dependency should raise a ValidationError - with self.assertRaises(ValidationError) as context: - self.JetTemplateDependency.create( - { - "template_id": template_self.id, - "template_required_id": template_self.id, - "state_required_id": self.state_running.id, - } - ) - - # Verify the error message mentions self-dependency - error_message = str(context.exception) - self.assertIn("cannot depend on itself", error_message.lower()) - - def test_calculate_dependency_levels_simple_chain(self): - """Test _calculate_dependency_levels with simple dependency chain""" - # pylint: disable=protected-access - # Use existing dependency chain: Odoo -> Postgres -> Docker -> Tower Core - - # Build the graph manually to test _calculate_dependency_levels - graph = { - self.jet_template_odoo.id: { - "template": self.jet_template_odoo, - "name": self.jet_template_odoo.name, - "reference": self.jet_template_odoo.reference, - "dependencies": [ - {"template_id": self.jet_template_postgres.id}, - {"template_id": self.jet_template_nginx.id}, - ], - "level": 0, # Will be calculated - }, - self.jet_template_postgres.id: { - "template": self.jet_template_postgres, - "name": self.jet_template_postgres.name, - "reference": self.jet_template_postgres.reference, - "dependencies": [{"template_id": self.jet_template_docker.id}], - "level": 0, # Will be calculated - }, - self.jet_template_docker.id: { - "template": self.jet_template_docker, - "name": self.jet_template_docker.name, - "reference": self.jet_template_docker.reference, - "dependencies": [{"template_id": self.jet_template_tower_core.id}], - "level": 0, # Will be calculated - }, - self.jet_template_tower_core.id: { - "template": self.jet_template_tower_core, - "name": self.jet_template_tower_core.name, - "reference": self.jet_template_tower_core.reference, - "dependencies": [], - "level": 0, # Will be calculated - }, - } - - # Call _calculate_dependency_levels - self.jet_template_odoo._calculate_dependency_levels(graph) - - # Verify levels - self.assertEqual( - graph[self.jet_template_odoo.id]["level"], - 0, - "Odoo should be level 0 (root)", - ) - self.assertEqual( - graph[self.jet_template_postgres.id]["level"], - 1, - "Postgres should be level 1", - ) - self.assertEqual( - graph[self.jet_template_docker.id]["level"], 2, "Docker should be level 2" - ) - self.assertEqual( - graph[self.jet_template_tower_core.id]["level"], - 3, - "Tower Core should be level 3", - ) - - def test_calculate_dependency_levels_branching_dependencies(self): - """Test _calculate_dependency_levels with branching dependencies""" - # Use existing WordPress template with branching dependencies: - # WordPress -> MariaDB/Nginx -> Docker - - # Build the graph manually - graph = { - self.jet_template_wordpress.id: { - "template": self.jet_template_wordpress, - "name": self.jet_template_wordpress.name, - "reference": self.jet_template_wordpress.reference, - "dependencies": [ - {"template_id": self.jet_template_mariadb.id}, - {"template_id": self.jet_template_nginx.id}, - ], - "level": 0, - }, - self.jet_template_mariadb.id: { - "template": self.jet_template_mariadb, - "name": self.jet_template_mariadb.name, - "reference": self.jet_template_mariadb.reference, - "dependencies": [{"template_id": self.jet_template_docker.id}], - "level": 0, - }, - self.jet_template_nginx.id: { - "template": self.jet_template_nginx, - "name": self.jet_template_nginx.name, - "reference": self.jet_template_nginx.reference, - "dependencies": [{"template_id": self.jet_template_docker.id}], - "level": 0, - }, - self.jet_template_docker.id: { - "template": self.jet_template_docker, - "name": self.jet_template_docker.name, - "reference": self.jet_template_docker.reference, - "dependencies": [{"template_id": self.jet_template_tower_core.id}], - "level": 0, - }, - self.jet_template_tower_core.id: { - "template": self.jet_template_tower_core, - "name": self.jet_template_tower_core.name, - "reference": self.jet_template_tower_core.reference, - "dependencies": [], - "level": 0, - }, - } - - # Call _calculate_dependency_levels - self.jet_template_wordpress._calculate_dependency_levels(graph) - - # Verify levels - self.assertEqual( - graph[self.jet_template_wordpress.id]["level"], - 0, - "WordPress should be level 0 (root)", - ) - self.assertEqual( - graph[self.jet_template_mariadb.id]["level"], 1, "MariaDB should be level 1" - ) - self.assertEqual( - graph[self.jet_template_nginx.id]["level"], 1, "Nginx should be level 1" - ) - self.assertEqual( - graph[self.jet_template_docker.id]["level"], - 2, - "Docker should be level 2 (shortest path from WordPress)", - ) - self.assertEqual( - graph[self.jet_template_tower_core.id]["level"], - 3, - "Tower Core should be level 3", - ) - - def test_calculate_dependency_levels_multiple_paths(self): - """Test _calculate_dependency_levels with multiple paths to same template""" - # Use existing WordPress template with multiple paths - - # Build the graph manually - graph = { - self.jet_template_wordpress.id: { - "template": self.jet_template_wordpress, - "name": self.jet_template_wordpress.name, - "reference": self.jet_template_wordpress.reference, - "dependencies": [ - {"template_id": self.jet_template_mariadb.id}, - {"template_id": self.jet_template_nginx.id}, - ], - "level": 0, - }, - self.jet_template_mariadb.id: { - "template": self.jet_template_mariadb, - "name": self.jet_template_mariadb.name, - "reference": self.jet_template_mariadb.reference, - "dependencies": [{"template_id": self.jet_template_docker.id}], - "level": 0, - }, - self.jet_template_nginx.id: { - "template": self.jet_template_nginx, - "name": self.jet_template_nginx.name, - "reference": self.jet_template_nginx.reference, - "dependencies": [{"template_id": self.jet_template_docker.id}], - "level": 0, - }, - self.jet_template_docker.id: { - "template": self.jet_template_docker, - "name": self.jet_template_docker.name, - "reference": self.jet_template_docker.reference, - "dependencies": [{"template_id": self.jet_template_tower_core.id}], - "level": 0, - }, - self.jet_template_tower_core.id: { - "template": self.jet_template_tower_core, - "name": self.jet_template_tower_core.name, - "reference": self.jet_template_tower_core.reference, - "dependencies": [], - "level": 0, - }, - } - - # Call _calculate_dependency_levels - self.jet_template_wordpress._calculate_dependency_levels(graph) - - # Verify levels - Docker should have level 2 (shortest path from WordPress) - self.assertEqual( - graph[self.jet_template_wordpress.id]["level"], - 0, - "WordPress should be level 0 (root)", - ) - self.assertEqual( - graph[self.jet_template_mariadb.id]["level"], 1, "MariaDB should be level 1" - ) - self.assertEqual( - graph[self.jet_template_nginx.id]["level"], 1, "Nginx should be level 1" - ) - self.assertEqual( - graph[self.jet_template_docker.id]["level"], - 2, - "Docker should be level 2 (shortest path)", - ) - self.assertEqual( - graph[self.jet_template_tower_core.id]["level"], - 3, - "Tower Core should be level 3", - ) - - def test_calculate_dependency_levels_empty_graph(self): - """Test _calculate_dependency_levels with empty graph""" - # pylint: disable=protected-access - # Use existing Tower Core template - - # Empty graph - graph = {} - - # Call _calculate_dependency_levels - should not raise error - self.jet_template_tower_core._calculate_dependency_levels(graph) - - # Graph should remain empty - self.assertEqual(len(graph), 0, "Empty graph should remain empty") - - def test_calculate_dependency_levels_single_template(self): - """Test _calculate_dependency_levels with single template""" - # pylint: disable=protected-access - # Use existing Tower Core template (has no dependencies) - - # Single template graph - graph = { - self.jet_template_tower_core.id: { - "template": self.jet_template_tower_core, - "name": self.jet_template_tower_core.name, - "reference": self.jet_template_tower_core.reference, - "dependencies": [], - "level": 0, - } - } - - # Call _calculate_dependency_levels - self.jet_template_tower_core._calculate_dependency_levels(graph) - - # Tower Core should be level 0 - self.assertEqual( - graph[self.jet_template_tower_core.id]["level"], - 0, - "Single template should be level 0", - ) - - def test_calculate_dependency_levels_missing_template_in_graph(self): - """Test _calculate_dependency_levels with template not in graph""" - # pylint: disable=protected-access - # Use existing Odoo template but reference a non-existent template - - # Graph with Odoo but not the referenced template - graph = { - self.jet_template_odoo.id: { - "template": self.jet_template_odoo, - "name": self.jet_template_odoo.name, - "reference": self.jet_template_odoo.reference, - "dependencies": [{"template_id": 99999}], # Non-existent template ID - "level": 0, - } - } - - # Call _calculate_dependency_levels - should handle missing template gracefully - self.jet_template_odoo._calculate_dependency_levels(graph) - - # Odoo should be level 0 - self.assertEqual( - graph[self.jet_template_odoo.id]["level"], 0, "Odoo should be level 0" - ) - - def test_calculate_dependency_levels_complex_hierarchy(self): - """Test _calculate_dependency_levels with complex hierarchy""" - # pylint: disable=protected-access - # Use existing templates with complex hierarchy - # This creates a complex hierarchy - - # Build the graph manually - only include Odoo's actual dependencies - graph = { - self.jet_template_odoo.id: { - "template": self.jet_template_odoo, - "name": self.jet_template_odoo.name, - "reference": self.jet_template_odoo.reference, - "dependencies": [ - {"template_id": self.jet_template_postgres.id}, - {"template_id": self.jet_template_nginx.id}, - ], - "level": 0, - }, - self.jet_template_postgres.id: { - "template": self.jet_template_postgres, - "name": self.jet_template_postgres.name, - "reference": self.jet_template_postgres.reference, - "dependencies": [{"template_id": self.jet_template_docker.id}], - "level": 0, - }, - self.jet_template_nginx.id: { - "template": self.jet_template_nginx, - "name": self.jet_template_nginx.name, - "reference": self.jet_template_nginx.reference, - "dependencies": [{"template_id": self.jet_template_docker.id}], - "level": 0, - }, - self.jet_template_docker.id: { - "template": self.jet_template_docker, - "name": self.jet_template_docker.name, - "reference": self.jet_template_docker.reference, - "dependencies": [{"template_id": self.jet_template_tower_core.id}], - "level": 0, - }, - self.jet_template_tower_core.id: { - "template": self.jet_template_tower_core, - "name": self.jet_template_tower_core.name, - "reference": self.jet_template_tower_core.reference, - "dependencies": [], - "level": 0, - }, - } - - # Call _calculate_dependency_levels from Odoo - self.jet_template_odoo._calculate_dependency_levels(graph) - - # Verify levels - self.assertEqual( - graph[self.jet_template_odoo.id]["level"], - 0, - "Odoo should be level 0 (root)", - ) - self.assertEqual( - graph[self.jet_template_postgres.id]["level"], - 1, - "Postgres should be level 1", - ) - self.assertEqual( - graph[self.jet_template_nginx.id]["level"], 1, "Nginx should be level 1" - ) - self.assertEqual( - graph[self.jet_template_docker.id]["level"], 2, "Docker should be level 2" - ) - self.assertEqual( - graph[self.jet_template_tower_core.id]["level"], - 3, - "Tower Core should be level 3", - ) - - # Verify that only Odoo's dependencies are in the graph - expected_template_ids = [ - self.jet_template_odoo.id, - self.jet_template_postgres.id, - self.jet_template_nginx.id, - self.jet_template_docker.id, - self.jet_template_tower_core.id, - ] - self.assertEqual( - set(graph.keys()), - set(expected_template_ids), - "Graph should only contain Odoo's dependencies", - ) - - def test_get_all_dependencies_simple_chain(self): - """Test _get_all_dependencies with simple dependency chain""" - # pylint: disable=protected-access - # Use existing Odoo dependency chain: - # Odoo -> Postgres/Nginx -> Docker -> Tower Core - - dependencies = self.jet_template_odoo._get_all_dependencies() - - # Should return all dependencies in level order (closest first) - expected_dependencies = { - self.jet_template_postgres, - self.jet_template_nginx, - self.jet_template_docker, - self.jet_template_tower_core, - } - self.assertEqual( - set(dependencies), - expected_dependencies, - "Should return all expected dependencies", - ) - - # Verify the order is correct (level 1, then level 2, then level 3) - # Postgres and Nginx should be first (level 1) - self.assertIn( - self.jet_template_postgres, - dependencies[:2], - "Postgres should be in first two dependencies", - ) - self.assertIn( - self.jet_template_nginx, - dependencies[:2], - "Nginx should be in first two dependencies", - ) - - # Docker should be third (level 2) - self.assertEqual( - dependencies[2], self.jet_template_docker, "Docker should be third" - ) - - # Tower Core should be last (level 3) - self.assertEqual( - dependencies[3], self.jet_template_tower_core, "Tower Core should be last" - ) - - def test_get_all_dependencies_no_dependencies(self): - """Test _get_all_dependencies with template that has no dependencies""" - # pylint: disable=protected-access - # Use Tower Core which has no dependencies - - dependencies = self.jet_template_tower_core._get_all_dependencies() - - # Should return empty list - self.assertEqual( - dependencies, - [], - "Should return empty list for template with no dependencies", - ) - - def test_get_all_dependencies_wordpress_chain(self): - """Test _get_all_dependencies with WordPress dependency chain""" - # pylint: disable=protected-access - # Use WordPress dependency chain: - # WordPress -> MariaDB/Nginx -> Docker -> Tower Core - - dependencies = self.jet_template_wordpress._get_all_dependencies() - - # Should return all dependencies in level order - expected_dependencies = { - self.jet_template_mariadb, - self.jet_template_nginx, - self.jet_template_docker, - self.jet_template_tower_core, - } - self.assertEqual( - set(dependencies), - expected_dependencies, - "Should return all expected dependencies", - ) - - # Verify the order is correct - # MariaDB and Nginx should be first (level 1) - self.assertIn( - self.jet_template_mariadb, - dependencies[:2], - "MariaDB should be in first two dependencies", - ) - self.assertIn( - self.jet_template_nginx, - dependencies[:2], - "Nginx should be in first two dependencies", - ) - - # Docker should be third (level 2) - self.assertEqual( - dependencies[2], self.jet_template_docker, "Docker should be third" - ) - - # Tower Core should be last (level 3) - self.assertEqual( - dependencies[3], self.jet_template_tower_core, "Tower Core should be last" - ) - - def test_get_all_dependencies_docker_chain(self): - """Test _get_all_dependencies with Docker dependency chain""" - # pylint: disable=protected-access - # Use Docker dependency chain: Docker -> Tower Core - - dependencies = self.jet_template_docker._get_all_dependencies() - - # Should return only Tower Core - expected_dependencies = [self.jet_template_tower_core] - self.assertEqual( - dependencies, expected_dependencies, "Should return only Tower Core" - ) - - def test_get_all_dependencies_nginx_chain(self): - """Test _get_all_dependencies with Nginx dependency chain""" - # pylint: disable=protected-access - # Use Nginx dependency chain: Nginx -> Docker -> Tower Core - - dependencies = self.jet_template_nginx._get_all_dependencies() - - # Should return Docker and Tower Core - expected_dependencies = [self.jet_template_docker, self.jet_template_tower_core] - self.assertEqual( - dependencies, expected_dependencies, "Should return Docker and Tower Core" - ) - - def test_get_all_dependencies_complex_scenario(self): - """Test _get_all_dependencies with complex dependency scenario""" - # pylint: disable=protected-access - # Use existing WooCommerce with Odoo template - # This tests the scenario where a template has multiple dependency paths - - dependencies = self.jet_template_woocommerce_odoo._get_all_dependencies() - - # Should include all dependencies from both Odoo and WordPress - # Expected: Odoo, WordPress, Postgres, MariaDB, Nginx, Docker, Tower Core - expected_template_ids = [ - self.jet_template_odoo.id, - self.jet_template_wordpress.id, - self.jet_template_postgres.id, - self.jet_template_mariadb.id, - self.jet_template_nginx.id, - self.jet_template_docker.id, - self.jet_template_tower_core.id, - ] - - actual_template_ids = [dep.id for dep in dependencies] - self.assertEqual( - set(actual_template_ids), - set(expected_template_ids), - "Should include all dependencies from both Odoo and WordPress", - ) - - # Verify that dependencies are ordered by level - # Level 1: Odoo, WordPress - # Level 2: Postgres, MariaDB, Nginx - # Level 3: Docker - # Level 4: Tower Core - - # Check that Odoo and WordPress are in the first two positions - self.assertIn( - self.jet_template_odoo, - dependencies[:2], - "Odoo should be in first two dependencies", - ) - self.assertIn( - self.jet_template_wordpress, - dependencies[:2], - "WordPress should be in first two dependencies", - ) - - # Check that Tower Core is last - self.assertEqual( - dependencies[-1], self.jet_template_tower_core, "Tower Core should be last" - ) - - def test_get_all_dependencies_excludes_self(self): - """Test _get_all_dependencies excludes the template itself""" - # pylint: disable=protected-access - # Use Odoo template - - dependencies = self.jet_template_odoo._get_all_dependencies() - - # Should not include Odoo itself - self.assertNotIn( - self.jet_template_odoo, - dependencies, - "Should not include the template itself", - ) - - # Verify all returned dependencies are different from the root template - for dependency in dependencies: - self.assertNotEqual( - dependency.id, - self.jet_template_odoo.id, - f"Should not include template with ID {dependency.id}", - ) - - def test_get_all_dependencies_same_level_must_order_transitive_edges(self): - """ - If root A depends on B and C directly, and C also depends on B, then B and - C share the same shortest-path level. Install lines use reverse order by - ``order``; the dependency list must place C before B so B gets a higher - line order and is installed before C. - """ - # pylint: disable=protected-access - tpl_b = self.JetTemplate.create( - { - "name": "Topo Base B", - "reference": "topo_base_b", - } - ) - tpl_c = self.JetTemplate.create( - { - "name": "Topo Mid C", - "reference": "topo_mid_c", - } - ) - tpl_a = self.JetTemplate.create( - { - "name": "Topo Root A", - "reference": "topo_root_a", - } - ) - # C depends on B - self.JetTemplateDependency.create( - { - "template_id": tpl_c.id, - "template_required_id": tpl_b.id, - "state_required_id": self.state_running.id, - } - ) - # A depends on C first, then B so graph traversal tends to visit B before C - # in ``graph.items()`` while both stay at level 1. - self.JetTemplateDependency.create( - { - "template_id": tpl_a.id, - "template_required_id": tpl_c.id, - "state_required_id": self.state_running.id, - } - ) - self.JetTemplateDependency.create( - { - "template_id": tpl_a.id, - "template_required_id": tpl_b.id, - "state_required_id": self.state_running.id, - } - ) - - dependencies = tpl_a._get_all_dependencies() - idx_b = next(i for i, t in enumerate(dependencies) if t.id == tpl_b.id) - idx_c = next(i for i, t in enumerate(dependencies) if t.id == tpl_c.id) - - self.assertLess( - idx_c, - idx_b, - "C must appear before B in dependency order so install (reverse order)" - " runs B before C when C depends on B", - ) - - def test_get_all_dependencies_consistency_with_build_graph(self): - """ - _get_all_dependencies must return dependents before their prerequisites. - - Correctness is verified against the graph edges directly (the topological - invariant) rather than re-running _topological_sort_dependency_graph, which - would create a circular check where a bug in the sort masks itself. - """ - # pylint: disable=protected-access - graph = self.jet_template_odoo._build_dependency_graph() - dependencies = self.jet_template_odoo._get_all_dependencies() - - self.assertTrue(dependencies, "Expected a non-empty dependency list") - - index = {tmpl.id: i for i, tmpl in enumerate(dependencies)} - - for u_id, info in graph.items(): - if u_id not in index: - continue - for dep in info["dependencies"]: - v_id = dep["template_id"] - if v_id not in index: - continue - self.assertLess( - index[u_id], - index[v_id], - f"{graph[u_id]['name']} (dependent) must appear before " - f"{graph[v_id]['name']} (prerequisite)", - ) - - def test_get_all_dependencies_woocommerce_odoo_chain(self): - """Test _get_all_dependencies with WooCommerce with Odoo dependency chain""" - # pylint: disable=protected-access - # Use WooCommerce with Odoo dependency chain: - # WooCommerce -> WordPress/Odoo -> - # MariaDB/Postgres/Nginx -> Docker -> Tower Core - - dependencies = self.jet_template_woocommerce_odoo._get_all_dependencies() - - # Should include all dependencies from both WordPress and Odoo - # Expected: WordPress, Odoo, MariaDB, Postgres, Nginx, Docker, Tower Core - expected_template_ids = [ - self.jet_template_wordpress.id, - self.jet_template_odoo.id, - self.jet_template_mariadb.id, - self.jet_template_postgres.id, - self.jet_template_nginx.id, - self.jet_template_docker.id, - self.jet_template_tower_core.id, - ] - - actual_template_ids = [dep.id for dep in dependencies] - self.assertEqual( - set(actual_template_ids), - set(expected_template_ids), - "Should include all dependencies from both WordPress and Odoo", - ) - - # Verify that dependencies are ordered by level - # Level 1: WordPress, Odoo - # Level 2: MariaDB, Postgres, Nginx - # Level 3: Docker - # Level 4: Tower Core - - # Check that WordPress and Odoo are in the first two positions - self.assertIn( - self.jet_template_wordpress, - dependencies[:2], - "WordPress should be in first two dependencies", - ) - self.assertIn( - self.jet_template_odoo, - dependencies[:2], - "Odoo should be in first two dependencies", - ) - - # Check that Tower Core is last - self.assertEqual( - dependencies[-1], self.jet_template_tower_core, "Tower Core should be last" - ) - - # Verify that all level 2 dependencies are present - level_2_deps = [ - self.jet_template_mariadb, - self.jet_template_postgres, - self.jet_template_nginx, - ] - for dep in level_2_deps: - self.assertIn(dep, dependencies, f"{dep.name} should be in dependencies") - - # Verify that Docker is present - self.assertIn( - self.jet_template_docker, dependencies, "Docker should be in dependencies" - ) - - def test_get_action_path_with_destroy_action_only(self): - """ - Test _get_action_path with only destroy action set - """ - # Create states - state_running = self.JetState.create( - { - "name": "Running", - "reference": "running", - "sequence": 20, - } - ) - state_stopped = self.JetState.create( - { - "name": "Stopped", - "reference": "stopped", - "sequence": 30, - } - ) - - # Create destroy action - destroy_action = self.JetAction.create( - { - "name": "Destroy Action", - "reference": "destroy_action", - "jet_template_id": self.clean_template.id, - "state_from_id": state_running.id, - "state_to_id": False, - "state_transit_id": state_stopped.id, - "priority": 10, - } - ) - - # Set destroy action - self.clean_template.action_destroy_id = destroy_action - - # Test path without state_to (should use destroy action) - result = self.clean_template._get_action_path(state_from=state_running) - self.assertEqual( - result, - [destroy_action], - "Should return destroy action when no state_to provided", - ) - - # Test path with state_to (should not use destroy action) - result = self.clean_template._get_action_path( - state_from=state_running, state_to=state_stopped - ) - self.assertEqual( - result, - [], - "Should return empty list when state_to provided and no path exists", - ) - - def test_get_action_path_same_state(self): - """ - Test _get_action_path when start and end states are the same - """ - # Test same state without destroy action - result = self.clean_template._get_action_path( - state_from=self.state_a, state_to=self.state_a - ) - self.assertEqual( - result, [], "Should return empty list for same start and end state" - ) - - # Create destroy action - destroy_action = self.JetAction.create( - { - "name": "Destroy Action", - "reference": "destroy_action", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": False, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - self.clean_template.action_destroy_id = destroy_action - - # Test same state with destroy action (no state_to provided) - result = self.clean_template._get_action_path(state_from=self.state_a) - self.assertEqual( - result, - [destroy_action], - "Should return destroy action for same state when no state_to provided", - ) - - def test_get_action_path_direct_path(self): - """ - Test _get_action_path with direct path between states - """ - # Create direct action - action_ab = self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - - # Test direct path - result = self.clean_template._get_action_path( - state_from=self.state_a, state_to=self.state_b - ) - self.assertEqual(result, [action_ab], "Should return direct action path") - - def test_get_action_path_multi_step_path(self): - """ - Test _get_action_path with multi-step path - """ - # Create multi-step actions - action_ab = self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - action_bc = self.JetAction.create( - { - "name": "Action B to C", - "reference": "action_bc", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_b.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Test multi-step path - result = self.clean_template._get_action_path( - state_from=self.state_a, state_to=self.state_c - ) - expected_path = [action_ab, action_bc] - self.assertEqual(result, expected_path, "Should return multi-step action path") - - def test_get_action_path_with_create_and_multi_step(self): - """ - Test _get_action_path with create action and multi-step path - """ - # Create create action - create_action = self.JetAction.create( - { - "name": "Create Action", - "reference": "create_action", - "jet_template_id": self.clean_template.id, - "state_from_id": False, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_a.id, - "priority": 10, - } - ) - - # Create transition action - action_rs = self.JetAction.create( - { - "name": "Action Running to Stopped", - "reference": "action_rs", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_b.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_c.id, - "priority": 10, - } - ) - - # Set create action - self.clean_template.action_create_id = create_action - - # Test path from create to final state - result = self.clean_template._get_action_path(state_to=self.state_c) - expected_path = [create_action, action_rs] - self.assertEqual( - result, expected_path, "Should return create action + transition path" - ) - - def test_get_action_path_with_multi_step_and_destroy(self): - """ - Test _get_action_path with multi-step path and destroy action - """ - # Create multi-step actions - action_ab = self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - action_bc = self.JetAction.create( - { - "name": "Action B to C", - "reference": "action_bc", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_b.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Create destroy action - destroy_action = self.JetAction.create( - { - "name": "Destroy Action", - "reference": "destroy_action", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_c.id, - "state_to_id": False, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Set destroy action - self.clean_template.action_destroy_id = destroy_action - - # Test path from A to destroy - result = self.clean_template._get_action_path(state_from=self.state_a) - expected_path = [action_ab, action_bc, destroy_action] - self.assertEqual( - result, expected_path, "Should return multi-step path + destroy action" - ) - - def test_get_action_path_complete_lifecycle(self): - """ - Test _get_action_path with complete lifecycle (create -> multi-step -> destroy) - """ - # Create create action - create_action = self.JetAction.create( - { - "name": "Create Action", - "reference": "create_action", - "jet_template_id": self.clean_template.id, - "state_from_id": False, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_a.id, - "priority": 10, - } - ) - - # Create transition action - action_rs = self.JetAction.create( - { - "name": "Action Running to Stopped", - "reference": "action_rs", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_b.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_c.id, - "priority": 10, - } - ) - - # Create destroy action - destroy_action = self.JetAction.create( - { - "name": "Destroy Action", - "reference": "destroy_action", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_c.id, - "state_to_id": False, - "state_transit_id": self.state_c.id, - "priority": 10, - } - ) - - # Set border actions - self.clean_template.action_create_id = create_action - self.clean_template.action_destroy_id = destroy_action - - # Test complete lifecycle - result = self.clean_template._get_action_path() - expected_path = [create_action, action_rs, destroy_action] - self.assertEqual(result, expected_path, "Should return complete lifecycle path") - - def test_get_action_path_no_path_exists(self): - """ - Test _get_action_path when no path exists between states - """ - # Create action that doesn't connect A to C - self.JetAction.create( - { - "name": "Action B to C", - "reference": "action_bc", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_b.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Test path from A to C (no path exists) - result = self.clean_template._get_action_path( - state_from=self.state_a, state_to=self.state_c - ) - self.assertEqual(result, [], "Should return empty list when no path exists") - - def test_get_action_path_complex_multi_level_path(self): - """ - Test _get_action_path with complex multi-level path - """ - # Create additional states for this test - state_e = self.JetState.create( - { - "name": "State E", - "reference": "state_e", - "sequence": 50, - } - ) - - # Create complex multi-level actions - action_ab = self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - action_bc = self.JetAction.create( - { - "name": "Action B to C", - "reference": "action_bc", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_b.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - action_cd = self.JetAction.create( - { - "name": "Action C to D", - "reference": "action_cd", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_c.id, - "state_to_id": self.state_d.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - action_de = self.JetAction.create( - { - "name": "Action D to E", - "reference": "action_de", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_d.id, - "state_to_id": state_e.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Test complex multi-level path - result = self.clean_template._get_action_path( - state_from=self.state_a, state_to=state_e - ) - expected_path = [action_ab, action_bc, action_cd, action_de] - self.assertEqual( - result, expected_path, "Should return complex multi-level path" - ) - - def test_get_action_path_shortest_path_selection(self): - """ - Test _get_action_path selects shortest path when multiple paths exist - """ - # Create short path: A -> C - action_ac = self.JetAction.create( - { - "name": "Action A to C (short)", - "reference": "action_ac", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Create long path: A -> B -> D -> C - self.JetAction.create( - { - "name": "Action A to B", - "reference": "action_ab", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_a.id, - "state_to_id": self.state_b.id, - "state_transit_id": self.state_starting.id, - "priority": 10, - } - ) - self.JetAction.create( - { - "name": "Action B to D", - "reference": "action_bd", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_b.id, - "state_to_id": self.state_d.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - self.JetAction.create( - { - "name": "Action D to C", - "reference": "action_dc", - "jet_template_id": self.clean_template.id, - "state_from_id": self.state_d.id, - "state_to_id": self.state_c.id, - "state_transit_id": self.state_stopping.id, - "priority": 10, - } - ) - - # Test that shortest path is selected - result = self.clean_template._get_action_path( - state_from=self.state_a, state_to=self.state_c - ) - expected_path = [action_ac] # Shortest path - self.assertEqual( - result, - expected_path, - "Should select shortest path when multiple paths exist", - ) - - def test_check_dependency_satisfaction_no_dependencies(self): - """Test _check_dependency_satisfaction when template has no dependencies""" - # pylint: disable=protected-access - server = self.server_test_1 - - # Test with template that has no dependencies - missing_templates = self.jet_template_tower_core._check_dependency_satisfaction( - server - ) - - # Should return empty list since tower_core has no dependencies - self.assertEqual( - len(missing_templates), - 0, - "Should return empty list when no dependencies exist", - ) - - def test_check_dependency_satisfaction_all_missing(self): - """Test _check_dependency_satisfaction when all dependencies are missing""" - # pylint: disable=protected-access - server = self.server_test_1 - - # Test with different templates that have dependencies - templates_to_test = [ - self.jet_template_nginx, - self.jet_template_odoo, - self.jet_template_woocommerce_odoo, - ] - - for template in templates_to_test: - # Get actual dependencies for template - all_deps = template._get_all_dependencies() - - # Test - should return all missing dependencies - missing_templates = template._check_dependency_satisfaction(server) - - # Should return all dependencies since none are installed - expected_dependencies = set(all_deps) - actual_dependencies = set(missing_templates) - self.assertEqual( - actual_dependencies, - expected_dependencies, - f"Should return all missing dependencies for {template.name}", - ) - - def test_check_dependency_satisfaction_all_satisfied(self): - """Test _check_dependency_satisfaction when all dependencies are satisfied""" - # pylint: disable=protected-access - server = self.server_test_1 - - # Test with different templates that have dependencies - templates_to_test = [ - self.jet_template_nginx, - self.jet_template_odoo, - self.jet_template_woocommerce_odoo, - ] - - for template in templates_to_test: - # Install all dependencies for this template - all_deps = template._get_all_dependencies() - for dep_template in all_deps: - dep_template.server_ids = [(4, server.id)] - - # Test - should return empty list - missing_templates = template._check_dependency_satisfaction(server) - - # Should return empty list since all dependencies are now installed - self.assertEqual( - len(missing_templates), - 0, - f"Should return empty list for {template.name}", - ) - - def test_check_dependency_satisfaction_partial_installation(self): - """Test _check_dependency_satisfaction with partial installation""" - # pylint: disable=protected-access - server = self.server_test_1 - - # Get all dependencies for odoo - all_deps = self.jet_template_odoo._get_all_dependencies() - - # Install some dependencies but not all (install first half) - half_count = len(all_deps) // 2 - for i, dep in enumerate(all_deps): - if i < half_count: - dep.server_ids = [(4, server.id)] - - # Test with odoo - missing_templates = self.jet_template_odoo._check_dependency_satisfaction( - server - ) - - # Should return the remaining uninstalled dependencies - expected_missing = set(all_deps[half_count:]) - actual_missing = set(missing_templates) - self.assertEqual( - actual_missing, - expected_missing, - "Should return only the missing dependencies", - ) - - def test_check_dependency_satisfaction_no_server(self): - """Test _check_dependency_satisfaction when server is None""" - # pylint: disable=protected-access - - # Test with odoo and None server - missing_templates = self.jet_template_odoo._check_dependency_satisfaction(None) - - # Should return empty list when server is None (no server to check against) - self.assertEqual( - len(missing_templates), 0, "Should return empty list when server is None" - ) - - def test_check_dependency_satisfaction_multiple_servers(self): - """Test _check_dependency_satisfaction with different server states""" - # pylint: disable=protected-access - server1 = self.server_test_1 - server2 = self.server_test_2 - - # Get actual dependencies for nginx - all_deps = self.jet_template_nginx._get_all_dependencies() - - # Install all dependencies on server1 - for dep in all_deps: - dep.server_ids = [(4, server1.id)] - - # Test with nginx on both servers - missing_templates_server1 = ( - self.jet_template_nginx._check_dependency_satisfaction(server1) - ) - missing_templates_server2 = ( - self.jet_template_nginx._check_dependency_satisfaction(server2) - ) - - # Server1 should have no missing dependencies - self.assertEqual( - len(missing_templates_server1), - 0, - "Server1 should have no missing dependencies", - ) - self.assertEqual( - len(missing_templates_server2), - len(all_deps), - "Server2 should have all dependencies missing", - ) - - # Verify server2 has all the expected missing dependencies - expected_missing_server2 = set(all_deps) - actual_missing_server2 = set(missing_templates_server2) - self.assertEqual( - actual_missing_server2, - expected_missing_server2, - "Server2 should be missing all dependencies", - ) - - def test_check_dependency_satisfaction_self_dependency(self): - """Test _check_dependency_satisfaction with template that depends on itself""" - # pylint: disable=protected-access - server = self.server_test_1 - - # Create a template that depends on itself - # But let's test the method behavior anyway - self_loop_template = self.JetTemplate.create( - { - "name": "Self Loop Template", - "reference": "self_loop_template", - } - ) - - # Manually create a dependency record (this would normally be prevented) - # We'll test the method's behavior when it encounters this situation - missing_templates = self_loop_template._check_dependency_satisfaction(server) - - # Should return empty list since template has no dependencies - self.assertEqual( - len(missing_templates), - 0, - "Should return empty list for template with no dependencies", - ) - - def test_get_all_depend_on_this_no_dependents(self): - """Test _get_all_depend_on_this when template has no dependents""" - # pylint: disable=protected-access - - # Test with woocommerce_odoo which should have no dependents - dependents = self.jet_template_woocommerce_odoo._get_all_depend_on_this() - - # Should return empty recordset since no templates depend on woocommerce_odoo - self.assertEqual( - len(dependents), - 0, - "Should return empty recordset when no templates depend on this one", - ) - - def test_get_all_depend_on_this_docker_dependents(self): - """Test _get_all_depend_on_this with docker's dependents""" - # pylint: disable=protected-access - - # Test with docker - should have all dependents (direct and indirect) - dependents = self.jet_template_docker._get_all_depend_on_this() - - # Should return all templates that depend on docker (directly or indirectly) - # docker -> nginx/postgres/mariadb -> odoo/wordpress -> woocommerce_odoo - expected_dependents = { - self.jet_template_nginx, - self.jet_template_postgres, - self.jet_template_mariadb, - self.jet_template_odoo, - self.jet_template_wordpress, - self.jet_template_woocommerce_odoo, - } - actual_dependents = set(dependents) - - # Filter out any templates that aren't in the expected set - # (some tests might have created additional dependencies) - actual_dependents_filtered = { - t for t in actual_dependents if t in expected_dependents - } - - self.assertEqual( - actual_dependents_filtered, - expected_dependents, - "Should return all dependents of docker", - ) - - def test_get_all_depend_on_this_indirect_dependents(self): - """Test _get_all_depend_on_this with indirect dependents""" - # pylint: disable=protected-access - - # Test with tower_core - should have many indirect dependents - dependents = self.jet_template_tower_core._get_all_depend_on_this() - - # Should return all templates that depend on tower_core (directly or indirectly) - # tower_core -> docker -> nginx/postgres -> odoo/wordpress -> woocommerce_odoo - expected_dependents = { - self.jet_template_docker, - self.jet_template_nginx, - self.jet_template_postgres, - self.jet_template_mariadb, - self.jet_template_odoo, - self.jet_template_wordpress, - self.jet_template_woocommerce_odoo, - } - actual_dependents = set(dependents) - - # Filter out any templates that aren't in the expected set - # (some tests might have created additional dependencies) - actual_dependents_filtered = { - t for t in actual_dependents if t in expected_dependents - } - - self.assertEqual( - actual_dependents_filtered, - expected_dependents, - "Should return all dependents including indirect ones", - ) - - def test_get_all_depend_on_this_complex_hierarchy(self): - """Test _get_all_depend_on_this with complex dependency hierarchy""" - # pylint: disable=protected-access - - # Test with nginx - should have odoo, wordpress, and woocommerce_odoo - dependents = self.jet_template_nginx._get_all_depend_on_this() - - # Should return odoo, wordpress, and woocommerce_odoo - expected_dependents = { - self.jet_template_odoo, - self.jet_template_wordpress, - self.jet_template_woocommerce_odoo, - } - actual_dependents = set(dependents) - - # Filter out any templates that aren't in the expected set - # (some tests might have created additional dependencies) - actual_dependents_filtered = { - t for t in actual_dependents if t in expected_dependents - } - - self.assertEqual( - actual_dependents_filtered, - expected_dependents, - "Should return all dependents in complex hierarchy", - ) - - def test_get_all_depend_on_this_multiple_levels(self): - """Test _get_all_depend_on_this with multiple dependency levels""" - # pylint: disable=protected-access - - # Test with postgres - should have odoo and woocommerce_odoo as dependents - dependents = self.jet_template_postgres._get_all_depend_on_this() - - # Should return odoo and woocommerce_odoo - expected_dependents = { - self.jet_template_odoo, - self.jet_template_woocommerce_odoo, - } - actual_dependents = set(dependents) - - # Filter out any templates that aren't in the expected set - # (some tests might have created additional dependencies) - actual_dependents_filtered = { - t for t in actual_dependents if t in expected_dependents - } - - self.assertEqual( - actual_dependents_filtered, - expected_dependents, - "Should return dependents across multiple levels", - ) - - def test_get_all_depend_on_this_self_dependency(self): - """Test _get_all_depend_on_this with template that has no dependents""" - # pylint: disable=protected-access - - # Test with a template that has no dependents - dependents = self.jet_template_woocommerce_odoo._get_all_depend_on_this() - - # Should return empty recordset - self.assertEqual( - len(dependents), - 0, - "Should return empty recordset for template with no dependents", - ) - - def test_get_all_depend_on_this_consistency_with_dependencies(self): - """Test that _get_all_depend_on_this is consistent with _get_all_dependencies""" - # pylint: disable=protected-access - - # For each template, check that its dependents are consistent - templates_to_test = [ - self.jet_template_tower_core, - self.jet_template_docker, - self.jet_template_nginx, - self.jet_template_postgres, - self.jet_template_odoo, - ] - - for template in templates_to_test: - # Get templates that depend on this template - dependents = template._get_all_depend_on_this() - - # For each dependent, check that this template is in its dependencies - for dependent in dependents: - dependent_deps = dependent._get_all_dependencies() - self.assertIn( - template, - dependent_deps, - f"{dependent.name} should have {template.name} in its dependencies", - ) - - def test_get_all_depend_on_this_circular_dependency_handling(self): - """Test _get_all_depend_on_this handles circular dependencies correctly""" - # pylint: disable=protected-access - - # Test with templates that might have circular dependencies - # This test ensures the method doesn't get stuck in infinite loops - templates_to_test = [ - self.jet_template_tower_core, - self.jet_template_docker, - self.jet_template_nginx, - self.jet_template_postgres, - self.jet_template_odoo, - ] - - for template in templates_to_test: - # This should not raise an exception or get stuck - dependents = template._get_all_depend_on_this() - - # Should return a valid recordset - self.assertIsInstance( - dependents, self.env["cx.tower.jet.template"].__class__ - ) - - # Should not include the template itself - self.assertNotIn( - template, dependents, "Template should not depend on itself" - ) - - def test_create_jet_with_server_logs(self): - """Test create_jet creates server logs correctly""" - # Create a file template for server logs - file_template = self.FileTemplate.create( - { - "name": "Test Log File Template", - "file_name": "test_log.txt", - "source": "tower", - "server_dir": "/var/log", - "code": "Test log content", - } - ) - - # Create server logs on the template - server_log_file = self.ServerLog.create( - { - "name": "Test File Log", - "server_id": self.server_test_1.id, - "jet_template_id": self.jet_template_test.id, - "log_type": "file", - "file_template_id": file_template.id, - "access_level": "1", - } - ) - - server_log_command = self.ServerLog.create( - { - "name": "Test Command Log", - "server_id": self.server_test_1.id, - "jet_template_id": self.jet_template_test.id, - "log_type": "command", - "command_id": self.command_list_dir.id, - "access_level": "1", - } - ) - - # Ensure template is installed on server - self.jet_template_test.write({"server_ids": [(4, self.server_test_1.id)]}) - - # Create jet from template - jet = self.jet_template_test.create_jet( - server=self.server_test_1, name="Test Jet with Logs" - ) - - # Verify jet was created - self.assertTrue(jet, "Jet should be created") - self.assertEqual(jet.name, "Test Jet with Logs") - self.assertEqual(jet.server_id, self.server_test_1) - self.assertEqual(jet.jet_template_id, self.jet_template_test) - - # Verify server logs were created for the jet - jet_logs = self.ServerLog.search([("jet_id", "=", jet.id)]) - self.assertEqual( - len(jet_logs), - 2, - "Should create 2 server logs (one file, one command)", - ) - - # Verify file-type log - jet_log_file = jet_logs.filtered(lambda log: log.log_type == "file") - self.assertEqual( - len(jet_log_file), - 1, - "Should have exactly one file-type log", - ) - jet_log_file = jet_log_file[0] # Get single record - self.assertEqual( - jet_log_file.jet_id, - jet, - "File log should be linked to the jet", - ) - self.assertEqual( - jet_log_file.server_id, - self.server_test_1, - "File log should be linked to the server", - ) - self.assertFalse( - jet_log_file.jet_template_id, - "File log should not be linked to template", - ) - self.assertTrue( - jet_log_file.file_id, - "File log should have a file created", - ) - self.assertEqual( - jet_log_file.file_template_id, - server_log_file.file_template_id, - "File log should reference the same file template as template log", - ) - self.assertEqual( - jet_log_file.name, - server_log_file.name, - "File log should have the same name as template log", - ) - self.assertEqual( - jet_log_file.file_id.jet_id, - jet, - "Created file should be linked to the jet", - ) - self.assertEqual( - jet_log_file.file_id.server_id, - self.server_test_1, - "Created file should be linked to the server", - ) - - # Verify command-type log - jet_log_command = jet_logs.filtered(lambda log: log.log_type == "command") - self.assertEqual( - len(jet_log_command), - 1, - "Should have exactly one command-type log", - ) - jet_log_command = jet_log_command[0] # Get single record - self.assertEqual( - jet_log_command.jet_id, - jet, - "Command log should be linked to the jet", - ) - self.assertEqual( - jet_log_command.server_id, - self.server_test_1, - "Command log should be linked to the server", - ) - self.assertFalse( - jet_log_command.jet_template_id, - "Command log should not be linked to template", - ) - self.assertFalse( - jet_log_command.file_id, - "Command log should not have a file", - ) - self.assertEqual( - jet_log_command.command_id, - server_log_command.command_id, - "Command log should reference the same command as template log", - ) - self.assertEqual( - jet_log_command.name, - server_log_command.name, - "Command log should have the same name as template log", - ) - - # Verify original template logs are unchanged - template_logs = self.ServerLog.search( - [("jet_template_id", "=", self.jet_template_test.id)] - ) - self.assertIn( - server_log_file, - template_logs, - "Template file log should still exist", - ) - self.assertIn( - server_log_command, - template_logs, - "Template command log should still exist", - ) - self.assertFalse( - server_log_file.jet_id, - "Template file log should not be linked to any jet", - ) - self.assertFalse( - server_log_command.jet_id, - "Template command log should not be linked to any jet", - ) - - def test_create_jet_with_multiple_file_logs(self): - """Test create_jet creates multiple file logs correctly""" - # Create multiple file templates - file_template_1 = self.FileTemplate.create( - { - "name": "Log File Template 1", - "file_name": "log1.txt", - "source": "tower", - "server_dir": "/var/log", - "code": "Log 1 content", - } - ) - - file_template_2 = self.FileTemplate.create( - { - "name": "Log File Template 2", - "file_name": "log2.txt", - "source": "tower", - "server_dir": "/var/log", - "code": "Log 2 content", - } - ) - - # Create multiple server logs on the template - self.ServerLog.create( - { - "name": "File Log 1", - "server_id": self.server_test_1.id, - "jet_template_id": self.jet_template_test.id, - "log_type": "file", - "file_template_id": file_template_1.id, - "access_level": "1", - } - ) - - self.ServerLog.create( - { - "name": "File Log 2", - "server_id": self.server_test_1.id, - "jet_template_id": self.jet_template_test.id, - "log_type": "file", - "file_template_id": file_template_2.id, - "access_level": "2", - } - ) - - # Ensure template is installed on server - self.jet_template_test.write({"server_ids": [(4, self.server_test_1.id)]}) - - # Create jet from template - jet = self.jet_template_test.create_jet( - server=self.server_test_1, name="Test Jet Multiple Files" - ) - - # Verify all file logs were created - jet_logs = self.ServerLog.search([("jet_id", "=", jet.id)]) - file_logs = jet_logs.filtered(lambda log: log.log_type == "file") - self.assertEqual( - len(file_logs), - 2, - "Should create 2 file logs", - ) - - # Verify each file log has its own file - files = file_logs.mapped("file_id") - self.assertEqual( - len(files), - 2, - "Should create 2 files", - ) - self.assertEqual( - len(set(files.ids)), - 2, - "Files should be different", - ) - - # Verify files are linked correctly - for log in file_logs: - self.assertTrue(log.file_id, "Each log should have a file") - self.assertEqual( - log.file_id.jet_id, - jet, - "File should be linked to the jet", - ) - self.assertEqual( - log.file_id.server_id, - self.server_test_1, - "File should be linked to the server", - ) - - def test_create_jet_with_no_server_logs(self): - """Test create_jet works correctly when template has no server logs""" - # Ensure template has no server logs - self.jet_template_test.server_log_ids.unlink() - - # Ensure template is installed on server - self.jet_template_test.write({"server_ids": [(4, self.server_test_1.id)]}) - - # Create jet from template - jet = self.jet_template_test.create_jet( - server=self.server_test_1, name="Test Jet No Logs" - ) - - # Verify jet was created - self.assertTrue(jet, "Jet should be created") - - # Verify no server logs were created - jet_logs = self.ServerLog.search([("jet_id", "=", jet.id)]) - self.assertEqual( - len(jet_logs), - 0, - "Should not create any server logs when template has none", - ) - - def test_create_jet_server_logs_fields_copied(self): - """Test that server log fields are correctly copied from template""" - # Create a file template - file_template = self.FileTemplate.create( - { - "name": "Test Log File Template", - "file_name": "test_log.txt", - "source": "tower", - "server_dir": "/var/log", - "code": "Test log content", - } - ) - - # Create server log with various fields - server_log = self.ServerLog.create( - { - "name": "Test Log with Fields", - "server_id": self.server_test_1.id, - "jet_template_id": self.jet_template_test.id, - "log_type": "file", - "file_template_id": file_template.id, - "access_level": "2", - "use_sudo": True, - "reference": "test_log_ref", - } - ) - - # Ensure template is installed on server - self.jet_template_test.write({"server_ids": [(4, self.server_test_1.id)]}) - - # Create jet from template - jet = self.jet_template_test.create_jet( - server=self.server_test_1, name="Test Jet Fields" - ) - - # Find the created log - jet_log = self.ServerLog.search([("jet_id", "=", jet.id)], limit=1) - - # Verify fields are copied correctly - self.assertEqual( - jet_log.name, - server_log.name, - "Log name should be copied", - ) - self.assertEqual( - jet_log.log_type, - server_log.log_type, - "Log type should be copied", - ) - self.assertEqual( - jet_log.file_template_id, - server_log.file_template_id, - "File template should be copied", - ) - self.assertEqual( - jet_log.access_level, - server_log.access_level, - "Access level should be copied", - ) - self.assertEqual( - jet_log.use_sudo, - server_log.use_sudo, - "Use sudo should be copied", - ) - # Reference should be different (due to reference mixin) - self.assertNotEqual( - jet_log.reference, - server_log.reference, - "Reference should be different (unique)", - ) - # Verify file was created for file-type log - self.assertTrue( - jet_log.file_id, - "File should be created for file-type log", - ) - self.assertEqual( - jet_log.file_id.jet_id, - jet, - "Created file should be linked to the jet", - ) - - def test_create_jet_different_servers(self): - """Test create_jet creates logs with correct server_id for different servers""" - # Create a file template - file_template = self.FileTemplate.create( - { - "name": "Test Log File Template", - "file_name": "test_log.txt", - "source": "tower", - "server_dir": "/var/log", - "code": "Test log content", - } - ) - - # Create server log on template (linked to server_test_1) - self.ServerLog.create( - { - "name": "Test Log", - "server_id": self.server_test_1.id, - "jet_template_id": self.jet_template_test.id, - "log_type": "file", - "file_template_id": file_template.id, - } - ) - - # Ensure template is installed on both servers - self.jet_template_test.write( - { - "server_ids": [ - (4, self.server_test_1.id), - (4, self.server_test_2.id), - ] - } - ) - - # Create jet on server_test_2 - jet = self.jet_template_test.create_jet( - server=self.server_test_2, name="Test Jet Server 2" - ) - - # Verify jet was created on correct server - self.assertEqual( - jet.server_id, - self.server_test_2, - "Jet should be on server_test_2", - ) - - # Verify server log is linked to server_test_2 - jet_log = self.ServerLog.search([("jet_id", "=", jet.id)], limit=1) - self.assertEqual( - jet_log.server_id, - self.server_test_2, - "Server log should be linked to server_test_2", - ) - self.assertEqual( - jet_log.file_id.server_id, - self.server_test_2, - "File should be linked to server_test_2", - ) diff --git a/addons/cetmix_tower_server/tests/test_jet_template_access.py b/addons/cetmix_tower_server/tests/test_jet_template_access.py deleted file mode 100644 index 107e9b3..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_template_access.py +++ /dev/null @@ -1,551 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetTemplateAccess(TestTowerJetsCommon): - """ - Test access rules for Jet Template model - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Use existing users from common.py (cls.user, cls.manager, cls.root) - # Create additional manager for multi-manager tests - cls.manager2 = cls.Users.create( - { - "name": "Test Manager 2", - "login": "test_manager_2", - "email": "test_manager_2@example.com", - "groups_id": [(6, 0, [cls.group_manager.id])], - } - ) - - # ====================== - # User Access Tests - # ====================== - - def test_user_read_access_level_user(self): - """Test User: Read access when access_level is "User" (1)""" - record = self.JetTemplate.create( - { - "name": "User Level Template", - "reference": "user_level_template", - "access_level": "1", # User level - "user_ids": False, # No users initially - "manager_ids": False, # No managers initially - } - ) - - # User should be able to read when access_level is "User" - records = self.JetTemplate.with_user(self.user).search([("id", "=", record.id)]) - self.assertEqual( - len(records), - 1, - "User should be able to read record when access_level is 'User'", - ) - - def test_user_read_access_user_ids(self): - """Test User: Read access when user is added in user_ids""" - record = self.JetTemplate.create( - { - "name": "User Added Template", - "reference": "user_added_template", - "access_level": "2", # Manager level - normally not accessible - "user_ids": [(4, self.user.id)], # User added - "manager_ids": False, - } - ) - - # User should be able to read when added to user_ids - records = self.JetTemplate.with_user(self.user).search([("id", "=", record.id)]) - self.assertEqual( - len(records), - 1, - "User should be able to read record when added to user_ids", - ) - - def test_user_read_access_jet_user_ids(self): - """ - Test User: Read access when user is added in "Users" of any Jets - created from the template - """ - # Create template with Manager level - normally not accessible - # and user NOT in template's user_ids - template = self.JetTemplate.create( - { - "name": "Template with Jet Users", - "reference": "template_with_jet_users", - "access_level": "2", # Manager level - normally not accessible - "user_ids": False, # No users in template - "manager_ids": False, - } - ) - - # User should NOT be able to read initially - records = self.JetTemplate.with_user(self.user).search( - [("id", "=", template.id)] - ) - self.assertEqual( - len(records), - 0, - "User should not be able to read template without access", - ) - - # Create a Jet from this template - # Need to add server to template's server_ids for jet creation - template.write({"server_ids": [(4, self.server_test_1.id)]}) - self._create_jet( - name="Test Jet from Template", - reference="test_jet_from_template", - template=template, - server=self.server_test_1, - user_ids=[(4, self.user.id)], # Add user to Jet's user_ids - ) - - # User should now be able to read the template - records = self.JetTemplate.with_user(self.user).search( - [("id", "=", template.id)] - ) - self.assertEqual( - len(records), - 1, - "User should be able to read template when added to Jet's user_ids", - ) - - def test_user_read_no_access(self): - """ - Test User: No read access when access_level is higher, - user not in template's user_ids, and user not in any Jet's user_ids - """ - record = self.JetTemplate.create( - { - "name": "Manager Level Template", - "reference": "manager_level_template", - "access_level": "2", # Manager level - "user_ids": False, # No users - "manager_ids": False, - } - ) - - # User should not be able to read - # (no access via access_level, template user_ids, or jet user_ids) - records = self.JetTemplate.with_user(self.user).search([("id", "=", record.id)]) - self.assertEqual( - len(records), - 0, - "User should not see record with Manager level " - "when not in user_ids or jet user_ids", - ) - - def test_user_write_forbidden(self): - """Test User: Cannot write/create/delete records""" - record = self.JetTemplate.create( - { - "name": "User Template", - "reference": "user_template", - "access_level": "1", - "user_ids": [(4, self.user.id)], - } - ) - - # User should not be able to write - with self.assertRaises(AccessError): - record.with_user(self.user).write({"name": "Updated Name"}) - - # User should not be able to create - with self.assertRaises(AccessError): - self.JetTemplate.with_user(self.user).create( - {"name": "New Template", "reference": "new_template"} - ) - - # User should not be able to delete - with self.assertRaises(AccessError): - record.with_user(self.user).unlink() - - # ====================== - # Manager Read Access Tests - # ====================== - - def test_manager_read_access_level_user(self): - """Test Manager: Read when access_level is "User" (1)""" - record = self.JetTemplate.create( - { - "name": "User Level for Manager", - "reference": "user_level_manager", - "access_level": "1", - "user_ids": False, - "manager_ids": False, - } - ) - - records = self.JetTemplate.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual(len(records), 1, "Manager should read access_level='1'") - - def test_manager_read_access_level_manager(self): - """Test Manager: Read when access_level is "Manager" (2)""" - record = self.JetTemplate.create( - { - "name": "Manager Level", - "reference": "manager_level", - "access_level": "2", - "user_ids": False, - "manager_ids": False, - } - ) - - records = self.JetTemplate.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual(len(records), 1, "Manager should read access_level='2'") - - def test_manager_read_access_user_ids(self): - """Test Manager: Read when added to user_ids regardless of access_level""" - record = self.JetTemplate.create( - { - "name": "Manager in Users", - "reference": "manager_in_users", - "access_level": "3", # Root level - normally not accessible - "user_ids": [(4, self.manager.id)], # Manager added as user - "manager_ids": False, - } - ) - - records = self.JetTemplate.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual(len(records), 1, "Manager should read when in user_ids") - - def test_manager_read_no_access_root_level(self): - """Test Manager: No read access for Root level (3) without user_ids""" - record = self.JetTemplate.create( - { - "name": "Root Level", - "reference": "root_level", - "access_level": "3", - "user_ids": False, - "manager_ids": False, - } - ) - - records = self.JetTemplate.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual(len(records), 0, "Manager should not read access_level='3'") - - # ====================== - # Manager Write/Create Access Tests - # ====================== - - def test_manager_write_access_level_and_manager_ids(self): - """Test Manager: Write when access_level <= 2 AND in manager_ids""" - record = self.JetTemplate.create( - { - "name": "Manager Can Write", - "reference": "manager_can_write", - "access_level": "2", - "user_ids": False, - "manager_ids": [(4, self.manager.id)], # Manager added - } - ) - - # Manager should be able to write - try: - record.with_user(self.manager).write({"name": "Updated Name"}) - record.invalidate_recordset() - self.assertEqual( - record.name, "Updated Name", "Manager should be able to update" - ) - except AccessError: - self.fail("Manager should be able to update when in manager_ids") - - def test_manager_write_access_level_user(self): - """Test Manager: Write when access_level = 1 and in manager_ids""" - record = self.JetTemplate.create( - { - "name": "User Level Manager Write", - "reference": "user_level_manager_write", - "access_level": "1", - "user_ids": False, - "manager_ids": [(4, self.manager.id)], - } - ) - - try: - record.with_user(self.manager).write({"name": "Updated"}) - except AccessError: - self.fail("Manager should be able to write access_level='1'") - - def test_manager_write_forbidden_not_in_manager_ids(self): - """Test Manager: No write when not in manager_ids""" - record = self.JetTemplate.create( - { - "name": "No Write Access", - "reference": "no_write_access", - "access_level": "2", - "user_ids": [(4, self.manager.id)], # Only in user_ids, not manager_ids - "manager_ids": False, - } - ) - - with self.assertRaises(AccessError): - record.with_user(self.manager).write({"name": "Should Fail"}) - - def test_manager_write_forbidden_root_level(self): - """Test Manager: No write when access_level is Root (3)""" - record = self.JetTemplate.create( - { - "name": "Root Level No Write", - "reference": "root_level_no_write", - "access_level": "3", - "user_ids": [(4, self.manager.id)], - "manager_ids": [(4, self.manager.id)], # In manager_ids - } - ) - - with self.assertRaises(AccessError): - record.with_user(self.manager).write({"name": "Should Fail"}) - - def test_manager_create_access(self): - """Test Manager: Create when access_level <= 2 AND in manager_ids""" - # Try to create without adding to manager_ids - should fail - with self.assertRaises(AccessError): - self.JetTemplate.with_user(self.manager).create( - { - "name": "Create Fail", - "reference": "create_fail", - "access_level": "2", - "manager_ids": False, # Not in manager_ids - } - ) - - # Create with manager added - should succeed - try: - record = self.JetTemplate.with_user(self.manager).create( - { - "name": "Create Success", - "reference": "create_success", - "access_level": "2", - "manager_ids": [(4, self.manager.id)], # In manager_ids - } - ) - records = self.JetTemplate.search([("id", "=", record.id)]) - self.assertEqual(len(records), 1, "Manager should be able to create") - except AccessError: - self.fail("Manager should be able to create when in manager_ids") - - # ====================== - # Manager Delete Access Tests - # ====================== - - def test_manager_delete_own_record(self): - """Test Manager: Delete own record when in manager_ids""" - record = self.JetTemplate.with_user(self.manager).create( - { - "name": "My Record", - "reference": "my_record", - "access_level": "2", - "manager_ids": [(4, self.manager.id)], - } - ) - - try: - record.with_user(self.manager).unlink() - records = self.JetTemplate.search([("id", "=", record.id)]) - self.assertEqual( - len(records), 0, "Manager should be able to delete own record" - ) - except AccessError: - self.fail("Manager should be able to delete own record") - - def test_manager_delete_not_creator(self): - """Test Manager: Cannot delete record created by another user""" - record = self.JetTemplate.with_user(self.manager2).create( - { - "name": "Other's Record", - "reference": "others_record", - "access_level": "2", - "manager_ids": [(4, self.manager.id), (4, self.manager2.id)], - } - ) - - # Manager1 cannot delete Manager2's record - with self.assertRaises(AccessError): - record.with_user(self.manager).unlink() - - def test_manager_delete_not_in_manager_ids(self): - """Test Manager: Cannot delete when not in manager_ids""" - record = self.JetTemplate.with_user(self.manager).create( - { - "name": "Removed Manager", - "reference": "removed_manager", - "access_level": "2", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Remove from manager_ids - record.write({"manager_ids": False}) - - # Cannot delete anymore - with self.assertRaises(AccessError): - record.with_user(self.manager).unlink() - - def test_manager_delete_root_level(self): - """Test Manager: Cannot delete Root level record""" - # Create record with Root level as root (default user) - record = self.JetTemplate.create( - { - "name": "Root Level Delete", - "reference": "root_level_delete", - "access_level": "3", # Root level - "manager_ids": [(4, self.manager.id)], - } - ) - - with self.assertRaises(AccessError): - record.with_user(self.manager).unlink() - - # ====================== - # Root Access Tests - # ====================== - - def test_root_full_access(self): - """ - Test Root: Full CRUD access regardless of access_level or creator. - - Root has unrestricted access to all records via security rule - [(1, '=', 1)], so we test: - - Create records with all access levels - - Read records with all access levels - - Write to records with all access levels - - Delete records regardless of creator - """ - # Test CRUD operations for all access levels - for access_level in ["1", "2", "3"]: - # Root can create any level - record = self.JetTemplate.with_user(self.root).create( - { - "name": f"Root Level {access_level}", - "reference": f"root_level_{access_level}", - "access_level": access_level, - "user_ids": False, - "manager_ids": False, - } - ) - - # Root can read any level - records = self.JetTemplate.with_user(self.root).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 1, - f"Root should be able to read access_level={access_level}", - ) - - # Root can write any level - record.with_user(self.root).write( - {"name": f"Root Updated Level {access_level}"} - ) - record.invalidate_recordset() - self.assertEqual( - record.name, - f"Root Updated Level {access_level}", - f"Root should be able to update access_level={access_level}", - ) - - # Test Root can delete records created by other users - manager_record = self.JetTemplate.with_user(self.manager).create( - { - "name": "Manager's Record", - "reference": "managers_record", - "access_level": "2", - "manager_ids": [(4, self.manager.id)], - } - ) - manager_record.with_user(self.root).unlink() - records = self.JetTemplate.with_user(self.root).search( - [("id", "=", manager_record.id)] - ) - self.assertEqual( - len(records), 0, "Root should be able to delete records from any creator" - ) - - # ====================== - # Edge Cases - # ====================== - - def test_access_level_changes_visibility(self): - """Test that changing access_level affects visibility""" - # Create with User level - record = self.JetTemplate.create( - { - "name": "Changing Level", - "reference": "changing_level", - "access_level": "1", - "user_ids": False, - "manager_ids": False, - } - ) - - # User can read - records = self.JetTemplate.with_user(self.user).search([("id", "=", record.id)]) - self.assertEqual(len(records), 1, "User should read level 1") - - # Change to Root level - record.write({"access_level": "3"}) - - # User cannot read anymore - records = self.JetTemplate.with_user(self.user).search([("id", "=", record.id)]) - self.assertEqual(len(records), 0, "User should not read level 3") - - def test_multiple_managers_access(self): - """Test multiple managers accessing the same record""" - record = self.JetTemplate.with_user(self.manager).create( - { - "name": "Multi Manager", - "reference": "multi_manager", - "access_level": "2", - "manager_ids": [(4, self.manager.id), (4, self.manager2.id)], - } - ) - - # Both managers should be able to read - records1 = self.JetTemplate.with_user(self.manager).search( - [("id", "=", record.id)] - ) - records2 = self.JetTemplate.with_user(self.manager2).search( - [("id", "=", record.id)] - ) - self.assertEqual(len(records1), 1, "Manager1 should read") - self.assertEqual(len(records2), 1, "Manager2 should read") - - # Both can write - record.with_user(self.manager).write({"name": "Manager1 Update"}) - record.with_user(self.manager2).write({"name": "Manager2 Update"}) - - # Only creator can delete - with self.assertRaises(AccessError): - record.with_user(self.manager2).unlink() - - # Creator can delete - record = self.JetTemplate.with_user(self.manager).create( - { - "name": "Creator Delete", - "reference": "creator_delete", - "access_level": "2", - "manager_ids": [(4, self.manager.id), (4, self.manager2.id)], - } - ) - try: - record.with_user(self.manager).unlink() - except AccessError: - self.fail("Creator should be able to delete") diff --git a/addons/cetmix_tower_server/tests/test_jet_template_dependency_access.py b/addons/cetmix_tower_server/tests/test_jet_template_dependency_access.py deleted file mode 100644 index b9a8611..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_template_dependency_access.py +++ /dev/null @@ -1,194 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetTemplateDependencyAccess(TestTowerJetsCommon): - """ - Test access rules for Jet Template Dependency model - """ - # ====================== - # Manager Read Access Tests - # ====================== - - def test_manager_read_access_level_manager(self): - """Test Manager: Read when template access_level is 'Manager' (2)""" - _, _, dependency = self._create_jet_template_dependency( - "Manager Level Template", "manager_level_template", access_level="2" - ) - - records = self.JetTemplateDependency.with_user(self.manager).search( - [("id", "=", dependency.id)] - ) - self.assertEqual(len(records), 1, "Manager should read when access_level='2'") - - def test_manager_read_access_user_ids(self): - """Test Manager: Read when added to template user_ids""" - _, _, dependency = self._create_jet_template_dependency( - "Manager in Users", - "manager_in_users", - access_level="3", - user_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateDependency.with_user(self.manager).search( - [("id", "=", dependency.id)] - ) - self.assertEqual(len(records), 1, "Manager should read when in user_ids") - - def test_manager_read_access_manager_ids(self): - """Test Manager: Read when added to template manager_ids""" - _, _, dependency = self._create_jet_template_dependency( - "Manager in Managers", - "manager_in_managers", - access_level="3", - manager_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateDependency.with_user(self.manager).search( - [("id", "=", dependency.id)] - ) - self.assertEqual(len(records), 1, "Manager should read when in manager_ids") - - def test_manager_read_no_access_root_level(self): - """Test Manager: No read access for Root level (3) without user_ids""" - _, _, dependency = self._create_jet_template_dependency( - "Root Level Template", "root_level_template", access_level="3" - ) - - records = self.JetTemplateDependency.with_user(self.manager).search( - [("id", "=", dependency.id)] - ) - self.assertEqual(len(records), 0, "Manager should not read access_level='3'") - - # ====================== - # Manager CRUD Access Tests - # ====================== - - def test_manager_create_access(self): - """ - Test Manager: Create when template access_level <= '2' - AND manager is in template.manager_ids - """ - # Create a template dependency with manager access using helper - try: - _, _, dependency = self._create_jet_template_dependency( - template_name="Create Manager Template", - template_reference="create_manager_template", - access_level="2", - manager_ids=[(4, self.manager.id)], - template_required=self.jet_template_tower_core, - state_required_id=self.state_running.id, - with_user=self.manager, - ) - - # Ensure dependency was created - records = self.JetTemplateDependency.search([("id", "=", dependency.id)]) - self.assertIn( - dependency, records, "Manager should be able to create dependency" - ) - except AccessError: - self.fail("Manager should be able to create template dependency") - - def test_manager_create_forbidden_not_in_manager_ids(self): - """Test Manager: Cannot create when not in template.manager_ids""" - self.assertRaises( - AccessError, - lambda: self.JetTemplateDependency.with_user(self.manager).create( - { - "template_id": self.jet_template_test.id, - "template_required_id": self.jet_template_tower_core.id, - "state_required_id": self.state_running.id, - } - ), - ) - - def test_manager_write_access(self): - """ - Test Manager: Can write when template access_level <= '2' - AND manager is in template.manager_ids. Toggle state_required_id. - """ - # Create dependency with proper access - _, _, dependency = self._create_jet_template_dependency( - template_name="Write Manager Template", - template_reference="write_manager_template", - access_level="2", - manager_ids=[(4, self.manager.id)], - template_required=self.jet_template_tower_core, - state_required_id=self.state_running.id, - with_user=self.manager, - ) - - # Perform an actual write: change state_required_id - try: - dependency.invalidate_recordset() - dependency.with_user(self.manager).write( - {"state_required_id": self.state_stopped.id} - ) - except AccessError: - self.fail("Manager should be able to write state_required_id") - - def test_manager_unlink_access(self): - """ - Test Manager: Can unlink when template access_level <= '2' - AND manager is in template.manager_ids. - """ - # Create dependency with proper access - _, _, dependency = self._create_jet_template_dependency( - template_name="Unlink Manager Template", - template_reference="unlink_manager_template", - access_level="2", - manager_ids=[(4, self.manager.id)], - template_required=self.jet_template_tower_core, - state_required_id=self.state_running.id, - with_user=self.manager, - ) - - dependency.invalidate_recordset() - dependency = dependency.with_user(self.manager) - try: - dependency.unlink() - records = self.JetTemplateDependency.search([("id", "=", dependency.id)]) - self.assertEqual( - len(records), 0, "Manager should be able to unlink dependency" - ) - except AccessError: - self.fail("Manager should be able to unlink dependency") - - # ====================== - # Root Access Tests - # ====================== - - def test_root_full_access(self): - """Root: Full CRUD access regardless of access restrictions""" - # Root can create - _, _, dependency = self._create_jet_template_dependency( - template_name="Root Template", - template_reference="root_template", - access_level="3", - template_required=self.jet_template_tower_core, - state_required_id=self.state_running.id, - with_user=self.root, - ) - - # Root can read - records = self.JetTemplateDependency.with_user(self.root).search( - [("id", "=", dependency.id)] - ) - self.assertIn(dependency, records, "Root should be able to read") - - # Root can write allowed field - dependency.invalidate_recordset() - dependency.with_user(self.root).write( - {"state_required_id": self.state_running.id} - ) - - # Root can delete - dependency.with_user(self.root).unlink() - records = self.JetTemplateDependency.with_user(self.root).search( - [("id", "=", dependency.id)] - ) - self.assertEqual(len(records), 0, "Root should be able to delete dependency") diff --git a/addons/cetmix_tower_server/tests/test_jet_template_install.py b/addons/cetmix_tower_server/tests/test_jet_template_install.py deleted file mode 100644 index 2846db1..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_template_install.py +++ /dev/null @@ -1,1773 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from unittest.mock import patch - -from odoo.exceptions import ValidationError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetTemplateInstall(TestTowerJetsCommon): - """ - Test the cx.tower.jet.template.install model methods - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create additional servers for testing - cls.server_test_2 = cls.Server.create( - { - "name": "Test Server 2", - "reference": "test_server_2", - "ip_v4_address": "192.168.1.102", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": cls.os_debian_10.id, - } - ) - cls.server_test_3 = cls.Server.create( - { - "name": "Test Server 3", - "reference": "test_server_3", - "ip_v4_address": "192.168.1.103", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": cls.os_debian_10.id, - } - ) - - def test_uninstall_creates_install_record(self): - """Test that uninstall creates a new install record with correct data""" - server = self.server_test_1 - template = self.jet_template_test - - # Create a dummy record to satisfy ensure_one() - # Note: This is a workaround for ensure_one() in @api.model method - dummy_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "install", - } - ) - - # Call uninstall on the dummy record - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" - ".CxTowerJetTemplateInstall._process_install" - ) as mock_process: - install_record = dummy_record.uninstall(server, template) - - # Verify install record was created - self.assertTrue(install_record, "Should return an install record") - self.assertEqual( - install_record.jet_template_id, - template, - "Install record should reference the template", - ) - self.assertEqual( - install_record.server_id, - server, - "Install record should reference the server", - ) - self.assertEqual( - install_record.action, - "uninstall", - "Install record action should be 'uninstall'", - ) - self.assertEqual( - install_record.state, - "processing", - "Install record state should be 'processing'", - ) - - # Verify line_ids contains only the template (no dependencies) - self.assertEqual( - len(install_record.line_ids), - 1, - "Should have exactly one line for uninstall", - ) - line = install_record.line_ids[0] - self.assertEqual( - line.jet_template_id, - template, - "Line should reference the template", - ) - self.assertEqual(line.order, 0, "Line order should be 0") - - # Verify _process_install was called - mock_process.assert_called_once() - - def test_uninstall_creates_notification(self): - """Test that uninstall sends a notification to the user""" - server = self.server_test_1 - template = self.jet_template_test - - # Create a dummy record to satisfy ensure_one() - dummy_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "install", - } - ) - - # Mock notify_info to verify it's called - with patch.object(self.env.user.__class__, "notify_info") as mock_notify, patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" - ".CxTowerJetTemplateInstall._process_install" - ): - dummy_record.uninstall(server, template) - - # Verify notify_info was called - self.assertEqual(mock_notify.call_count, 1, "Should call notify_info once") - - # Verify notification parameters - call_args = mock_notify.call_args - self.assertIn("message", call_args.kwargs, "Should have message") - self.assertIn("title", call_args.kwargs, "Should have title") - self.assertEqual( - call_args.kwargs["title"], - template.name, - "Notification title should be template name", - ) - self.assertEqual( - call_args.kwargs["sticky"], - False, - "Notification should not be sticky", - ) - self.assertIn("action", call_args.kwargs, "Should have action") - - def test_uninstall_different_template(self): - """Test uninstall with a different template""" - server = self.server_test_1 - template = self.jet_template_odoo - - # Create a dummy record to satisfy ensure_one() - dummy_record = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server.id, - "action": "install", - } - ) - - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" - ".CxTowerJetTemplateInstall._process_install" - ): - install_record = dummy_record.uninstall(server, template) - - self.assertEqual( - install_record.jet_template_id, - template, - "Should uninstall the specified template", - ) - self.assertEqual( - install_record.server_id, - server, - "Should uninstall on the specified server", - ) - - def test_uninstall_different_server(self): - """Test uninstall with a different server""" - server = self.server_test_2 - template = self.jet_template_test - - # Create a dummy record to satisfy ensure_one() - dummy_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": self.server_test_1.id, - "action": "install", - } - ) - - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" - ".CxTowerJetTemplateInstall._process_install" - ): - install_record = dummy_record.uninstall(server, template) - - self.assertEqual( - install_record.server_id, - server, - "Should uninstall on the specified server", - ) - - def test_uninstall_removes_template_from_server_ids(self): - """Test that successful uninstallation removes template from server_ids""" - server = self.server_test_1 - template = self.jet_template_test - - # First, add template to server_ids to simulate installed state - template.write({"server_ids": [(4, server.id)]}) - self.assertIn( - server.id, - template.server_ids.ids, - "Template should be in server_ids before uninstall", - ) - - # Create uninstall record - uninstall_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "uninstall", - "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], - } - ) - - # Process uninstallation (without flight plan - direct completion) - # This simulates the case where there's no flight plan - uninstall_record.line_ids[0].write({"state": "to_process"}) - uninstall_record.with_context(cetmix_tower_no_commit=True)._process_install() - - # Verify template was removed from server_ids - template.invalidate_recordset(["server_ids"]) - self.assertNotIn( - server.id, - template.server_ids.ids, - "Template should be removed from server_ids after successful uninstall", - ) - - def test_uninstall_does_not_remove_template_on_failure(self): - """Test that template is not removed from server_ids if uninstallation fails""" - server = self.server_test_1 - template = self.jet_template_test - - # First, add template to server_ids to simulate installed state - template.write({"server_ids": [(4, server.id)]}) - self.assertIn( - server.id, - template.server_ids.ids, - "Template should be in server_ids before uninstall", - ) - - # Create uninstall record with a line - uninstall_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "uninstall", - "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], - } - ) - - # Set current_line_id to simulate flight plan execution - uninstall_record.write({"current_line_id": uninstall_record.line_ids[0].id}) - - # Simulate flight plan finishing with failure (exit code != 0) - uninstall_record.with_context( - cetmix_tower_no_commit=True - )._flight_plan_finished(1) - - # Verify template is still in server_ids (not removed on failure) - template.invalidate_recordset(["server_ids"]) - self.assertIn( - server.id, - template.server_ids.ids, - "Template should remain in server_ids after uninstall failure", - ) - - # ====================== - # Tests for _flight_plan_finished - # ====================== - - def test_flight_plan_finished_success_install_adds_template_to_server_ids(self): - """Test that successful install flight plan adds template to server_ids""" - server = self.server_test_1 - template = self.jet_template_test - - # Ensure template is not in server_ids initially - template.write({"server_ids": [(5, 0, 0)]}) - self.assertNotIn( - server.id, - template.server_ids.ids, - "Template should not be in server_ids before install", - ) - - # Create install record with a line - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "install", - "state": "processing", - "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], - } - ) - - # Set current_line_id to simulate flight plan execution - current_line = install_record.line_ids[0] - install_record.write({"current_line_id": current_line.id}) - - # Simulate flight plan finishing successfully (exit code 0) - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" - ".CxTowerJetTemplateInstall._process_install" - ) as mock_process: - install_record.with_context( - cetmix_tower_no_commit=True - )._flight_plan_finished(0) - - # Verify template was added to server_ids - template.invalidate_recordset(["server_ids"]) - self.assertIn( - server.id, - template.server_ids.ids, - "Template should be added to server_ids after install success", - ) - - # Verify current line was marked as done (check before clearing) - current_line.invalidate_recordset(["state"]) - self.assertEqual( - current_line.state, - "done", - "Current line should be marked as done", - ) - - # Verify current_line_id was cleared - install_record.invalidate_recordset(["current_line_id"]) - self.assertFalse( - install_record.current_line_id, - "current_line_id should be cleared after success", - ) - - # Verify _process_install was called to continue processing - mock_process.assert_called_once() - - def test_flight_plan_finished_success_uninstall_removes_template_from_server_ids( - self, - ): - """ - Test that successful uninstall flight plan - removes template from server_ids - """ - server = self.server_test_1 - template = self.jet_template_test - - # Add template to server_ids to simulate installed state - template.write({"server_ids": [(4, server.id)]}) - self.assertIn( - server.id, - template.server_ids.ids, - "Template should be in server_ids before uninstall", - ) - - # Create uninstall record with a line - uninstall_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "uninstall", - "state": "processing", - "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], - } - ) - - # Set current_line_id to simulate flight plan execution - current_line = uninstall_record.line_ids[0] - uninstall_record.write({"current_line_id": current_line.id}) - - # Simulate flight plan finishing successfully (exit code 0) - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" - ".CxTowerJetTemplateInstall._process_install" - ) as mock_process: - uninstall_record.with_context( - cetmix_tower_no_commit=True - )._flight_plan_finished(0) - - # Verify template was removed from server_ids - template.invalidate_recordset(["server_ids"]) - self.assertNotIn( - server.id, - template.server_ids.ids, - "Template should be removed from server_ids after uninstall success", - ) - - # Verify current line was marked as done (check before clearing) - current_line.invalidate_recordset(["state"]) - self.assertEqual( - current_line.state, - "done", - "Current line should be marked as done", - ) - - # Verify current_line_id was cleared - uninstall_record.invalidate_recordset(["current_line_id"]) - self.assertFalse( - uninstall_record.current_line_id, - "current_line_id should be cleared after success", - ) - - # Verify _process_install was called to continue processing - mock_process.assert_called_once() - - def test_flight_plan_finished_failure_marks_line_as_failed(self): - """Test that failed flight plan marks current line as failed""" - server = self.server_test_1 - template = self.jet_template_test - - # Create install record with a line - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "install", - "state": "processing", - "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], - } - ) - - # Set current_line_id to simulate flight plan execution - current_line = install_record.line_ids[0] - install_record.write({"current_line_id": current_line.id}) - - # Simulate flight plan finishing with failure (exit code != 0) - install_record.with_context(cetmix_tower_no_commit=True)._flight_plan_finished( - 1 - ) - - # Verify current line was marked as failed - self.assertEqual( - current_line.state, - "failed", - "Current line should be marked as failed", - ) - - # Verify install record state was set to failed - self.assertEqual( - install_record.state, - "failed", - "Install record state should be 'failed'", - ) - - # Verify date_done was set - self.assertTrue( - install_record.date_done, - "date_done should be set on failure", - ) - - # Verify current_line_id was cleared - self.assertFalse( - install_record.current_line_id, - "current_line_id should be cleared after failure", - ) - - def test_flight_plan_finished_failure_marks_all_to_process_lines_as_failed(self): - """Test that failed flight plan marks all 'to_process' lines as failed""" - server = self.server_test_1 - template = self.jet_template_test - - # Create install record with multiple lines - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "install", - "state": "processing", - "line_ids": [ - (0, 0, {"jet_template_id": template.id, "order": 0}), - (0, 0, {"jet_template_id": template.id, "order": 1}), - (0, 0, {"jet_template_id": template.id, "order": 2}), - ], - } - ) - - # Set first line as current and mark others as to_process - current_line = install_record.line_ids[0] - other_lines = install_record.line_ids[1:] - install_record.write({"current_line_id": current_line.id}) - other_lines.write({"state": "to_process"}) - - # Simulate flight plan finishing with failure - install_record.with_context(cetmix_tower_no_commit=True)._flight_plan_finished( - 1 - ) - - # Verify all 'to_process' lines were marked as failed - for line in other_lines: - self.assertEqual( - line.state, - "failed", - "All 'to_process' lines should be marked as failed", - ) - - def test_flight_plan_finished_failure_sends_notification(self): - """Test that failed flight plan sends error notification when enabled""" - server = self.server_test_1 - template = self.jet_template_test - - # Enable error notifications - self.env["ir.config_parameter"].sudo().set_param( - "cetmix_tower_server.notification_type_error", "sticky" - ) - - # Create install record with a line - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "install", - "state": "processing", - "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], - } - ) - - # Set current_line_id to simulate flight plan execution - install_record.write({"current_line_id": install_record.line_ids[0].id}) - - # Mock notify_danger to verify it's called - with patch.object(self.env.user.__class__, "notify_danger") as mock_notify: - install_record.with_context( - cetmix_tower_no_commit=True - )._flight_plan_finished(1) - - # Verify notify_danger was called - self.assertEqual( - mock_notify.call_count, 1, "Should call notify_danger once" - ) - - # Verify notification parameters - call_args = mock_notify.call_args - self.assertIn("message", call_args.kwargs, "Should have message") - self.assertIn("title", call_args.kwargs, "Should have title") - self.assertEqual( - call_args.kwargs["title"], - template.name, - "Notification title should be template name", - ) - self.assertEqual( - call_args.kwargs["sticky"], - True, - "Notification should be sticky when configured", - ) - self.assertIn("action", call_args.kwargs, "Should have action") - - def test_flight_plan_finished_no_notification_when_disabled(self): - """Test that failed flight plan doesn't send notification when disabled""" - server = self.server_test_1 - template = self.jet_template_test - - # Disable error notifications - self.env["ir.config_parameter"].sudo().set_param( - "cetmix_tower_server.notification_type_error", False - ) - - # Create install record with a line - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "install", - "state": "processing", - "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], - } - ) - - # Set current_line_id to simulate flight plan execution - install_record.write({"current_line_id": install_record.line_ids[0].id}) - - # Mock notify_danger to verify it's NOT called - with patch.object(self.env.user.__class__, "notify_danger") as mock_notify: - install_record.with_context( - cetmix_tower_no_commit=True - )._flight_plan_finished(1) - - # Verify notify_danger was NOT called - mock_notify.assert_not_called() - - def test_flight_plan_finished_no_current_line_id_returns_early(self): - """Test that _flight_plan_finished returns early if no current_line_id""" - server = self.server_test_1 - template = self.jet_template_test - - # Create install record without current_line_id - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "install", - "state": "processing", - "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], - } - ) - - # Ensure current_line_id is False - self.assertFalse(install_record.current_line_id) - - # Mock logger to verify warning is logged - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install._logger.warning" - ) as mock_warning: - install_record.with_context( - cetmix_tower_no_commit=True - )._flight_plan_finished(0) - - # Verify warning was logged - mock_warning.assert_called_once() - - # Verify template was not modified (early return) - template.invalidate_recordset(["server_ids"]) - self.assertNotIn( - server.id, - template.server_ids.ids, - "Template should not be modified when no current_line_id", - ) - - def test_flight_plan_finished_wrong_state_returns_early(self): - """Test that _flight_plan_finished returns early if state is not 'processing'""" - server = self.server_test_1 - template = self.jet_template_test - - # Create install record in 'done' state - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - "action": "install", - "state": "done", - "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], - } - ) - - # Set current_line_id - install_record.write({"current_line_id": install_record.line_ids[0].id}) - - # Mock logger to verify warning is logged - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install._logger.warning" - ) as mock_warning: - install_record.with_context( - cetmix_tower_no_commit=True - )._flight_plan_finished(0) - - # Verify warning was logged - mock_warning.assert_called_once() - - # Verify template was not modified (early return) - template.invalidate_recordset(["server_ids"]) - self.assertNotIn( - server.id, - template.server_ids.ids, - "Template should not be modified when state is not 'processing'", - ) - - # ====================== - # Tests for _is_installation_needed (from JetTemplate model) - # ====================== - - def test_is_installation_needed_server_already_installed(self): - """Test _is_installation_needed when server is already installed""" - # pylint: disable=protected-access - # Create a server - server = self.Server.create( - { - "name": "Test Server", - "reference": "test_server", - "ip_v4_address": "192.168.1.100", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - - # Add server to template's installed servers - self.jet_template_test.server_ids = [(4, server.id)] - - result = self.jet_template_test._is_installation_needed(server) - self.assertFalse(result, "Should return False when server is already installed") - - def test_is_installation_needed_installation_in_progress_processing(self): - """Test _is_installation_needed when installation is in processing state""" - # pylint: disable=protected-access - # Create a server - server = self.Server.create( - { - "name": "Test Server", - "reference": "test_server", - "ip_v4_address": "192.168.1.100", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - - # Create an installation record in processing state - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server.id, - "state": "processing", - } - ) - - # Create install line - self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record.id, - "jet_template_id": self.jet_template_test.id, - "state": "processing", - } - ) - - result = self.jet_template_test._is_installation_needed(server) - self.assertFalse( - result, "Should return False when installation is in processing state" - ) - - def test_is_installation_needed_installation_in_progress_to_process(self): - """Test _is_installation_needed when installation is in to_process state""" - # pylint: disable=protected-access - # Create a server - server = self.Server.create( - { - "name": "Test Server", - "reference": "test_server", - "ip_v4_address": "192.168.1.100", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - - # Create an installation record in to_process state - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server.id, - "state": "processing", - } - ) - - # Create install line - self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record.id, - "jet_template_id": self.jet_template_test.id, - "state": "to_process", - } - ) - - result = self.jet_template_test._is_installation_needed(server) - self.assertFalse( - result, "Should return False when installation is in to_process state" - ) - - def test_is_installation_needed_installation_completed(self): - """Test _is_installation_needed when installation is completed""" - # pylint: disable=protected-access - # Create a server - server = self.Server.create( - { - "name": "Test Server", - "reference": "test_server", - "ip_v4_address": "192.168.1.100", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - - # Create an installation record in installed state - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server.id, - "state": "done", - } - ) - - # Create install line - self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record.id, - "jet_template_id": self.jet_template_test.id, - "state": "done", - } - ) - - result = self.jet_template_test._is_installation_needed(server) - self.assertTrue( - result, - "Should return True when installation is completed (not in progress)", - ) - - def test_is_installation_needed_installation_failed(self): - """Test _is_installation_needed when installation failed""" - # pylint: disable=protected-access - # Create a server - server = self.Server.create( - { - "name": "Test Server", - "reference": "test_server", - "ip_v4_address": "192.168.1.100", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - - # Create an installation record in failed state - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server.id, - "state": "failed", - } - ) - - # Create install line - self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record.id, - "jet_template_id": self.jet_template_test.id, - "state": "failed", - } - ) - - result = self.jet_template_test._is_installation_needed(server) - self.assertTrue(result, "Should return True when installation failed") - - def test_is_installation_needed_multiple_installations(self): - """Test _is_installation_needed with multiple installation records""" - # pylint: disable=protected-access - # Create a server - server = self.Server.create( - { - "name": "Test Server", - "reference": "test_server", - "ip_v4_address": "192.168.1.100", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - - # Create multiple installation records - install_record1 = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server.id, - "state": "done", - } - ) - - install_record2 = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server.id, - "state": "processing", - } - ) - - # Create install lines - self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record1.id, - "jet_template_id": self.jet_template_test.id, - "state": "done", - } - ) - - self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record2.id, - "jet_template_id": self.jet_template_test.id, - "state": "processing", - } - ) - - result = self.jet_template_test._is_installation_needed(server) - self.assertFalse( - result, "Should return False when any installation is in progress" - ) - - def test_is_installation_needed_different_servers(self): - """Test _is_installation_needed with different servers""" - # pylint: disable=protected-access - # Create two servers - server1 = self.Server.create( - { - "name": "Test Server 1", - "reference": "test_server_1", - "ip_v4_address": "192.168.1.101", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - server2 = self.Server.create( - { - "name": "Test Server 2", - "reference": "test_server_2", - "ip_v4_address": "192.168.1.102", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - - # Add server1 to template's installed servers - self.jet_template_test.server_ids = [(4, server1.id)] - - # Create installation record for server2 - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server2.id, - "state": "processing", - } - ) - - # Create install line - self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record.id, - "jet_template_id": self.jet_template_test.id, - "state": "processing", - } - ) - - # Check server1 (already installed) - result1 = self.jet_template_test._is_installation_needed(server1) - self.assertFalse(result1, "Should return False for server1 (already installed)") - - # Check server2 (installation in progress) - result2 = self.jet_template_test._is_installation_needed(server2) - self.assertFalse( - result2, "Should return False for server2 (installation in progress)" - ) - - def test_is_installation_needed_no_installations(self): - """Test _is_installation_needed when no installation records exist""" - # pylint: disable=protected-access - # Create a server - server = self.Server.create( - { - "name": "Test Server", - "reference": "test_server", - "ip_v4_address": "192.168.1.100", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - - result = self.jet_template_test._is_installation_needed(server) - self.assertTrue(result, "Should return True when no installation records exist") - - def test_is_installation_needed_mixed_states(self): - """Test _is_installation_needed with mixed installation states""" - # pylint: disable=protected-access - # Create a server - server = self.Server.create( - { - "name": "Test Server", - "reference": "test_server", - "ip_v4_address": "192.168.1.100", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - - # Create installation records with different states - install_record1 = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server.id, - "state": "done", - } - ) - - install_record2 = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server.id, - "state": "failed", - } - ) - - # Create install lines - self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record1.id, - "jet_template_id": self.jet_template_test.id, - "state": "done", - } - ) - - self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record2.id, - "jet_template_id": self.jet_template_test.id, - "state": "failed", - } - ) - - result = self.jet_template_test._is_installation_needed(server) - self.assertTrue( - result, "Should return True when all installations are completed or failed" - ) - - # ====================== - # Tests for install_on_servers (from JetTemplate model) - # ====================== - - def test_install_on_servers_no_dependencies(self): - """Test install_on_servers with template that has no dependencies""" - # pylint: disable=protected-access - # Use existing server from common.py - server = self.server_test_1 - - # Call install method directly with cetmix_tower_no_commit context - self.jet_template_test.with_context( - cetmix_tower_no_commit=True - ).install_on_servers(server) - - # Verify installation record was created - install_records = self.JetTemplateInstall.search( - [ - ("jet_template_id", "=", self.jet_template_test.id), - ("server_id", "=", server.id), - ] - ) - self.assertEqual( - len(install_records), 1, "Should create exactly one installation record" - ) - - def test_install_on_servers_already_installed(self): - """Test install_on_servers when template is already installed""" - # pylint: disable=protected-access - # Use existing server from common.py - server = self.server_test_1 - - # Add server to template's installed servers - self.jet_template_test.server_ids = [(4, server.id)] - - # Call install method - should skip since already installed - self.jet_template_test.with_context( - cetmix_tower_no_commit=True - ).install_on_servers(server) - - # Verify no new installation record was created - install_records = self.JetTemplateInstall.search( - [ - ("jet_template_id", "=", self.jet_template_test.id), - ("server_id", "=", server.id), - ] - ) - self.assertEqual( - len(install_records), - 0, - "Should not create installation record when already installed", - ) - - def test_install_on_servers_installation_in_progress(self): - """Test install_on_servers when installation is already in progress""" - # pylint: disable=protected-access - # Use existing server from common.py - server = self.server_test_1 - - # Create installation record in progress - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server.id, - "state": "processing", - } - ) - - # Create install line - self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record.id, - "jet_template_id": self.jet_template_test.id, - "state": "processing", - } - ) - - # Call install method - should skip since installation in progress - self.jet_template_test.with_context( - cetmix_tower_no_commit=True - ).install_on_servers(server) - - # Verify no additional installation record was created - install_records = self.JetTemplateInstall.search( - [ - ("jet_template_id", "=", self.jet_template_test.id), - ("server_id", "=", server.id), - ] - ) - self.assertEqual( - len(install_records), - 1, - "Should not create additional installation record", - ) - - def test_install_on_servers_dependency_satisfaction(self): - """Test install_on_servers dependency satisfaction logic""" - # pylint: disable=protected-access - # Use class-level dependency hierarchy - # Use existing server from common.py - server = self.server_test_1 - - # Install Tower Core on server - self.jet_template_tower_core.server_ids = [(4, server.id)] - - # Call install method directly - self.jet_template_postgres.with_context( - cetmix_tower_no_commit=True - ).install_on_servers(server) - - # Verify installation record was created - install_records = self.JetTemplateInstall.search( - [ - ("jet_template_id", "=", self.jet_template_postgres.id), - ("server_id", "=", server.id), - ] - ) - self.assertEqual( - len(install_records), 1, "Should create exactly one installation record" - ) - - def test_install_on_servers_multiple_servers(self): - """Test install_on_servers with multiple servers""" - # pylint: disable=protected-access - # Use existing servers from class setup - server1 = self.server_test_1 - server2 = self.server_test_2 - - # Add server1 to template's installed servers - self.jet_template_test.server_ids = [(4, server1.id)] - - # Call install method directly - self.jet_template_test.with_context( - cetmix_tower_no_commit=True - ).install_on_servers([server1, server2]) - - # Verify installation record was created only for server2 - install_records = self.JetTemplateInstall.search( - [ - ("jet_template_id", "=", self.jet_template_test.id), - ("server_id", "=", server2.id), - ] - ) - self.assertEqual( - len(install_records), 1, "Should create installation record for server2" - ) - - # Verify no installation record for server1 (already installed) - install_records_server1 = self.JetTemplateInstall.search( - [ - ("jet_template_id", "=", self.jet_template_test.id), - ("server_id", "=", server1.id), - ] - ) - self.assertEqual( - len(install_records_server1), - 0, - "Should not create installation record for server1 (already installed)", - ) - - def test_install_on_servers_empty_server_list(self): - """Test install_on_servers with empty server list""" - # pylint: disable=protected-access - # Call install method with empty list - self.jet_template_test.with_context( - cetmix_tower_no_commit=True - ).install_on_servers([]) - - # Verify no installation record was created - install_records = self.JetTemplateInstall.search( - [("jet_template_id", "=", self.jet_template_test.id)] - ) - self.assertEqual( - len(install_records), - 0, - "Should not create installation record with empty server list", - ) - - def test_install_on_servers_mixed_server_states(self): - """Test install_on_servers with mixed server states""" - # Use existing servers from class setup - server1 = self.server_test_1 - server2 = self.server_test_2 - server3 = self.server_test_3 - - # Server1: Already installed - self.jet_template_test.server_ids = [(4, server1.id)] - - # Server2: Installation in progress - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": self.jet_template_test.id, - "server_id": server2.id, - "state": "processing", - } - ) - self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record.id, - "jet_template_id": self.jet_template_test.id, - "state": "processing", - } - ) - - # Server3: Not installed (should trigger installation) - - # Call install method directly - self.jet_template_test.with_context( - cetmix_tower_no_commit=True - ).install_on_servers([server1, server2, server3]) - - # Verify installation record was created only for server3 - install_records = self.JetTemplateInstall.search( - [ - ("jet_template_id", "=", self.jet_template_test.id), - ("server_id", "=", server3.id), - ] - ) - self.assertEqual( - len(install_records), 1, "Should create installation record for server3" - ) - - def test_install_on_servers_odoo_scenario_complete_installation(self): - """Test complete Odoo installation scenario""" - # Use class-level dependency hierarchy - # Use existing server from common.py - server = self.server_test_1 - - # Call install for Odoo template - self.jet_template_odoo.with_context( - cetmix_tower_no_commit=True - ).install_on_servers(server) - - # Verify installation log is created - install_records = self.JetTemplateInstall.search( - [ - ("jet_template_id", "=", self.jet_template_odoo.id), - ("server_id", "=", server.id), - ] - ) - self.assertEqual( - len(install_records), 1, "Should create exactly one installation record" - ) - - install_record = install_records[0] - self.assertEqual( - install_record.jet_template_id, - self.jet_template_odoo, - "Installation should be for Odoo template", - ) - self.assertEqual( - install_record.server_id, server, "Installation should be on test server" - ) - - # Verify all dependencies are in installation log lines - install_lines = install_record.line_ids.sorted("order") - self.assertEqual( - len(install_lines), - 5, - "Should have 5 installation lines (Odoo + 4 dependencies)", - ) - - # Verify all expected templates are included - template_ids = install_lines.mapped("jet_template_id.id") - expected_template_ids = [ - self.jet_template_tower_core.id, - self.jet_template_docker.id, - self.jet_template_postgres.id, - self.jet_template_nginx.id, - self.jet_template_odoo.id, - ] - self.assertEqual( - set(template_ids), - set(expected_template_ids), - "All expected templates should be in installation lines", - ) - - # Verify correct order: Odoo first, then Nginx/Postgres (either order), - # then Docker, then Tower Core. - odoo_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_odoo - ) - self.assertEqual(odoo_line.order, 0, "Odoo should be first (order 0)") - - # Verify dependency relationships are correct - # Odoo should be first (main template) - odoo_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_odoo - ) - self.assertEqual(len(odoo_line), 1, "Should have exactly one Odoo line") - self.assertEqual(odoo_line.order, 0, "Odoo should be first (order 0)") - - # Nginx and Postgres should be second and third (direct dependencies of Odoo) - nginx_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_nginx - ) - postgres_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_postgres - ) - self.assertEqual(len(nginx_line), 1, "Should have exactly one Nginx line") - self.assertEqual(len(postgres_line), 1, "Should have exactly one Postgres line") - self.assertIn(nginx_line.order, [1, 2], "Nginx should be order 1 or 2") - self.assertIn(postgres_line.order, [1, 2], "Postgres should be order 1 or 2") - self.assertNotEqual( - nginx_line.order, - postgres_line.order, - "Nginx and Postgres should have different orders", - ) - - # Docker should be fourth (dependency of both Postgres and Nginx) - docker_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_docker - ) - self.assertEqual(len(docker_line), 1, "Should have exactly one Docker line") - self.assertEqual(docker_line.order, 3, "Docker should be fourth (order 3)") - - # Tower Core should be last (dependency of Docker) - tower_core_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_tower_core - ) - self.assertEqual( - len(tower_core_line), 1, "Should have exactly one Tower Core line" - ) - self.assertEqual( - tower_core_line.order, 4, "Tower Core should be last (order 4)" - ) - - def test_install_on_servers_woocommerce_odoo_scenario(self): - """Test install_on_servers with WooCommerce with Odoo scenario""" - # pylint: disable=protected-access - # Use existing server from common.py - server = self.server_test_1 - - # Call install for WooCommerce with Odoo template - self.jet_template_woocommerce_odoo.with_context( - cetmix_tower_no_commit=True - ).install_on_servers(server) - - # Verify installation log is created - install_records = self.JetTemplateInstall.search( - [ - ("jet_template_id", "=", self.jet_template_woocommerce_odoo.id), - ("server_id", "=", server.id), - ] - ) - self.assertEqual( - len(install_records), 1, "Should create exactly one installation record" - ) - - install_record = install_records[0] - self.assertEqual( - install_record.jet_template_id, - self.jet_template_woocommerce_odoo, - "Installation should be for WooCommerce with Odoo template", - ) - self.assertEqual( - install_record.server_id, server, "Installation should be on test server" - ) - - # Verify all dependencies are in installation log lines - install_lines = install_record.line_ids.sorted("order") - # Should have 8 installation lines: - # WooCommerce + 7 dependencies - # WordPress, Odoo, MariaDB, Postgres, Nginx, Docker, Tower Core - self.assertEqual( - len(install_lines), - 8, - "Should have 8 installation lines (WooCommerce + 7 dependencies)", - ) - - # Verify topological constraints: - # WooCommerce first (root), Tower Core last (deepest leaf), - # Docker before Nginx/Postgres/MariaDB, etc. - wc_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_woocommerce_odoo - ) - self.assertEqual(wc_line.order, 0, "WooCommerce should be first (order 0)") - - tc_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_tower_core - ) - self.assertEqual(tc_line.order, 7, "Tower Core should be last (order 7)") - - docker_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_docker - ) - nginx_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_nginx - ) - postgres_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_postgres - ) - mariadb_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_mariadb - ) - odoo_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_odoo - ) - wp_line = install_lines.filtered( - lambda line: line.jet_template_id == self.jet_template_wordpress - ) - - self.assertGreater( - tc_line.order, - docker_line.order, - "Tower Core must have higher order than Docker (installed first)", - ) - self.assertGreater( - docker_line.order, - nginx_line.order, - "Docker must have higher order than Nginx (installed first)", - ) - self.assertGreater( - docker_line.order, - postgres_line.order, - "Docker must have higher order than Postgres (installed first)", - ) - self.assertGreater( - docker_line.order, - mariadb_line.order, - "Docker must have higher order than MariaDB (installed first)", - ) - self.assertGreater( - nginx_line.order, - odoo_line.order, - "Nginx must have higher order than Odoo (installed first)", - ) - self.assertGreater( - postgres_line.order, - odoo_line.order, - "Postgres must have higher order than Odoo (installed first)", - ) - self.assertGreater( - nginx_line.order, - wp_line.order, - "Nginx must have higher order than WordPress (installed first)", - ) - self.assertGreater( - mariadb_line.order, - wp_line.order, - "MariaDB must have higher order than WordPress (installed first)", - ) - - # Verify all expected templates are included - template_ids = install_lines.mapped("jet_template_id.id") - expected_template_ids = [ - self.jet_template_tower_core.id, - self.jet_template_docker.id, - self.jet_template_mariadb.id, - self.jet_template_postgres.id, - self.jet_template_nginx.id, - self.jet_template_wordpress.id, - self.jet_template_odoo.id, - self.jet_template_woocommerce_odoo.id, - ] - self.assertEqual( - set(template_ids), - set(expected_template_ids), - "All expected templates should be in installation lines", - ) - - # ====================== - # Tests for uninstall_from_servers (from JetTemplate model) - # ====================== - - def test_uninstall_from_servers_template_not_installed(self): - """Test uninstall_from_servers when template is not installed""" - server = self.server_test_1 - template = self.jet_template_test - - # Ensure template is not installed - template.write({"server_ids": [(5, 0, 0)]}) - - # Should raise ValidationError when raise_if_not_possible=True - with self.assertRaises(ValidationError) as context: - template.uninstall_from_servers(server, raise_if_not_possible=True) - - error_message = str(context.exception) - self.assertIn("not installed", error_message.lower()) - self.assertIn(template.name, error_message) - self.assertIn(server.name, error_message) - - def test_uninstall_from_servers_template_not_installed_warning(self): - """Test uninstall_from_servers shows warning when template is not installed""" - server = self.server_test_1 - template = self.jet_template_test - - # Ensure template is not installed - template.write({"server_ids": [(5, 0, 0)]}) - - # Mock notify_warning to verify it's called - with patch.object(self.env.user.__class__, "notify_warning") as mock_notify: - template.uninstall_from_servers(server, raise_if_not_possible=False) - - # Verify notify_warning was called - mock_notify.assert_called_once() - call_args = mock_notify.call_args - self.assertIn("message", call_args.kwargs) - self.assertIn("not installed", call_args.kwargs["message"].lower()) - - def test_uninstall_from_servers_jets_still_exist(self): - """Test uninstall_from_servers when jets still exist on server""" - server = self.server_test_1 - template = self.jet_template_test - - # Install template on server - template.write({"server_ids": [(4, server.id)]}) - - # Create a jet on the server - self.Jet.create( - { - "name": "Test Jet Uninstall Still Exist", - "reference": "test_jet_uninstall_still_exist", - "jet_template_id": template.id, - "server_id": server.id, - } - ) - - # Should raise ValidationError when raise_if_not_possible=True - with self.assertRaises(ValidationError) as context: - template.uninstall_from_servers(server, raise_if_not_possible=True) - - error_message = str(context.exception) - self.assertIn("still jets", error_message.lower()) - self.assertIn(template.name, error_message) - self.assertIn(server.name, error_message) - - def test_uninstall_from_servers_jets_still_exist_warning(self): - """Test uninstall_from_servers shows warning when jets still exist""" - server = self.server_test_1 - template = self.jet_template_test - - # Install template on server - template.write({"server_ids": [(4, server.id)]}) - - # Create a jet on the server - self.Jet.create( - { - "name": "Test Jet Uninstall Still Exist Warning", - "reference": "test_jet_uninstall_still_exist_warning", - "jet_template_id": template.id, - "server_id": server.id, - } - ) - - # Mock notify_warning to verify it's called - with patch.object(self.env.user.__class__, "notify_warning") as mock_notify: - template.uninstall_from_servers(server, raise_if_not_possible=False) - - # Verify notify_warning was called - mock_notify.assert_called_once() - call_args = mock_notify.call_args - self.assertIn("message", call_args.kwargs) - self.assertIn("still jets", call_args.kwargs["message"].lower()) - - def test_uninstall_from_servers_dependent_templates_installed(self): - """Test uninstall_from_servers when dependent templates are installed""" - server = self.server_test_1 - # Use postgres template which depends on docker - base_template = self.jet_template_docker - dependent_template = self.jet_template_postgres - - # Install both templates on server - base_template.write({"server_ids": [(4, server.id)]}) - dependent_template.write({"server_ids": [(4, server.id)]}) - - # Verify dependency exists - self.assertTrue( - dependent_template.template_requires_ids.filtered( - lambda dep: dep.template_required_id == base_template - ), - "Postgres should depend on Docker", - ) - - # Should raise ValidationError when raise_if_not_possible=True - with self.assertRaises(ValidationError) as context: - base_template.uninstall_from_servers(server, raise_if_not_possible=True) - - error_message = str(context.exception) - self.assertIn("depend", error_message.lower()) - self.assertIn(base_template.name, error_message) - self.assertIn(server.name, error_message) - - def test_uninstall_from_servers_dependent_templates_installed_warning(self): - """ - Test uninstall_from_servers shows warning - when dependent templates are installed - """ - server = self.server_test_1 - # Use postgres template which depends on docker - base_template = self.jet_template_docker - dependent_template = self.jet_template_postgres - - # Install both templates on server - base_template.write({"server_ids": [(4, server.id)]}) - dependent_template.write({"server_ids": [(4, server.id)]}) - - # Mock notify_warning to verify it's called - with patch.object(self.env.user.__class__, "notify_warning") as mock_notify: - base_template.uninstall_from_servers(server, raise_if_not_possible=False) - - # Verify notify_warning was called - mock_notify.assert_called_once() - call_args = mock_notify.call_args - self.assertIn("message", call_args.kwargs) - self.assertIn("depend", call_args.kwargs["message"].lower()) - - def test_uninstall_from_servers_dependent_templates_not_installed(self): - """ - Test uninstall_from_servers succeeds - when dependent templates are not installed - """ - server = self.server_test_1 - # Use docker template - base_template = self.jet_template_docker - - # Install only base template on server (not the dependent one) - base_template.write({"server_ids": [(4, server.id)]}) - - # Mock uninstall to verify it's called - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" - ".CxTowerJetTemplateInstall.uninstall" - ) as mock_uninstall: - base_template.uninstall_from_servers(server, raise_if_not_possible=True) - - # Verify uninstall was called - mock_uninstall.assert_called_once_with( - server=server, template=base_template - ) - - def test_uninstall_from_servers_success(self): - """Test successful uninstall_from_servers""" - server = self.server_test_1 - template = self.jet_template_test - - # Clean up any existing jets for this template/server combination - existing_jets = server.jet_ids.filtered( - lambda jet: jet.jet_template_id == template - ) - if existing_jets: - existing_jets.unlink() - - # Install template on server - template.write({"server_ids": [(4, server.id)]}) - - # Ensure no jets exist - self.assertFalse( - server.jet_ids.filtered(lambda jet: jet.jet_template_id == template), - "No jets should exist for this template", - ) - - # Mock uninstall to verify it's called - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" - ".CxTowerJetTemplateInstall.uninstall" - ) as mock_uninstall: - template.uninstall_from_servers(server, raise_if_not_possible=True) - - # Verify uninstall was called - mock_uninstall.assert_called_once_with(server=server, template=template) - - def test_uninstall_from_servers_multiple_servers(self): - """Test uninstall_from_servers with multiple servers""" - server1 = self.server_test_1 - server2 = self.server_test_2 - template = self.jet_template_test - - # Clean up any existing jets for this template on both servers - existing_jets_1 = server1.jet_ids.filtered( - lambda jet: jet.jet_template_id == template - ) - if existing_jets_1: - existing_jets_1.unlink() - existing_jets_2 = server2.jet_ids.filtered( - lambda jet: jet.jet_template_id == template - ) - if existing_jets_2: - existing_jets_2.unlink() - - # Ensure no dependent templates are installed on these servers - # Remove any templates that depend on this template from both servers - for server in [server1, server2]: - dependent_templates = server.jet_template_ids.filtered( - lambda t: t.template_requires_ids.filtered( - lambda dep: dep.template_required_id == template - ) - ) - if dependent_templates: - # Remove server from dependent template's server_ids - for dep_template in dependent_templates: - dep_template.write({"server_ids": [(3, server.id)]}) - - # Install template on both servers - template.write({"server_ids": [(4, server1.id), (4, server2.id)]}) - - # Mock uninstall to verify it's called for both servers - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" - ".CxTowerJetTemplateInstall.uninstall" - ) as mock_uninstall: - template.uninstall_from_servers( - [server1, server2], raise_if_not_possible=True - ) - - # Verify uninstall was called twice (once per server) - self.assertEqual(mock_uninstall.call_count, 2) - # Verify both servers were called - call_args_list = mock_uninstall.call_args_list - servers_called = [call[1]["server"] for call in call_args_list] - self.assertIn(server1, servers_called) - self.assertIn(server2, servers_called) - - def test_uninstall_from_servers_mixed_validation_states(self): - """Test uninstall_from_servers with mixed server validation states""" - server1 = self.server_test_1 - server2 = self.server_test_2 - server3 = self.server_test_3 - template = self.jet_template_test - - # Server1: Template not installed - template.write({"server_ids": [(5, 0, 0)]}) - - # Server2: Jets still exist - template.write({"server_ids": [(4, server2.id)]}) - self.Jet.create( - { - "name": "Test Jet Mixed Validation Server2", - "reference": "test_jet_mixed_validation_server2", - "jet_template_id": template.id, - "server_id": server2.id, - } - ) - - # Server3: Valid for uninstallation - template.write({"server_ids": [(4, server3.id)]}) - - # Mock uninstall and notify_warning - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_template_install" - ".CxTowerJetTemplateInstall.uninstall" - ) as mock_uninstall, patch.object( - self.env.user.__class__, "notify_warning" - ) as mock_notify: - template.uninstall_from_servers( - [server1, server2, server3], raise_if_not_possible=False - ) - - # Verify warnings were shown for server1 and server2 - self.assertEqual(mock_notify.call_count, 2) - - # Verify uninstall was called only for server3 - mock_uninstall.assert_called_once_with(server=server3, template=template) diff --git a/addons/cetmix_tower_server/tests/test_jet_template_install_access.py b/addons/cetmix_tower_server/tests/test_jet_template_install_access.py deleted file mode 100644 index 3cc9635..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_template_install_access.py +++ /dev/null @@ -1,387 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetTemplateInstallAccess(TestTowerJetsCommon): - """ - Test access rules for Jet Template Install model - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create additional server for testing - cls.server_test_2 = cls.Server.create( - { - "name": "Test Server 2", - "reference": "test_server_2", - "ip_v4_address": "192.168.1.102", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": cls.os_debian_10.id, - } - ) - - def _create_install_record( - self, - template=None, - server=None, - template_access_level="2", - template_user_ids=None, - template_manager_ids=None, - server_user_ids=None, - server_manager_ids=None, - ): - """Helper method to create a jet template install record""" - if not template: - template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "access_level": template_access_level, - "user_ids": template_user_ids - if template_user_ids is not None - else [(5, 0, 0)], - "manager_ids": template_manager_ids - if template_manager_ids is not None - else [(5, 0, 0)], - } - ) - - if not server: - server = self.server_test_1 - - # Update server access if needed - if server_user_ids is not None: - server.write({"user_ids": server_user_ids}) - if server_manager_ids is not None: - server.write({"manager_ids": server_manager_ids}) - - # Create install record - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - } - ) - - return template, server, install_record - - # ====================== - # Manager Read Access Tests - # ====================== - - def test_manager_read_server_user_ids_template_access_level_manager(self): - """Test Manager: Read when in server user_ids and template access_level <= 2""" - _, _, install_record = self._create_install_record( - template_access_level="2", - server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateInstall.with_user(self.manager).search( - [("id", "=", install_record.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in server user_ids" - " and template access_level <= 2", - ) - - def test_manager_read_server_manager_ids_template_access_level_manager(self): - """ - Test Manager: Read when in server manager_ids - and template access_level <= 2. - """ - _, _, install_record = self._create_install_record( - template_access_level="2", - server_manager_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateInstall.with_user(self.manager).search( - [("id", "=", install_record.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in server manager_ids" - " and template access_level <= 2", - ) - - def test_manager_read_template_user_ids_override(self): - """ - Test Manager: Read when in template user_ids overrides access_level - (server user_ids or manager_ids). - """ - # Test with server user_ids - _, _, install_record1 = self._create_install_record( - template_access_level="3", # Root level - normally not accessible - template_user_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateInstall.with_user(self.manager).search( - [("id", "=", install_record1.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in template user_ids" " and server user_ids", - ) - - # Test with server manager_ids - _, _, install_record2 = self._create_install_record( - template_access_level="3", # Root level - normally not accessible - template_user_ids=[(4, self.manager.id)], - server_manager_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateInstall.with_user(self.manager).search( - [("id", "=", install_record2.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in template user_ids" " and server manager_ids", - ) - - def test_manager_read_no_access_no_server_access(self): - """ - Test Manager: No read access when not in - server user_ids or manager_ids. - """ - _, _, install_record = self._create_install_record( - template_access_level="1", - server_user_ids=[(5, 0, 0)], - server_manager_ids=[(5, 0, 0)], - ) - - records = self.JetTemplateInstall.with_user(self.manager).search( - [("id", "=", install_record.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read when not in server user_ids or manager_ids", - ) - - def test_manager_read_no_access_template_root_level(self): - """ - Test Manager: No read access when template access_level - is Root and not in template user_ids. - """ - _, _, install_record = self._create_install_record( - template_access_level="3", # Root level - template_user_ids=[(5, 0, 0)], - server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateInstall.with_user(self.manager).search( - [("id", "=", install_record.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read when template access_level" - " is Root and not in template user_ids", - ) - - def test_manager_read_no_access_template_manager_level_no_server_access(self): - """ - Test Manager: No read access when template access_level - is Manager but not in server. - """ - _, _, install_record = self._create_install_record( - template_access_level="2", - server_user_ids=[(5, 0, 0)], - server_manager_ids=[(5, 0, 0)], - ) - - records = self.JetTemplateInstall.with_user(self.manager).search( - [("id", "=", install_record.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read when not in server" - " even if template access_level is Manager", - ) - - def test_manager_write_forbidden(self): - """Test Manager: Cannot write/create/delete records""" - _, _, install_record = self._create_install_record( - template_access_level="2", - server_user_ids=[(4, self.manager.id)], - ) - - # Manager should not be able to write - with self.assertRaises(AccessError): - install_record.with_user(self.manager).write({"state": "done"}) - - # Manager should not be able to create - template = self.JetTemplate.create( - { - "name": "New Template", - "reference": "new_template", - "access_level": "2", - } - ) - server = self.server_test_1 - server.write({"user_ids": [(4, self.manager.id)]}) - - with self.assertRaises(AccessError): - self.JetTemplateInstall.with_user(self.manager).create( - { - "jet_template_id": template.id, - "server_id": server.id, - } - ) - - # Manager should not be able to delete - with self.assertRaises(AccessError): - install_record.with_user(self.manager).unlink() - - # ====================== - # Root Access Tests - # ====================== - - def test_root_write_access(self): - """Test Root: Can write any record""" - _, _, install_record = self._create_install_record() - - # Root should be able to write - try: - install_record.with_user(self.root).write({"state": "done"}) - install_record.invalidate_recordset() - self.assertEqual( - install_record.state, "done", "Root should be able to update" - ) - except AccessError: - self.fail("Root should be able to update any record") - - def test_root_create_access(self): - """Test Root: Can create any record""" - template = self.JetTemplate.with_user(self.root).create( - { - "name": "Root Template", - "reference": "root_template", - "access_level": "3", - } - ) - server = self.server_test_1 - - # Root should be able to create - try: - install_record = self.JetTemplateInstall.with_user(self.root).create( - { - "jet_template_id": template.id, - "server_id": server.id, - } - ) - records = self.JetTemplateInstall.with_user(self.root).search( - [("id", "=", install_record.id)] - ) - self.assertEqual(len(records), 1, "Root should be able to create") - except AccessError: - self.fail("Root should be able to create any record") - - def test_root_delete_access(self): - """Test Root: Can delete any record""" - _, _, install_record = self._create_install_record() - - # Root should be able to delete - try: - install_record.with_user(self.root).unlink() - records = self.JetTemplateInstall.with_user(self.root).search( - [("id", "=", install_record.id)] - ) - self.assertEqual(len(records), 0, "Root should be able to delete") - except AccessError: - self.fail("Root should be able to delete any record") - - def test_root_access_all_scenarios(self): - """Test Root can access records in all scenarios""" - # Test various combinations - scenarios = [ - { - "template_access_level": "1", - "server_user_ids": [(5, 0, 0)], - "server_manager_ids": [(5, 0, 0)], - }, - { - "template_access_level": "2", - "server_user_ids": [(5, 0, 0)], - "server_manager_ids": [(5, 0, 0)], - }, - { - "template_access_level": "3", - "server_user_ids": [(5, 0, 0)], - "server_manager_ids": [(5, 0, 0)], - }, - ] - - for scenario in scenarios: - _, _, install_record = self._create_install_record(**scenario) - records = self.JetTemplateInstall.with_user(self.root).search( - [("id", "=", install_record.id)] - ) - self.assertEqual( - len(records), - 1, - f"Root should be able to read record with scenario: {scenario}", - ) - - # ====================== - # Edge Cases - # ====================== - - def test_manager_read_multiple_servers(self): - """Test Manager access across multiple servers""" - # Manager in server 1, template accessible - template1, _, install1 = self._create_install_record( - template_access_level="2", - server_user_ids=[(4, self.manager.id)], - ) - - # Manager not in server 2, same template - _, _, install2 = self._create_install_record( - template=template1, - server=self.server_test_2, - template_access_level="2", - server_user_ids=[(5, 0, 0)], - server_manager_ids=[(5, 0, 0)], - ) - - # Manager should only see install1 - records = self.JetTemplateInstall.with_user(self.manager).search( - [("id", "in", [install1.id, install2.id])] - ) - self.assertEqual(len(records), 1, "Manager should only see accessible install") - self.assertEqual(records[0].id, install1.id, "Manager should see install1") - - def test_manager_read_multiple_templates(self): - """Test Manager access with multiple templates""" - # Template 1: Manager level, Manager in server - _, _, install1 = self._create_install_record( - template_access_level="2", - server_user_ids=[(4, self.manager.id)], - ) - - # Template 2: Root level, Manager in server but template user_ids - _, _, install2 = self._create_install_record( - template_access_level="3", - template_user_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - ) - - # Manager should see both - records = self.JetTemplateInstall.with_user(self.manager).search( - [("id", "in", [install1.id, install2.id])] - ) - self.assertEqual(len(records), 2, "Manager should see both installs") diff --git a/addons/cetmix_tower_server/tests/test_jet_template_install_line_access.py b/addons/cetmix_tower_server/tests/test_jet_template_install_line_access.py deleted file mode 100644 index f5d240f..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_template_install_line_access.py +++ /dev/null @@ -1,492 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetTemplateInstallLineAccess(TestTowerJetsCommon): - """ - Test access rules for Jet Template Install Line model - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create additional server for testing - cls.server_test_2 = cls.Server.create( - { - "name": "Test Server 2", - "reference": "test_server_2", - "ip_v4_address": "192.168.1.102", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": cls.os_debian_10.id, - } - ) - - def _create_install_line_record( - self, - template=None, - line_template=None, - server=None, - line_template_access_level="2", - line_template_user_ids=None, - server_user_ids=None, - server_manager_ids=None, - ): - """ - Helper method to create a jet template install line record - - Note: Install Line access rules only check server_id and line template - (jet_template_id), not the parent install template. So we only need - to vary these parameters for testing. - - Args: - template: Template for the install record (parent) - - defaults to simple template. - line_template: Template for the install line - server: Server for the install record - line_template_access_level: Access level for line template - line_template_user_ids: User IDs for line template - server_user_ids: User IDs for server - server_manager_ids: Manager IDs for server - """ - if not template: - template = self.JetTemplate.create( - { - "name": "Test Template", - "access_level": "2", # Default, doesn't affect Install Line access - } - ) - - if not line_template: - line_template = self.JetTemplate.create( - { - "name": "Test Line Template", - "reference": "test_line_template", - "access_level": line_template_access_level, - "user_ids": line_template_user_ids - if line_template_user_ids is not None - else [(5, 0, 0)], - "manager_ids": [(5, 0, 0)], - } - ) - - if not server: - server = self.server_test_1 - - # Update server access if needed - if server_user_ids is not None: - server.write({"user_ids": server_user_ids}) - if server_manager_ids is not None: - server.write({"manager_ids": server_manager_ids}) - - # Create install record - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - } - ) - - # Create install line record - install_line_record = self.JetTemplateInstallLine.create( - { - "jet_template_install_id": install_record.id, - "jet_template_id": line_template.id, - "order": 10, - } - ) - - return template, line_template, server, install_record, install_line_record - - # ====================== - # Manager Read Access Tests - # ====================== - - def test_manager_read_server_user_ids_line_template_access_level_manager(self): - """ - Test Manager: Read when in server user_ids - and line template access_level <= 2. - """ - _, _, _, _, install_line_record = self._create_install_line_record( - line_template_access_level="2", - server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateInstallLine.with_user(self.manager).search( - [("id", "=", install_line_record.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in server user_ids " - "and line template access_level <= 2.", - ) - - def test_manager_read_server_manager_ids_line_template_access_level_manager(self): - """ - Test Manager: Read when in server manager_ids - and line template access_level <= 2. - """ - _, _, _, _, install_line_record = self._create_install_line_record( - line_template_access_level="2", - server_manager_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateInstallLine.with_user(self.manager).search( - [("id", "=", install_line_record.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in server manager_ids" - " and line template access_level <= 2", - ) - - def test_manager_read_line_template_user_ids_override(self): - """ - Test Manager: Read when in line template user_ids overrides access_level - (server user_ids or manager_ids). - """ - # Test with server user_ids - _, _, _, _, install_line_record1 = self._create_install_line_record( - line_template_access_level="3", # Root level - normally not accessible - line_template_user_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateInstallLine.with_user(self.manager).search( - [("id", "=", install_line_record1.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in line template user_ids" " and server user_ids", - ) - - # Test with server manager_ids - _, _, _, _, install_line_record2 = self._create_install_line_record( - line_template_access_level="3", # Root level - normally not accessible - line_template_user_ids=[(4, self.manager.id)], - server_manager_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateInstallLine.with_user(self.manager).search( - [("id", "=", install_line_record2.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read when in line template user_ids" - " and server manager_ids", - ) - - def test_manager_read_no_access_no_server_access(self): - """ - Test Manager: No read access when not in server - user_ids and manager_ids. - """ - _, _, _, _, install_line_record = self._create_install_line_record( - line_template_access_level="1", - server_user_ids=[(5, 0, 0)], - server_manager_ids=[(5, 0, 0)], - ) - - records = self.JetTemplateInstallLine.with_user(self.manager).search( - [("id", "=", install_line_record.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read when not in server user_ids or manager_ids", - ) - - def test_manager_read_no_access_line_template_root_level(self): - """ - Test Manager: No read access when line template - access_level is Root and not in line template user_ids. - """ - _, _, _, _, install_line_record = self._create_install_line_record( - line_template_access_level="3", # Root level - line_template_user_ids=[(5, 0, 0)], - server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateInstallLine.with_user(self.manager).search( - [("id", "=", install_line_record.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read when line template access_level" - " is Root and not in line template user_ids", - ) - - def test_manager_read_no_access_line_template_manager_level_no_server_access(self): - """ - Test Manager: No read access when line template access_level - is Manager but not in server. - """ - _, _, _, _, install_line_record = self._create_install_line_record( - line_template_access_level="2", - server_user_ids=[(5, 0, 0)], - server_manager_ids=[(5, 0, 0)], - ) - - records = self.JetTemplateInstallLine.with_user(self.manager).search( - [("id", "=", install_line_record.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read when not in server" - " even if line template access_level is Manager", - ) - - def test_manager_write_forbidden(self): - """Test Manager: Cannot write/create/delete records""" - _, _, _, _, install_line_record = self._create_install_line_record( - line_template_access_level="2", - server_user_ids=[(4, self.manager.id)], - ) - - # Manager should not be able to write - with self.assertRaises(AccessError): - install_line_record.with_user(self.manager).write({"state": "done"}) - - # Manager should not be able to create - template = self.JetTemplate.create( - { - "name": "New Template", - "reference": "new_template", - "access_level": "2", - } - ) - line_template = self.JetTemplate.create( - { - "name": "New Line Template", - "reference": "new_line_template", - "access_level": "2", - } - ) - server = self.server_test_1 - server.write({"user_ids": [(4, self.manager.id)]}) - - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - } - ) - - with self.assertRaises(AccessError): - self.JetTemplateInstallLine.with_user(self.manager).create( - { - "jet_template_install_id": install_record.id, - "jet_template_id": line_template.id, - "order": 10, - } - ) - - # Manager should not be able to delete - with self.assertRaises(AccessError): - install_line_record.with_user(self.manager).unlink() - - # ====================== - # Root Access Tests - # ====================== - - def test_root_write_access(self): - """Test Root: Can write any record""" - _, _, _, _, install_line_record = self._create_install_line_record() - - # Root should be able to write - try: - install_line_record.with_user(self.root).write({"state": "done"}) - install_line_record.invalidate_recordset() - self.assertEqual( - install_line_record.state, "done", "Root should be able to update" - ) - except AccessError: - self.fail("Root should be able to update any record") - - def test_root_create_access(self): - """Test Root: Can create any record""" - template = self.JetTemplate.with_user(self.root).create( - { - "name": "Root Template", - "reference": "root_template", - "access_level": "3", - } - ) - line_template = self.JetTemplate.with_user(self.root).create( - { - "name": "Root Line Template", - "reference": "root_line_template", - "access_level": "3", - } - ) - server = self.server_test_1 - - install_record = self.JetTemplateInstall.create( - { - "jet_template_id": template.id, - "server_id": server.id, - } - ) - - # Root should be able to create - try: - install_line_record = self.JetTemplateInstallLine.with_user( - self.root - ).create( - { - "jet_template_install_id": install_record.id, - "jet_template_id": line_template.id, - "order": 10, - } - ) - records = self.JetTemplateInstallLine.with_user(self.root).search( - [("id", "=", install_line_record.id)] - ) - self.assertEqual(len(records), 1, "Root should be able to create") - except AccessError: - self.fail("Root should be able to create any record") - - def test_root_delete_access(self): - """Test Root: Can delete any record""" - _, _, _, _, install_line_record = self._create_install_line_record() - - # Root should be able to delete - try: - install_line_record.with_user(self.root).unlink() - records = self.JetTemplateInstallLine.with_user(self.root).search( - [("id", "=", install_line_record.id)] - ) - self.assertEqual(len(records), 0, "Root should be able to delete") - except AccessError: - self.fail("Root should be able to delete any record") - - def test_root_access_all_scenarios(self): - """Test Root can access records in all scenarios""" - # Test various combinations - scenarios = [ - { - "line_template_access_level": "1", - "server_user_ids": [(5, 0, 0)], - "server_manager_ids": [(5, 0, 0)], - }, - { - "line_template_access_level": "2", - "server_user_ids": [(5, 0, 0)], - "server_manager_ids": [(5, 0, 0)], - }, - { - "line_template_access_level": "3", - "server_user_ids": [(5, 0, 0)], - "server_manager_ids": [(5, 0, 0)], - }, - ] - - for scenario in scenarios: - _, _, _, _, install_line_record = self._create_install_line_record( - **scenario - ) - records = self.JetTemplateInstallLine.with_user(self.root).search( - [("id", "=", install_line_record.id)] - ) - self.assertEqual( - len(records), - 1, - f"Root should be able to read record with scenario: {scenario}", - ) - - # ====================== - # Edge Cases - # ====================== - - def test_manager_read_multiple_servers(self): - """Test Manager access across multiple servers""" - # Manager in server 1, line template accessible - _, line_template1, _, _, install_line1 = self._create_install_line_record( - line_template_access_level="2", - server_user_ids=[(4, self.manager.id)], - ) - - # Manager not in server 2, same line template - _, _, _, _, install_line2 = self._create_install_line_record( - line_template=line_template1, - server=self.server_test_2, - line_template_access_level="2", - server_user_ids=[(5, 0, 0)], - server_manager_ids=[(5, 0, 0)], - ) - - # Manager should only see install_line1 - records = self.JetTemplateInstallLine.with_user(self.manager).search( - [("id", "in", [install_line1.id, install_line2.id])] - ) - self.assertEqual( - len(records), 1, "Manager should only see accessible install line" - ) - self.assertEqual( - records[0].id, install_line1.id, "Manager should see install_line1" - ) - - def test_manager_read_multiple_line_templates(self): - """Test Manager access with multiple line templates""" - # Line Template 1: Manager level, Manager in server - _, _, _, _, install_line1 = self._create_install_line_record( - line_template_access_level="2", - server_user_ids=[(4, self.manager.id)], - ) - - # Line Template 2: Root level, Manager in server but line template user_ids - _, _, _, _, install_line2 = self._create_install_line_record( - line_template_access_level="3", - line_template_user_ids=[(4, self.manager.id)], - server_user_ids=[(4, self.manager.id)], - ) - - # Manager should see both - records = self.JetTemplateInstallLine.with_user(self.manager).search( - [("id", "in", [install_line1.id, install_line2.id])] - ) - self.assertEqual(len(records), 2, "Manager should see both install lines") - - def test_manager_read_parent_template_does_not_affect_access(self): - """ - Test Manager: Parent install template access level - does not affect Install Line access. - """ - # Verify that Install Line access only depends on server_id and line template, - # not the parent install template. - # Create a line with Root-level parent template, - # but accessible line template - should still be accessible. - _, _, _, _, install_line_record = self._create_install_line_record( - template=self.JetTemplate.create( - { - "name": "Root Parent Template", - "reference": "root_parent_template", - "access_level": "3", - } - ), - line_template_access_level="2", # Manager level - accessible - server_user_ids=[(4, self.manager.id)], - ) - - records = self.JetTemplateInstallLine.with_user(self.manager).search( - [("id", "=", install_line_record.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should read Install Line when line template " - "and server are accessible, " - "regardless of parent install template access level", - ) diff --git a/addons/cetmix_tower_server/tests/test_jet_waypoint.py b/addons/cetmix_tower_server/tests/test_jet_waypoint.py deleted file mode 100644 index 1f3d022..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_waypoint.py +++ /dev/null @@ -1,1995 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import ValidationError -from odoo.tools import mute_logger - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetWaypoint(TestTowerJetsCommon): - """ - Test the Jet Waypoint model functionality - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create variables for testing - cls.variable_test_1 = cls.Variable.create( - { - "name": "Test Variable 1", - "reference": "test_var_1", - } - ) - cls.variable_test_2 = cls.Variable.create( - { - "name": "Test Variable 2", - "reference": "test_var_2", - } - ) - cls.variable_test_3 = cls.Variable.create( - { - "name": "Test Variable 3", - "reference": "test_var_3", - } - ) - # waypoint_template and waypoint are now inherited from TestTowerJetsCommon - - # Create commands for flight plans - cls.command_success = cls.Command.create( - { - "name": "Command -> Success", - "action": "python_code", - "code": "# Just return default values", - } - ) - cls.command_error = cls.Command.create( - { - "name": "Command -> Error", - "action": "python_code", - "code": "result = {'exit_code': -100, 'message': 'Error'}", - } - ) - cls.command_waypoint_check = cls.Command.create( - { - "name": "Command -> Waypoint Check", - "action": "python_code", - "code": ( - "result = {'exit_code': waypoint.id if waypoint else -1, " - "'message': 'waypoint check'}" - ), - } - ) - - # Create flight plans - cls.plan_success = cls.Plan.create( - { - "name": "Waypoint Success Plan", - } - ) - cls.plan_line.create( - { - "sequence": 10, - "plan_id": cls.plan_success.id, - "command_id": cls.command_success.id, - } - ) - - cls.plan_error = cls.Plan.create( - { - "name": "Waypoint Error Plan", - } - ) - cls.plan_line.create( - { - "sequence": 10, - "plan_id": cls.plan_error.id, - "command_id": cls.command_error.id, - } - ) - - cls.plan_waypoint_check = cls.Plan.create( - { - "name": "Waypoint Check Plan", - } - ) - cls.plan_line.create( - { - "sequence": 10, - "plan_id": cls.plan_waypoint_check.id, - "command_id": cls.command_waypoint_check.id, - } - ) - - def test_save_variable_values_empty(self): - """ - Test _save_variable_values when jet has no variable values - """ - # Ensure jet has no variable values - self.jet_test.variable_value_ids.unlink() - - # Save variable values - result = self.waypoint._save_variable_values() - - # Should return True - self.assertTrue(result, "Should return True when saving values") - - # Waypoint should have empty variable_values (or False, which is equivalent) - variable_values = self.waypoint.variable_values or {} - self.assertEqual( - variable_values, - {}, - "Variable values should be empty dict when jet has no values", - ) - - def test_save_variable_values_with_values(self): - """ - Test _save_variable_values when jet has variable values - """ - # Create variable values for the jet - self.VariableValue.create( - { - "variable_id": self.variable_test_1.id, - "value_char": "value_1", - "jet_id": self.jet_test.id, - } - ) - self.VariableValue.create( - { - "variable_id": self.variable_test_2.id, - "value_char": "value_2", - "jet_id": self.jet_test.id, - } - ) - - # Save variable values - result = self.waypoint._save_variable_values() - - # Should return True - self.assertTrue(result, "Should return True when saving values") - - # Waypoint should have saved variable values - self.assertEqual( - self.waypoint.variable_values, - {"test_var_1": "value_1", "test_var_2": "value_2"}, - "Variable values should be saved correctly", - ) - - def test_save_variable_values_with_empty_string(self): - """ - Test _save_variable_values when variable value is empty string - """ - # Create variable value with empty string - self.VariableValue.create( - { - "variable_id": self.variable_test_1.id, - "value_char": "", - "jet_id": self.jet_test.id, - } - ) - - # Save variable values - self.waypoint._save_variable_values() - - # Waypoint should have saved empty string value - self.assertEqual( - self.waypoint.variable_values, - {"test_var_1": ""}, - "Empty string values should be saved", - ) - - def test_save_variable_values_only_jet_values(self): - """ - Test _save_variable_values only saves jet-specific values, - not template/server/global values - """ - # Create jet-specific variable value - self.VariableValue.create( - { - "variable_id": self.variable_test_1.id, - "value_char": "jet_value", - "jet_id": self.jet_test.id, - } - ) - - # Create template variable value (should not be saved) - self.VariableValue.create( - { - "variable_id": self.variable_test_2.id, - "value_char": "template_value", - "jet_template_id": self.jet_template_test.id, - } - ) - - # Save variable values - self.waypoint._save_variable_values() - - # Waypoint should only have jet-specific value - self.assertEqual( - self.waypoint.variable_values, - {"test_var_1": "jet_value"}, - "Should only save jet-specific values", - ) - self.assertNotIn( - "test_var_2", - self.waypoint.variable_values, - "Should not save template values", - ) - - def test_restore_variable_values_empty(self): - """ - Test _restore_variable_values when waypoint has no saved values - """ - # Create some variable values in jet - self.VariableValue.create( - { - "variable_id": self.variable_test_1.id, - "value_char": "existing_value", - "jet_id": self.jet_test.id, - } - ) - - # Set waypoint variable_values to empty - self.waypoint.variable_values = {} - - # Restore variable values - result = self.waypoint._restore_variable_values() - - # Should return True - self.assertTrue(result, "Should return True when restoring values") - - # Jet should have no variable values - self.assertEqual( - len(self.jet_test.variable_value_ids), - 0, - "All jet variable values should be removed when waypoint is empty", - ) - - def test_restore_variable_values_basic(self): - """ - Test _restore_variable_values restores values correctly - """ - # Set waypoint variable values - self.waypoint.variable_values = { - "test_var_1": "restored_value_1", - "test_var_2": "restored_value_2", - } - - # Restore variable values - result = self.waypoint._restore_variable_values() - - # Should return True - self.assertTrue(result, "Should return True when restoring values") - - # Check values were restored - self.assertEqual( - self.jet_test.get_variable_value("test_var_1", no_fallback=True), - "restored_value_1", - "Variable 1 should be restored", - ) - self.assertEqual( - self.jet_test.get_variable_value("test_var_2", no_fallback=True), - "restored_value_2", - "Variable 2 should be restored", - ) - - def test_restore_variable_values_removes_unsaved(self): - """ - Test _restore_variable_values removes variable values not in waypoint - """ - # Create variable values in jet - self.VariableValue.create( - { - "variable_id": self.variable_test_1.id, - "value_char": "value_1", - "jet_id": self.jet_test.id, - } - ) - self.VariableValue.create( - { - "variable_id": self.variable_test_2.id, - "value_char": "value_2", - "jet_id": self.jet_test.id, - } - ) - self.VariableValue.create( - { - "variable_id": self.variable_test_3.id, - "value_char": "value_3", - "jet_id": self.jet_test.id, - } - ) - - # Set waypoint to only have variable 1 and 2 - self.waypoint.variable_values = { - "test_var_1": "value_1", - "test_var_2": "value_2", - } - - # Restore variable values - self.waypoint._restore_variable_values() - - # Variable 3 should be removed - self.assertIsNone( - self.jet_test.get_variable_value("test_var_3", no_fallback=True), - "Variable 3 should be removed", - ) - - # Variables 1 and 2 should still exist - self.assertEqual( - self.jet_test.get_variable_value("test_var_1", no_fallback=True), - "value_1", - "Variable 1 should still exist", - ) - self.assertEqual( - self.jet_test.get_variable_value("test_var_2", no_fallback=True), - "value_2", - "Variable 2 should still exist", - ) - - def test_restore_variable_values_updates_existing(self): - """ - Test _restore_variable_values updates existing variable values - """ - # Create variable value in jet - self.VariableValue.create( - { - "variable_id": self.variable_test_1.id, - "value_char": "old_value", - "jet_id": self.jet_test.id, - } - ) - - # Set waypoint with new value - self.waypoint.variable_values = {"test_var_1": "new_value"} - - # Restore variable values - self.waypoint._restore_variable_values() - - # Value should be updated - self.assertEqual( - self.jet_test.get_variable_value("test_var_1", no_fallback=True), - "new_value", - "Variable value should be updated", - ) - - def test_save_and_restore_roundtrip(self): - """ - Test saving and restoring variable values in a roundtrip - """ - # Create initial variable values - self.VariableValue.create( - { - "variable_id": self.variable_test_1.id, - "value_char": "initial_value_1", - "jet_id": self.jet_test.id, - } - ) - self.VariableValue.create( - { - "variable_id": self.variable_test_2.id, - "value_char": "initial_value_2", - "jet_id": self.jet_test.id, - } - ) - - # Save variable values - self.waypoint._save_variable_values() - - # Modify jet values - self.jet_test.set_variable_value("test_var_1", "modified_value_1") - self.jet_test.set_variable_value("test_var_2", "modified_value_2") - self.VariableValue.create( - { - "variable_id": self.variable_test_3.id, - "value_char": "new_value", - "jet_id": self.jet_test.id, - } - ) - - # Restore variable values - self.waypoint._restore_variable_values() - - # Values should be restored to original - self.assertEqual( - self.jet_test.get_variable_value("test_var_1", no_fallback=True), - "initial_value_1", - "Variable 1 should be restored to original value", - ) - self.assertEqual( - self.jet_test.get_variable_value("test_var_2", no_fallback=True), - "initial_value_2", - "Variable 2 should be restored to original value", - ) - # Variable 3 should be removed (not in saved waypoint) - self.assertIsNone( - self.jet_test.get_variable_value("test_var_3", no_fallback=True), - "Variable 3 should be removed", - ) - - def test_write_waypoint_template_draft_allowed(self): - """ - Test that modifying waypoint_template_id is allowed when state is draft - """ - # Create waypoint in draft state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Draft", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "draft", - } - ) - - # Should be able to change template in draft state - waypoint.write({"waypoint_template_id": self.waypoint_template_2.id}) - self.assertEqual( - waypoint.waypoint_template_id.id, - self.waypoint_template_2.id, - "Should be able to change template in draft state", - ) - - def test_write_waypoint_template_not_draft_raises_error(self): - """ - Test that modifying waypoint_template_id raises ValidationError - when state is not draft - """ - # Create waypoint in ready state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Ready", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "ready", - } - ) - - # Should raise ValidationError when trying to change template - with self.assertRaises(ValidationError) as context: - waypoint.write({"waypoint_template_id": self.waypoint_template_2.id}) - - self.assertIn( - "draft state", - str(context.exception), - "Should raise ValidationError about draft state", - ) - - def test_write_waypoint_template_same_value_allowed(self): - """ - Test that setting waypoint_template_id to the same value is allowed - even when not in draft state - """ - # Create waypoint in ready state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Ready", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "ready", - } - ) - original_template_id = waypoint.waypoint_template_id.id - - # Should be able to set to the same template - waypoint.write({"waypoint_template_id": original_template_id}) - self.assertEqual( - waypoint.waypoint_template_id.id, - original_template_id, - "Should be able to set same template value", - ) - - def test_write_other_fields_not_draft_allowed(self): - """ - Test that modifying other fields is allowed when state is not draft - """ - # Create waypoint in ready state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Ready", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "ready", - } - ) - - # Should be able to modify other fields - waypoint.write({"name": "Updated Name"}) - self.assertEqual( - waypoint.name, - "Updated Name", - "Should be able to modify other fields when not in draft", - ) - - def test_prepare_without_flight_plan(self): - """ - Test prepare() when waypoint template has no plan_create_id - """ - # Create waypoint template without plan_create_id - waypoint_template_no_plan = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template No Plan", - "jet_template_id": self.jet_template_test.id, - } - ) - - # Create waypoint in draft state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint No Plan", - "jet_id": self.jet_test.id, - "waypoint_template_id": waypoint_template_no_plan.id, - "state": "draft", - } - ) - - # Call prepare - result = waypoint.prepare() - - # Should return True and set state to ready - self.assertTrue(result, "Should return True") - self.assertEqual( - waypoint.state, - "ready", - "State should be set to ready when no flight plan", - ) - - def test_prepare_without_flight_plan_with_is_destination(self): - """ - Test prepare() when waypoint template has no plan_create_id - and is_destination=True - Should automatically call fly_to() when prepare completes - """ - # Create waypoint template without plan_create_id - waypoint_template_no_plan = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template No Plan Destination", - "jet_template_id": self.jet_template_test.id, - } - ) - - # Create waypoint in draft state with is_destination=True - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint No Plan Destination", - "jet_id": self.jet_test.id, - "waypoint_template_id": waypoint_template_no_plan.id, - "state": "draft", - } - ) - - # Call prepare - result = waypoint.prepare(is_destination=True) - - # Should return True - self.assertTrue(result, "Should return True") - # State should be set to current (because fly_to() was called) - # Since there's no previous waypoint and no plan_arrive_id, - # fly_to() sets state to arriving and calls _arrive() which sets it to current - self.assertEqual( - waypoint.state, - "current", - "State should be set to current after fly_to() and _arrive()", - ) - # Waypoint should be set as current waypoint - self.assertEqual( - self.jet_test.waypoint_id.id, - waypoint.id, - "Waypoint should be set as current waypoint after fly_to()", - ) - # is_destination should be cleared after arriving - self.assertFalse( - waypoint.is_destination, - "is_destination should be cleared after arriving", - ) - - def test_prepare_with_flight_plan_success(self): - """ - Test prepare() when waypoint template has plan_create_id and plan succeeds - """ - # Set template to use success plan - self.waypoint_template.plan_create_id = self.plan_success.id - - # Create waypoint in draft state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint With Plan", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "draft", - } - ) - - # Call prepare - plan executes synchronously in tests - result = waypoint.prepare() - - # Should return True - self.assertTrue(result, "Should return True") - - # State should be set to ready after successful plan completion - # (plan executes synchronously in tests, preparing -> ready) - self.assertEqual( - waypoint.state, - "ready", - "State should be set to ready after successful plan completion", - ) - # Waypoint should NOT be set as current waypoint after preparing - # (only arriving sets waypoint as current) - self.assertNotEqual( - self.jet_test.waypoint_id.id if self.jet_test.waypoint_id else False, - waypoint.id, - "Waypoint should not be set as current waypoint after preparing", - ) - - def test_waypoint_variable_in_python_command_prepare(self): - """ - Test that 'waypoint' variable is available in Python commands - run for a waypoint plan (plan_create) and its id is used as exit code - """ - # Set template to use waypoint check plan - self.waypoint_template.plan_create_id = self.plan_waypoint_check.id - - # Create waypoint in draft state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint For Variable Check", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "draft", - } - ) - - # Call prepare - plan executes synchronously in tests - waypoint.prepare() - - # Find the plan log created by prepare - plan_log = self.PlanLog.search( - [("waypoint_id", "=", waypoint.id)], - order="create_date desc", - limit=1, - ) - self.assertTrue(plan_log, "Plan log should be created") - - # Plan exit code (plan_status) must equal waypoint id - self.assertEqual( - plan_log.plan_status, - waypoint.id, - "Plan status must equal waypoint id (from waypoint variable)", - ) - - def test_waypoint_variable_in_python_command_arrive(self): - """ - Test that 'waypoint' variable is available in Python commands - run for a waypoint arrive plan and its id is used as exit code - """ - # Create waypoint template with plan_arrive_id - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Waypoint Template For Arrive Check", - "jet_template_id": self.jet_template_test.id, - "plan_arrive_id": self.plan_waypoint_check.id, - } - ) - - # Create waypoint in arriving state (no previous waypoint) - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint For Arrive Variable Check", - "jet_id": self.jet_test.id, - "waypoint_template_id": waypoint_template.id, - "state": "arriving", - } - ) - - # Call arrive - plan executes synchronously in tests - waypoint._arrive() - - # Find the plan log created by arrive - plan_log = self.PlanLog.search( - [("waypoint_id", "=", waypoint.id)], - order="create_date desc", - limit=1, - ) - self.assertTrue(plan_log, "Plan log should be created") - - # Plan exit code (plan_status) must equal waypoint id - self.assertEqual( - plan_log.plan_status, - waypoint.id, - "Plan status must equal waypoint id (from waypoint variable)", - ) - - def test_prepare_with_flight_plan_error(self): - """ - Test prepare() when waypoint template has plan_create_id and plan fails - """ - # Set template to use error plan - self.waypoint_template.plan_create_id = self.plan_error.id - - # Create waypoint in draft state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint With Plan Error", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "draft", - } - ) - - # Call prepare - plan executes synchronously in tests - with mute_logger( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" - ): - result = waypoint.prepare() - - # Should return True - self.assertTrue(result, "Should return True") - - # State should be set to error after failed plan completion - # (plan executes synchronously in tests) - self.assertEqual( - waypoint.state, - "error", - "State should be set to error after failed plan completion", - ) - # Waypoint should not be set as current waypoint on error - self.assertNotEqual( - self.jet_test.waypoint_id.id, - waypoint.id, - "Waypoint should not be set as current waypoint after failed prepare", - ) - - def test_prepare_not_draft_state(self): - """ - Test prepare() when waypoint is not in draft state - """ - # Create waypoint in ready state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Ready", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "ready", - } - ) - - # Call prepare. This will log and error because waypoint is not in draft state - with mute_logger( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" - ): - with self.assertRaises(ValidationError): - waypoint.prepare() - - def test_plan_finished_preparing_success(self): - """ - Test _plan_finished when waypoint is in preparing state and plan succeeds - """ - # Create waypoint in preparing state (simulating async plan execution) - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Preparing", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "preparing", - } - ) - - # Create plan log with success status - plan_log = self.PlanLog.create( - { - "server_id": self.jet_test.server_id.id, - "plan_id": self.plan_success.id, - "plan_status": 0, # Success - } - ) - - # Call _plan_finished - result = waypoint._plan_finished(plan_log) - - # Should return True - self.assertTrue(result, "Should return True") - # State should be set to ready - # (preparing -> ready, not current) - self.assertEqual( - waypoint.state, - "ready", - "State should be set to ready after successful plan completion", - ) - # Waypoint should NOT be set as current waypoint after preparing - # (only arriving sets waypoint as current) - self.assertNotEqual( - self.jet_test.waypoint_id.id if self.jet_test.waypoint_id else False, - waypoint.id, - "Waypoint should not be set as current waypoint after preparing", - ) - - def test_plan_finished_preparing_success_with_is_destination(self): - """ - Test _plan_finished when waypoint is in preparing state with is_destination=True - Should automatically call fly_to() when preparing finishes - """ - # Create waypoint in preparing state with is_destination=True - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Preparing Destination", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "preparing", - "is_destination": True, - } - ) - - # Create plan log with success status - plan_log = self.PlanLog.create( - { - "server_id": self.jet_test.server_id.id, - "plan_id": self.plan_success.id, - "plan_status": 0, # Success - } - ) - - # Call _plan_finished - result = waypoint._plan_finished(plan_log) - - # Should return True - self.assertTrue(result, "Should return True") - # State should be set to arriving (because fly_to() was called) - # Since there's no previous waypoint and no plan_arrive_id, - # fly_to() sets state to arriving and calls _arrive() which sets it to current - self.assertEqual( - waypoint.state, - "current", - "State should be set to current after fly_to() and _arrive()", - ) - # Waypoint should be set as current waypoint - self.assertEqual( - self.jet_test.waypoint_id.id, - waypoint.id, - "Waypoint should be set as current waypoint after fly_to()", - ) - # is_destination should be cleared after arriving - self.assertFalse( - waypoint.is_destination, - "is_destination should be cleared after arriving", - ) - - def test_plan_finished_arriving_success(self): - """ - Test _plan_finished when waypoint is in arriving state and plan succeeds - """ - # Create waypoint in arriving state (simulating async plan execution) - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Arriving", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "arriving", - } - ) - - # Create plan log with success status - plan_log = self.PlanLog.create( - { - "server_id": self.jet_test.server_id.id, - "plan_id": self.plan_success.id, - "plan_status": 0, # Success - } - ) - - # Call _plan_finished - result = waypoint._plan_finished(plan_log) - - # Should return True - self.assertTrue(result, "Should return True") - # State should be set to current - # (waypoint becomes current after successful arrive) - self.assertEqual( - waypoint.state, - "current", - "State should be set to current after successful plan completion", - ) - # Waypoint should be set as current waypoint - self.assertEqual( - self.jet_test.waypoint_id.id, - waypoint.id, - "Waypoint should be set as current waypoint after successful arrive", - ) - - def test_plan_finished_leaving_success(self): - """ - Test _plan_finished when waypoint is in leaving state and plan succeeds - """ - # Create current waypoint in current state - current_waypoint = self.JetWaypoint.create( - { - "name": "Current Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "current", - } - ) - self.jet_test.waypoint_id = current_waypoint.id - - # Create destination waypoint in arriving state - destination_waypoint = self.JetWaypoint.create( - { - "name": "Destination Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "is_destination": True, - "state": "arriving", - } - ) - - # Set current waypoint to leaving state - # readonly=True only affects UI, can be written programmatically - current_waypoint.write({"state": "leaving"}) - - # Create plan log with success status - plan_log = self.PlanLog.create( - { - "server_id": self.jet_test.server_id.id, - "plan_id": self.plan_success.id, - "plan_status": 0, # Success - } - ) - - # Call _plan_finished on leaving waypoint - result = current_waypoint._plan_finished(plan_log) - - # Should return True - self.assertTrue(result, "Should return True") - # Leaving waypoint state should be set to ready - self.assertEqual( - current_waypoint.state, - "ready", - "Leaving waypoint state should be set to ready", - ) - # Destination waypoint should have _arrive() called - # (state should be current if no plan_arrive_id) - # Since waypoint_template has no plan_arrive_id by default, - # _arrive() sets state to current - self.assertEqual( - destination_waypoint.state, - "current", - "Destination waypoint should have _arrive() called", - ) - # Destination waypoint should be set as current waypoint - self.assertEqual( - self.jet_test.waypoint_id.id, - destination_waypoint.id, - "Destination waypoint should be set as current waypoint" - " after leaving completes", - ) - - def test_plan_finished_deleting_success(self): - """ - Test _plan_finished when waypoint is in deleting state and plan succeeds - """ - # Create waypoint template with plan_delete_id - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Template With Delete Plan", - "jet_template_id": self.jet_template_test.id, - "plan_delete_id": self.plan_success.id, - } - ) - - # Create waypoint and set it as current - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Deleting", - "jet_id": self.jet_test.id, - "waypoint_template_id": waypoint_template.id, - "state": "ready", - } - ) - self.jet_test.waypoint_id = waypoint.id - - # Set waypoint to deleting state - # readonly=True only affects UI, can be written programmatically - waypoint.write({"state": "deleting"}) - - # Create plan log with success status - plan_log = self.PlanLog.create( - { - "server_id": self.jet_test.server_id.id, - "plan_id": self.plan_success.id, - "plan_status": 0, # Success - } - ) - - # Call _plan_finished - result = waypoint._plan_finished(plan_log) - - # Should return True - self.assertTrue(result, "Should return True") - # Waypoint should be unlinked (deleted) - # State is set to "deleted" before unlink - self.assertFalse( - waypoint.exists(), - "Waypoint should be unlinked after successful delete plan", - ) - # Jet waypoint_id should be set to False - self.assertFalse( - self.jet_test.waypoint_id, - "Jet waypoint_id should be set to False after successful delete", - ) - - def test_plan_finished_error(self): - """ - Test _plan_finished when plan fails (plan_status != 0) - """ - # Create waypoint in preparing state (simulating async plan execution) - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Preparing", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "preparing", - } - ) - original_waypoint_id = ( - self.jet_test.waypoint_id.id if self.jet_test.waypoint_id else False - ) - - # Create plan log with error status - plan_log = self.PlanLog.create( - { - "server_id": self.jet_test.server_id.id, - "plan_id": self.plan_error.id, - "plan_status": 1, # Error - } - ) - - # Call _plan_finished - with mute_logger( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" - ): - result = waypoint._plan_finished(plan_log) - - # Should return True - self.assertTrue(result, "Should return True") - # State should be set to error - self.assertEqual( - waypoint.state, - "error", - "State should be set to error after failed plan completion", - ) - # Waypoint should not be set as current waypoint - if original_waypoint_id: - self.assertEqual( - self.jet_test.waypoint_id.id, - original_waypoint_id, - "Current waypoint should not change on error", - ) - else: - self.assertFalse( - self.jet_test.waypoint_id, - "Current waypoint should remain False on error", - ) - - def test_plan_finished_error_arriving(self): - """ - Test _plan_finished when waypoint is in arriving state and plan fails - """ - # Create waypoint in arriving state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Arriving", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "arriving", - } - ) - - # Create plan log with error status - plan_log = self.PlanLog.create( - { - "server_id": self.jet_test.server_id.id, - "plan_id": self.plan_error.id, - "plan_status": 1, # Error - } - ) - - # Call _plan_finished - with mute_logger( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" - ): - result = waypoint._plan_finished(plan_log) - - # Should return True - self.assertTrue(result, "Should return True") - # State should be set to error - self.assertEqual( - waypoint.state, - "error", - "State should be set to error after failed plan completion", - ) - # Waypoint should not be set as current waypoint on error - self.assertNotEqual( - self.jet_test.waypoint_id.id if self.jet_test.waypoint_id else False, - waypoint.id, - "Waypoint should not be set as current waypoint after failed arrive", - ) - - def test_get_custom_variable_values_with_metadata(self): - """ - Test _get_custom_variable_values with metadata - """ - # Set template to use success plan - self.waypoint_template.plan_create_id = self.plan_success.id - - # Create waypoint with metadata - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint With Metadata", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "draft", - "metadata": {"key1": "value1", "key2": "value2", "env": "production"}, - } - ) - - # Call prepare to trigger flight plan - waypoint.prepare() - - # Find the plan log created by prepare - plan_log = self.PlanLog.search( - [ - ("waypoint_id", "=", waypoint.id), - ], - order="create_date desc", - limit=1, - ) - self.assertTrue(plan_log, "Plan log should be created") - - # Check custom variable values in plan log - self.assertEqual( - plan_log.variable_values.get("__waypoint"), - waypoint.reference, - "__waypoint should match waypoint reference", - ) - self.assertEqual( - plan_log.variable_values.get("__waypoint_type"), - self.waypoint_template.reference, - "__waypoint_type should match waypoint template reference", - ) - self.assertEqual( - plan_log.variable_values.get("__waypoint_state"), - "preparing", - "__waypoint_state should be preparing", - ) - # Check metadata keys - self.assertEqual( - plan_log.variable_values.get("__waypoint_key1"), - "value1", - "__waypoint_key1 should match metadata value", - ) - self.assertEqual( - plan_log.variable_values.get("__waypoint_key2"), - "value2", - "__waypoint_key2 should match metadata value", - ) - self.assertEqual( - plan_log.variable_values.get("__waypoint_env"), - "production", - "__waypoint_env should match metadata value", - ) - - def test_get_custom_variable_values_without_metadata(self): - """ - Test _get_custom_variable_values without metadata - """ - # Set template to use success plan - self.waypoint_template.plan_create_id = self.plan_success.id - - # Create waypoint without metadata - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Without Metadata", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "draft", - } - ) - - # Call prepare to trigger flight plan - waypoint.prepare() - - # Find the plan log created by prepare - plan_log = self.PlanLog.search( - [("waypoint_id", "=", waypoint.id)], - order="create_date desc", - limit=1, - ) - self.assertTrue(plan_log, "Plan log should be created") - - # Check basic custom variable values - self.assertEqual( - plan_log.variable_values.get("__waypoint"), - waypoint.reference, - "__waypoint should match waypoint reference", - ) - self.assertEqual( - plan_log.variable_values.get("__waypoint_type"), - self.waypoint_template.reference, - "__waypoint_type should match waypoint template reference", - ) - self.assertEqual( - plan_log.variable_values.get("__waypoint_state"), - "preparing", - "__waypoint_state should be preparing", - ) - # Check that metadata keys are not present - self.assertNotIn( - "__waypoint_key1", - plan_log.variable_values, - "Metadata keys should not be present when metadata is empty", - ) - - def test_leave_from_current_state(self): - """ - Test _leave() when waypoint is in current state - """ - # Create waypoint in current state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Current", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "current", - } - ) - self.jet_test.waypoint_id = waypoint.id - - # Call _leave - result = waypoint._leave() - - # Should return True - self.assertTrue(result, "Should return True") - # State should be set to ready - # (_leave() completes immediately when no plan_leave_id in tests) - self.assertEqual( - waypoint.state, - "ready", - "State should be set to ready after leaving completes", - ) - - def test_fly_to_from_current_waypoint(self): - """ - Test fly_to() when previous waypoint is in current state - """ - # Create current waypoint - current_waypoint = self.JetWaypoint.create( - { - "name": "Current Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "current", - } - ) - self.jet_test.waypoint_id = current_waypoint.id - - # Create destination waypoint - destination_waypoint = self.JetWaypoint.create( - { - "name": "Destination Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "ready", - } - ) - - # Call fly_to on destination waypoint - result = destination_waypoint.fly_to() - - # Should return True - self.assertTrue(result, "Should return True") - # Current waypoint should be in ready state - # (_leave() completes immediately when no plan_leave_id in tests) - self.assertEqual( - current_waypoint.state, - "ready", - "Current waypoint should be in ready state after leaving completes", - ) - # Destination waypoint should be in current state - # (_arrive() completes immediately when no plan_arrive_id in tests) - self.assertEqual( - destination_waypoint.state, - "current", - "Destination waypoint should be in current state after arriving", - ) - # Destination waypoint should be set as current waypoint - self.assertEqual( - self.jet_test.waypoint_id.id, - destination_waypoint.id, - "Destination waypoint should be set as current waypoint", - ) - - def test_fly_to_leave_failure_does_not_keep_destination_arriving(self): - """ - Regression: if source leave plan fails during fly_to(), - destination must not stay in arriving. - """ - # Create template with failing leave plan. - waypoint_template_with_leave_error = self.JetWaypointTemplate.create( - { - "name": "Template Leave Error", - "jet_template_id": self.jet_template_test.id, - "plan_leave_id": self.plan_error.id, - } - ) - - # Create current waypoint that will fail while leaving. - current_waypoint = self.JetWaypoint.create( - { - "name": "Current Waypoint Failing Leave", - "jet_id": self.jet_test.id, - "waypoint_template_id": waypoint_template_with_leave_error.id, - "state": "current", - } - ) - self.jet_test.waypoint_id = current_waypoint.id - - # Create destination waypoint (target of fly_to). - destination_waypoint = self.JetWaypoint.create( - { - "name": "Destination Waypoint Stuck Arriving", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "ready", - } - ) - - # Execute fly_to; leaving fails synchronously in tests. - with mute_logger( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" - ): - result = destination_waypoint.fly_to() - - self.assertFalse(result, "fly_to() should return False when leave fails") - self.assertEqual( - current_waypoint.state, - "error", - "Source waypoint should become error after failed leave plan", - ) - self.assertNotEqual( - destination_waypoint.state, - "arriving", - "Destination waypoint must be reverted from arriving when leave fails", - ) - self.assertFalse( - destination_waypoint.is_destination, - "Destination flag must be cleared when leave fails", - ) - - def test_unlink_current_state_raises_error(self): - """ - Test unlink() when waypoint is in current state raises ValidationError - """ - # Create waypoint in current state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Current", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "current", - } - ) - self.jet_test.waypoint_id = waypoint.id - - # Should raise ValidationError when trying to delete - - with self.assertRaises(ValidationError) as context: - waypoint.unlink() - - self.assertIn( - "current waypoint", - str(context.exception), - "Should raise ValidationError about current waypoint", - ) - - def test_unlink_current_state_with_no_raise_context(self): - """ - Test unlink() when waypoint is in current state - with 'waypoint_no_raise_on_delete' context. - The context prevents exception but waypoint is not deleted. - """ - # Create waypoint in current state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint Current", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "current", - } - ) - self.jet_test.waypoint_id = waypoint.id - waypoint_id = waypoint.id - - # Mute logger error for this test - with mute_logger( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" - ): - # Should not raise error with waypoint_no_raise_on_delete context - waypoint.with_context(waypoint_no_raise_on_delete=True).unlink() - - # Waypoint should still exist (not deleted) - # The context only prevents exception, but doesn't allow deletion - self.assertTrue( - waypoint.exists(), - "Waypoint should still exist - context only prevents exception", - ) - self.assertEqual( - waypoint.id, - waypoint_id, - "Waypoint ID should remain the same", - ) - self.assertEqual( - waypoint.state, - "current", - "Waypoint state should remain current", - ) - - def test_prepare_saves_variable_values(self): - """ - Test that prepare() saves variable values when state changes to ready - """ - # Set some variable values on the jet - self.jet_test.set_variable_value("test_var_1", "value1") - self.jet_test.set_variable_value("test_var_2", "value2") - - # Create waypoint in draft state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "draft", - } - ) - - # Ensure waypoint has no plan_create_id (so it goes directly to ready) - waypoint.waypoint_template_id.plan_create_id = False - - # Call prepare - waypoint.prepare() - - # Variable values should be saved in waypoint - variable_values = waypoint.variable_values or {} - self.assertEqual( - variable_values.get("test_var_1"), - "value1", - "Variable value should be saved when preparing", - ) - self.assertEqual( - variable_values.get("test_var_2"), - "value2", - "Variable value should be saved when preparing", - ) - - def test_prepare_with_plan_saves_variable_values(self): - """ - Test that prepare() saves variable values when plan completes - """ - # Set some variable values on the jet - self.jet_test.set_variable_value("test_var_1", "value1") - self.jet_test.set_variable_value("test_var_2", "value2") - - # Create waypoint template with plan_create_id - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Template", - "jet_template_id": self.jet_template_test.id, - "plan_create_id": self.plan_success.id, - } - ) - - # Create waypoint in draft state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": waypoint_template.id, - "state": "draft", - } - ) - - # Call prepare (plan executes synchronously in tests) - waypoint.prepare() - - # Variable values should be saved in waypoint after plan completes - variable_values = waypoint.variable_values or {} - self.assertEqual( - variable_values.get("test_var_1"), - "value1", - "Variable value should be saved when preparing completes", - ) - self.assertEqual( - variable_values.get("test_var_2"), - "value2", - "Variable value should be saved when preparing completes", - ) - - def test_leave_saves_variable_values(self): - """ - Test that _leave() saves variable values when state changes to ready - """ - # Set some variable values on the jet - self.jet_test.set_variable_value("test_var_1", "value1") - self.jet_test.set_variable_value("test_var_2", "value2") - - # Create waypoint in current state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "current", - } - ) - self.jet_test.waypoint_id = waypoint.id - - # Ensure waypoint has no plan_leave_id (so it goes directly to ready) - waypoint.waypoint_template_id.plan_leave_id = False - - # Call _leave - waypoint._leave() - - # Variable values should be saved in waypoint - variable_values = waypoint.variable_values or {} - self.assertEqual( - variable_values.get("test_var_1"), - "value1", - "Variable value should be saved when leaving", - ) - self.assertEqual( - variable_values.get("test_var_2"), - "value2", - "Variable value should be saved when leaving", - ) - - def test_leave_with_plan_saves_variable_values(self): - """ - Test that _leave() saves variable values when plan completes - """ - # Set some variable values on the jet - self.jet_test.set_variable_value("test_var_1", "value1") - self.jet_test.set_variable_value("test_var_2", "value2") - - # Create waypoint template with plan_leave_id - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Template", - "jet_template_id": self.jet_template_test.id, - "plan_leave_id": self.plan_success.id, - } - ) - - # Create waypoint in current state - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": waypoint_template.id, - "state": "current", - } - ) - self.jet_test.waypoint_id = waypoint.id - - # Call _leave (plan executes synchronously in tests) - waypoint._leave() - - # Variable values should be saved in waypoint after plan completes - variable_values = waypoint.variable_values or {} - self.assertEqual( - variable_values.get("test_var_1"), - "value1", - "Variable value should be saved when leaving completes", - ) - self.assertEqual( - variable_values.get("test_var_2"), - "value2", - "Variable value should be saved when leaving completes", - ) - - def test_fly_to_restores_variable_values(self): - """ - Test that fly_to() restores variable values when state changes to arriving - """ - # Create waypoint with saved variable values - waypoint = self.JetWaypoint.create( - { - "name": "Test Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "ready", - "variable_values": { - "test_var_1": "saved_value1", - "test_var_2": "saved_value2", - }, - } - ) - - # Set different values on the jet - self.jet_test.set_variable_value("test_var_1", "current_value1") - self.jet_test.set_variable_value("test_var_2", "current_value2") - - # Call fly_to (no previous waypoint) - waypoint.fly_to() - - # Variable values should be restored from waypoint - self.assertEqual( - self.jet_test.get_variable_value("test_var_1"), - "saved_value1", - "Variable value should be restored when flying to waypoint", - ) - self.assertEqual( - self.jet_test.get_variable_value("test_var_2"), - "saved_value2", - "Variable value should be restored when flying to waypoint", - ) - - def test_fly_to_restores_variable_values_with_previous_waypoint(self): - """ - Test that fly_to() restores variable values - after previous waypoint saves its values - """ - # Create previous waypoint in current state - previous_waypoint = self.JetWaypoint.create( - { - "name": "Previous Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "current", - } - ) - self.jet_test.waypoint_id = previous_waypoint.id - - # Set variable values on the jet - self.jet_test.set_variable_value("test_var_1", "previous_value1") - self.jet_test.set_variable_value("test_var_2", "previous_value2") - - # Create destination waypoint with saved variable values - destination_waypoint = self.JetWaypoint.create( - { - "name": "Destination Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "ready", - "variable_values": { - "test_var_1": "destination_value1", - "test_var_2": "destination_value2", - }, - } - ) - - # Ensure previous waypoint has no plan_leave_id (so it saves values immediately) - previous_waypoint.waypoint_template_id.plan_leave_id = False - - # Call fly_to - destination_waypoint.fly_to() - - # Previous waypoint should have saved its values - previous_values = previous_waypoint.variable_values or {} - self.assertEqual( - previous_values.get("test_var_1"), - "previous_value1", - "Previous waypoint should save its variable values", - ) - - # Variable values should be restored from destination waypoint - self.assertEqual( - self.jet_test.get_variable_value("test_var_1"), - "destination_value1", - "Variable value should be restored from destination waypoint", - ) - self.assertEqual( - self.jet_test.get_variable_value("test_var_2"), - "destination_value2", - "Variable value should be restored from destination waypoint", - ) - - def test_arriving_error_restores_variable_values(self): - """ - Test that when arriving fails, - variable values are restored from current waypoint - """ - # Create current waypoint with saved variable values - current_waypoint = self.JetWaypoint.create( - { - "name": "Current Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "current", - "variable_values": { - "test_var_1": "current_value1", - "test_var_2": "current_value2", - }, - } - ) - self.jet_test.waypoint_id = current_waypoint.id - - # Create arriving waypoint - arriving_waypoint = self.JetWaypoint.create( - { - "name": "Arriving Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "state": "arriving", - } - ) - - # Set different values on the jet - self.jet_test.set_variable_value("test_var_1", "arriving_value1") - self.jet_test.set_variable_value("test_var_2", "arriving_value2") - - # Create plan log with error status - plan_log = self.PlanLog.create( - { - "server_id": self.jet_test.server_id.id, - "plan_id": self.plan_error.id, - "plan_status": -100, # Error - } - ) - - # Call _plan_finished with error - with mute_logger( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint" - ): - arriving_waypoint._plan_finished(plan_log) - - # Variable values should be restored from current waypoint - self.assertEqual( - self.jet_test.get_variable_value("test_var_1"), - "current_value1", - "Variable value should be restored from current waypoint on error", - ) - self.assertEqual( - self.jet_test.get_variable_value("test_var_2"), - "current_value2", - "Variable value should be restored from current waypoint on error", - ) - - # Current waypoint state should be "current" - self.assertEqual( - current_waypoint.state, - "current", - "Current waypoint state should remain current", - ) - - # Arriving waypoint state should be "error" - self.assertEqual( - arriving_waypoint.state, - "error", - "Arriving waypoint state should be error", - ) - - # ------------------------------------ - # --- _check_is_destination tests ---- - # ------------------------------------ - - def _make_destination_waypoint(self, name, jet=None): - """ - Helper: create a waypoint and atomically transition it to the - ``preparing`` state with ``is_destination=True``. - - This mirrors what ``prepare(is_destination=True)`` does internally - when the waypoint template has a ``plan_create_id`` (it writes - ``state=preparing`` + ``is_destination`` in one call and does not - proceed to ``fly_to()``). Using that path keeps ``is_destination`` - stable for subsequent constraint assertions, whereas calling - ``prepare()`` without a plan triggers ``fly_to()`` → ``_arrive()``, - which clears ``is_destination`` immediately. - - Args: - name (str): Name of the waypoint. - jet (cx.tower.jet, optional): Target jet. Defaults to jet_test. - - Returns: - cx.tower.jet.waypoint: Waypoint in ``preparing`` state with - ``is_destination=True``. - """ - if jet is None: - jet = self.jet_test - waypoint = self.JetWaypoint.create( - { - "name": name, - "jet_id": jet.id, - "waypoint_template_id": self.waypoint_template.id, - } - ) - waypoint.write({"state": "preparing", "is_destination": True}) - return waypoint - - def test_check_is_destination_single_allowed(self): - """ - Preparing one destination waypoint for a jet via prepare() is valid. - """ - waypoint = self._make_destination_waypoint("Destination Waypoint") - self.assertTrue(waypoint.is_destination) - - def test_check_is_destination_different_jets_allowed(self): - """ - Each jet may independently have its own destination waypoint. - """ - self._make_destination_waypoint("Destination Jet Test", jet=self.jet_test) - waypoint_other = self._make_destination_waypoint( - "Destination Jet Odoo", jet=self.jet_odoo - ) - self.assertTrue(waypoint_other.is_destination) - - def test_check_is_destination_false_ignored(self): - """ - Waypoints with is_destination=False are never checked, even when - another destination already exists for the same jet. - """ - self._make_destination_waypoint("Existing Destination") - # Creating a non-destination waypoint must not raise. - non_dest = self.JetWaypoint.create( - { - "name": "Non Destination", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - "is_destination": False, - } - ) - self.assertFalse(non_dest.is_destination) - - def _assert_state_blocks_destination(self, state): - """ - Helper: create a waypoint, force it into ``state``, then assert that - writing ``is_destination=True`` raises a ValidationError. - - Args: - state (str): Waypoint state to test. - """ - waypoint = self.JetWaypoint.create( - { - "name": f"Waypoint in {state}", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - } - ) - waypoint.write({"state": state}) - with self.assertRaises(ValidationError): - waypoint.write({"is_destination": True}) - - def test_check_is_destination_draft_state_raises(self): - """ - Setting is_destination=True directly on a waypoint in the 'draft' state - must raise a ValidationError. - Use prepare(is_destination=True) to designate a destination waypoint. - """ - self._assert_state_blocks_destination("draft") - - def test_check_is_destination_error_state_raises(self): - """ - Setting is_destination=True on a waypoint in the 'error' state - must raise a ValidationError. - """ - self._assert_state_blocks_destination("error") - - def test_check_is_destination_leaving_state_raises(self): - """ - Setting is_destination=True on a waypoint in the 'leaving' state - must raise a ValidationError. - """ - self._assert_state_blocks_destination("leaving") - - def test_check_is_destination_deleting_state_raises(self): - """ - Setting is_destination=True on a waypoint in the 'deleting' state - must raise a ValidationError. - """ - self._assert_state_blocks_destination("deleting") - - def test_check_is_destination_deleted_state_raises(self): - """ - Setting is_destination=True on a waypoint in the 'deleted' state - must raise a ValidationError. - """ - self._assert_state_blocks_destination("deleted") - - def test_check_is_destination_duplicate_on_create_raises(self): - """ - Setting is_destination via prepare() then trying to prepare a second - destination for the same jet must raise a ValidationError. - """ - self._make_destination_waypoint("First Destination") - second = self.JetWaypoint.create( - { - "name": "Second Destination", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - } - ) - with self.assertRaises(ValidationError): - second.write({"state": "ready", "is_destination": True}) - - def test_check_is_destination_duplicate_on_write_raises(self): - """ - Writing is_destination=True on a second ready waypoint for the same jet - must raise a ValidationError. - """ - self._make_destination_waypoint("Existing Destination") - second = self.JetWaypoint.create( - { - "name": "Second Waypoint", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - } - ) - second.write({"state": "ready"}) - with self.assertRaises(ValidationError): - second.write({"is_destination": True}) - - def test_check_is_destination_duplicate_within_same_batch_raises(self): - """ - Writing is_destination=True on two ready waypoints for the same jet - in a single write() call must raise a ValidationError. - - Both records are excluded from the DB search (neither is a destination - yet), so the constraint must also detect duplicates within the batch. - """ - wp1 = self.JetWaypoint.create( - { - "name": "Batch Destination 1", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - } - ) - wp2 = self.JetWaypoint.create( - { - "name": "Batch Destination 2", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - } - ) - (wp1 | wp2).write({"state": "ready"}) - with self.assertRaises(ValidationError): - (wp1 | wp2).write({"is_destination": True}) - - # ------------------------------------ - # --- unlink destination guard tests - - # ------------------------------------ - - @mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint") - def test_unlink_destination_waypoint_raises(self): - """ - Deleting a waypoint with is_destination=True must raise a - ValidationError regardless of state, to prevent the jet from being - stranded mid-flight while a leave plan is still running. - """ - waypoint = self._make_destination_waypoint("Active Destination") - with self.assertRaises(ValidationError): - waypoint.unlink() - - @mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_jet_waypoint") - def test_unlink_destination_waypoint_no_raise_context_logs(self): - """ - When waypoint_no_raise_on_delete=True is set in context, deleting a - destination waypoint must not raise but must log the error and skip - the record. - """ - waypoint = self._make_destination_waypoint("Active Destination No Raise") - waypoint.with_context(waypoint_no_raise_on_delete=True).unlink() - # Record must still exist — it was skipped, not deleted. - self.assertTrue(waypoint.exists()) - - def test_unlink_non_destination_ready_waypoint_allowed(self): - """ - Deleting a ready waypoint that is NOT a destination must still work. - """ - waypoint = self.JetWaypoint.create( - { - "name": "Ready Non-Destination", - "jet_id": self.jet_test.id, - "waypoint_template_id": self.waypoint_template.id, - } - ) - waypoint.write({"state": "ready"}) - waypoint.unlink() - self.assertFalse(waypoint.exists()) diff --git a/addons/cetmix_tower_server/tests/test_jet_waypoint_access.py b/addons/cetmix_tower_server/tests/test_jet_waypoint_access.py deleted file mode 100644 index 6156870..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_waypoint_access.py +++ /dev/null @@ -1,970 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetWaypointAccess(TestTowerJetsCommon): - """ - Test access rules for Jet Waypoint model - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Use existing users from common.py (cls.user, cls.manager, cls.root) - # Create additional manager for multi-manager tests - cls.manager2 = cls.Users.create( - { - "name": "Test Manager 2", - "login": "test_manager_2", - "email": "test_manager_2@example.com", - "groups_id": [(6, 0, [cls.group_manager.id])], - } - ) - - # ====================== - # Manager Read Access Tests - # ====================== - - def test_manager_read_access_jet_user_ids(self): - """Test Manager: Read when user is added in jet's user_ids""" - # Use existing jet and add manager to user_ids - self.jet_test.write({"user_ids": [(4, self.manager.id)]}) - jet = self.jet_test - - record = self.JetWaypoint.create( - { - "name": "Waypoint with User Access", - "reference": "waypoint_user_access", - "jet_id": jet.id, - "waypoint_template_id": self.waypoint_template.id, - } - ) - - records = self.JetWaypoint.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should be able to read when added to jet's user_ids", - ) - - def test_manager_read_access_jet_manager_ids(self): - """Test Manager: Read when user is added in jet's manager_ids""" - # Use existing jet and add manager to manager_ids - self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) - jet = self.jet_test - - record = self.JetWaypoint.create( - { - "name": "Waypoint with Manager Access", - "reference": "waypoint_manager_access", - "jet_id": jet.id, - "waypoint_template_id": self.waypoint_template.id, - } - ) - - records = self.JetWaypoint.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should be able to read when added to jet's manager_ids", - ) - - def test_manager_read_no_access_root_level(self): - """Test Manager: No read access for Root level (3) even with jet access""" - # Use existing jet and add manager to manager_ids (has jet access) - self.jet_test.write({"manager_ids": [(4, self.manager.id)]}) - jet = self.jet_test - - # Create waypoint template with Root level - waypoint_template_root = self.JetWaypointTemplate.create( - { - "name": "Root Level Template", - "reference": "root_level_template", - "jet_template_id": self.jet_template_test.id, - "access_level": "3", # Root level - } - ) - - record = self.JetWaypoint.create( - { - "name": "Root Level Waypoint", - "reference": "root_level_waypoint", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template_root.id, - "access_level": "3", # Explicitly set Root level - } - ) - - records = self.JetWaypoint.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read access_level='3' " - "even when in jet's manager_ids (Root level blocks access)", - ) - - def test_manager_read_no_access_not_in_jet(self): - """Test Manager: No read access when not in jet's Users or Managers""" - # Use existing jet (manager not in user_ids/manager_ids) - jet = self.jet_test - - record = self.JetWaypoint.create( - { - "name": "No Access Waypoint", - "reference": "no_access_waypoint", - "jet_id": jet.id, - "waypoint_template_id": self.waypoint_template.id, - } - ) - - records = self.JetWaypoint.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read when not in jet's user_ids or manager_ids", - ) - - # ====================== - # Manager Write/Create Access Tests - # ====================== - - def test_manager_write_access_level_and_template_manager_ids(self): - """Test Manager: Write when access_level <= 2 AND in template's manager_ids""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Create jet from this template with unique name - jet = self._create_jet( - name="Write Access Jet", - reference="write_access_jet", - template=jet_template, - server=self.server_test_1, - ) - - # Create waypoint template - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template", - "reference": "test_waypoint_template", - "jet_template_id": jet_template.id, - } - ) - - record = self.JetWaypoint.create( - { - "name": "Manager Can Write", - "reference": "manager_can_write", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - } - ) - - # Manager should be able to write - try: - record.with_user(self.manager).write({"name": "Updated Name"}) - record.invalidate_recordset() - self.assertEqual( - record.name, "Updated Name", "Manager should be able to update" - ) - except AccessError: - self.fail("Manager should be able to update when in template's manager_ids") - - def test_manager_write_forbidden_not_in_template_manager_ids(self): - """Test Manager: No write when not in template's manager_ids""" - # Create jet template without manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": False, - } - ) - - # Create jet with manager in manager_ids (for read access) - jet = self._create_jet( - name="No Write Jet", - reference="no_write_jet", - template=jet_template, - server=self.server_test_1, - manager_ids=[(4, self.manager.id)], - ) - - # Create waypoint template - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template", - "reference": "test_waypoint_template", - "jet_template_id": jet_template.id, - } - ) - - record = self.JetWaypoint.create( - { - "name": "No Write Access", - "reference": "no_write_access", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - } - ) - - with self.assertRaises(AccessError): - record.with_user(self.manager).write({"name": "Should Fail"}) - - def test_manager_write_forbidden_root_level(self): - """Test Manager: No write when access_level is Root (3)""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Create jet from this template with unique name - jet = self._create_jet( - name="Write Access Jet", - reference="write_access_jet", - template=jet_template, - server=self.server_test_1, - ) - - # Create waypoint template with Root level - waypoint_template_root = self.JetWaypointTemplate.create( - { - "name": "Root Level Template", - "reference": "root_level_template", - "jet_template_id": jet_template.id, - "access_level": "3", # Root level - } - ) - - record = self.JetWaypoint.create( - { - "name": "Root Level No Write", - "reference": "root_level_no_write", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template_root.id, - "access_level": "3", # Explicitly set Root level - } - ) - - with self.assertRaises(AccessError): - record.with_user(self.manager).write({"name": "Should Fail"}) - - def test_manager_create_access(self): - """Test Manager: Create when access_level <= 2 AND in template's manager_ids""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Create jet from this template with unique name - jet = self._create_jet( - name="Write Access Jet", - reference="write_access_jet", - template=jet_template, - server=self.server_test_1, - ) - - # Create waypoint template - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template", - "reference": "test_waypoint_template", - "jet_template_id": jet_template.id, - } - ) - - # Try to create without being in template's manager_ids - should fail - jet_template_no_access = self.JetTemplate.create( - { - "name": "No Access Template", - "reference": "no_access_template", - "manager_ids": False, - } - ) - - jet_no_access = self._create_jet( - name="No Access Jet", - reference="no_access_jet", - template=jet_template_no_access, - server=self.server_test_1, - manager_ids=[(4, self.manager.id)], # Manager in jet but not template - ) - - waypoint_template_no_access = self.JetWaypointTemplate.create( - { - "name": "No Access Waypoint Template", - "reference": "no_access_waypoint_template", - "jet_template_id": jet_template_no_access.id, - } - ) - - with self.assertRaises(AccessError): - self.JetWaypoint.with_user(self.manager).create( - { - "name": "Create Fail", - "reference": "create_fail", - "jet_id": jet_no_access.id, - "waypoint_template_id": waypoint_template_no_access.id, - } - ) - - # Create with manager in template's manager_ids - should succeed - try: - record = self.JetWaypoint.with_user(self.manager).create( - { - "name": "Create Success", - "reference": "create_success", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - } - ) - records = self.JetWaypoint.search([("id", "=", record.id)]) - self.assertEqual(len(records), 1, "Manager should be able to create") - except AccessError: - self.fail("Manager should be able to create when in template's manager_ids") - - # ====================== - # Manager Delete Access Tests - # ====================== - - def test_manager_delete_own_record(self): - """Test Manager: Delete own record when in template's manager_ids""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Create jet from this template with unique name - jet = self._create_jet( - name="Write Access Jet", - reference="write_access_jet", - template=jet_template, - server=self.server_test_1, - ) - - # Create waypoint template - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template", - "reference": "test_waypoint_template", - "jet_template_id": jet_template.id, - } - ) - - record = self.JetWaypoint.with_user(self.manager).create( - { - "name": "My Record", - "reference": "my_record", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - } - ) - - try: - record.with_user(self.manager).unlink() - records = self.JetWaypoint.search([("id", "=", record.id)]) - self.assertEqual( - len(records), 0, "Manager should be able to delete own record" - ) - except AccessError: - self.fail("Manager should be able to delete own record") - - def test_manager_delete_not_creator(self): - """Test Manager: Cannot delete record created by another user""" - # Create jet template with both managers in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id), (4, self.manager2.id)], - } - ) - - # Create jet from this template with unique name - jet = self._create_jet( - name="Write Access Jet", - reference="write_access_jet", - template=jet_template, - server=self.server_test_1, - ) - - # Create waypoint template - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template", - "reference": "test_waypoint_template", - "jet_template_id": jet_template.id, - } - ) - - record = self.JetWaypoint.with_user(self.manager2).create( - { - "name": "Other's Record", - "reference": "others_record", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - } - ) - - # Manager1 cannot delete Manager2's record - with self.assertRaises(AccessError): - record.with_user(self.manager).unlink() - - def test_manager_delete_not_in_template_manager_ids(self): - """Test Manager: Cannot delete when not in template's manager_ids""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Create jet from this template with unique name - jet = self._create_jet( - name="Delete Not In Template Jet", - reference="delete_not_in_template_jet", - template=jet_template, - server=self.server_test_1, - ) - - # Create waypoint template - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template", - "reference": "test_waypoint_template", - "jet_template_id": jet_template.id, - } - ) - - record = self.JetWaypoint.with_user(self.manager).create( - { - "name": "Removed Manager", - "reference": "removed_manager", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - } - ) - - # Remove from template's manager_ids - jet_template.write({"manager_ids": False}) - - # Cannot delete anymore - with self.assertRaises(AccessError): - record.with_user(self.manager).unlink() - - def test_manager_delete_root_level(self): - """Test Manager: Cannot delete Root level record""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Create jet from this template with unique name - jet = self._create_jet( - name="Write Access Jet", - reference="write_access_jet", - template=jet_template, - server=self.server_test_1, - ) - - # Create waypoint template with Root level - waypoint_template_root = self.JetWaypointTemplate.create( - { - "name": "Root Level Template", - "reference": "root_level_template", - "jet_template_id": jet_template.id, - "access_level": "3", # Root level - } - ) - - # Create record with Root level as root (default user) - record = self.JetWaypoint.create( - { - "name": "Root Level Delete", - "reference": "root_level_delete", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template_root.id, - } - ) - - with self.assertRaises(AccessError): - record.with_user(self.manager).unlink() - - # ====================== - # Root Access Tests - # ====================== - - def test_root_full_access(self): - """ - Test Root: Full CRUD access regardless of access_level or creator. - - Root has unrestricted access to all records via security rule - [(1, '=', 1)], so we test: - - Create records with all access levels - - Read records with all access levels - - Write to records with all access levels - - Delete records regardless of creator - """ - # Create jet template for testing - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - } - ) - - # Create jet from this template with unique name - jet = self._create_jet( - name="Write Access Jet", - reference="write_access_jet", - template=jet_template, - server=self.server_test_1, - ) - - # Test CRUD operations for all access levels (only Manager and Root exist) - for access_level in ["2", "3"]: - # Create waypoint template with specific access level - waypoint_template = self.JetWaypointTemplate.create( - { - "name": f"Template Level {access_level}", - "reference": f"template_level_{access_level}", - "jet_template_id": jet_template.id, - "access_level": access_level, - } - ) - - # Root can create any level - record = self.JetWaypoint.with_user(self.root).create( - { - "name": f"Root Level {access_level}", - "reference": f"root_level_{access_level}", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - } - ) - - # Root can read any level - records = self.JetWaypoint.with_user(self.root).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 1, - f"Root should be able to read access_level={access_level}", - ) - - # Root can write any level - record.with_user(self.root).write( - {"name": f"Root Updated Level {access_level}"} - ) - record.invalidate_recordset() - self.assertEqual( - record.name, - f"Root Updated Level {access_level}", - f"Root should be able to update access_level={access_level}", - ) - - # Test Root can delete records created by other users - # Add manager to template's manager_ids so they can create the record - jet_template.write({"manager_ids": [(4, self.manager.id)]}) - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Manager Template", - "reference": "manager_template", - "jet_template_id": jet_template.id, - } - ) - manager_record = self.JetWaypoint.with_user(self.manager).create( - { - "name": "Manager's Record", - "reference": "managers_record", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - } - ) - manager_record.with_user(self.root).unlink() - records = self.JetWaypoint.with_user(self.root).search( - [("id", "=", manager_record.id)] - ) - self.assertEqual( - len(records), - 0, - "Root should be able to delete records from any creator", - ) - - # ====================== - # Edge Cases - # ====================== - - def test_access_level_changes_visibility(self): - """Test that changing access_level affects visibility""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Create jet with manager in manager_ids with unique name - jet = self._create_jet( - name="Access Level Changes Jet", - reference="access_level_changes_jet", - template=jet_template, - server=self.server_test_1, - manager_ids=[(4, self.manager.id)], - ) - - # Create waypoint template with Manager level - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template", - "reference": "test_waypoint_template", - "jet_template_id": jet_template.id, - "access_level": "2", - } - ) - - record = self.JetWaypoint.create( - { - "name": "Changing Level", - "reference": "changing_level", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - } - ) - - # Manager can read - records = self.JetWaypoint.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual(len(records), 1, "Manager should read level 2") - - # Change template to Root level - waypoint_template.write({"access_level": "3"}) - # Update waypoint's access_level since it's stored and doesn't auto-update - record.write({"access_level": "3"}) - record.invalidate_recordset() - - # Manager cannot read anymore - records = self.JetWaypoint.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual(len(records), 0, "Manager should not read level 3") - - def test_manager_prepare_forbidden_no_write_access(self): - """Test Manager: Cannot prepare waypoint without write access""" - # Create jet template without manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": False, - } - ) - - # Create jet with manager in manager_ids (for read access) - jet = self._create_jet( - name="Prepare Forbidden Jet", - reference="prepare_forbidden_jet", - template=jet_template, - server=self.server_test_1, - manager_ids=[(4, self.manager.id)], - ) - - # Create waypoint template - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template", - "reference": "test_waypoint_template", - "jet_template_id": jet_template.id, - } - ) - - record = self.JetWaypoint.create( - { - "name": "Prepare Forbidden", - "reference": "prepare_forbidden", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - "state": "draft", - } - ) - - # Manager should not be able to prepare without write access - with self.assertRaises(AccessError): - record.with_user(self.manager).prepare() - - def test_manager_prepare_forbidden_root_level(self): - """Test Manager: Cannot prepare waypoint with Root level""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Create jet from this template - jet = self._create_jet( - name="Prepare Root Level Jet", - reference="prepare_root_level_jet", - template=jet_template, - server=self.server_test_1, - ) - - # Create waypoint template with Root level - waypoint_template_root = self.JetWaypointTemplate.create( - { - "name": "Root Level Template", - "reference": "root_level_template", - "jet_template_id": jet_template.id, - "access_level": "3", # Root level - } - ) - - record = self.JetWaypoint.create( - { - "name": "Root Level Prepare", - "reference": "root_level_prepare", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template_root.id, - "access_level": "3", # Explicitly set Root level - "state": "draft", - } - ) - - # Manager should not be able to prepare Root level waypoint - with self.assertRaises(AccessError): - record.with_user(self.manager).prepare() - - def test_manager_fly_to_forbidden_no_write_access(self): - """Test Manager: Cannot fly_to waypoint without write access""" - # Create jet template without manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": False, - } - ) - - # Create jet with manager in manager_ids (for read access) - jet = self._create_jet( - name="Fly To Forbidden Jet", - reference="fly_to_forbidden_jet", - template=jet_template, - server=self.server_test_1, - manager_ids=[(4, self.manager.id)], - ) - - # Create waypoint template - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template", - "reference": "test_waypoint_template", - "jet_template_id": jet_template.id, - } - ) - - record = self.JetWaypoint.create( - { - "name": "Fly To Forbidden", - "reference": "fly_to_forbidden", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - "state": "ready", - } - ) - - # Manager should not be able to fly_to without write access - with self.assertRaises(AccessError): - record.with_user(self.manager).fly_to() - - def test_manager_fly_to_forbidden_root_level(self): - """Test Manager: Cannot fly_to waypoint with Root level""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Create jet from this template - jet = self._create_jet( - name="Fly To Root Level Jet", - reference="fly_to_root_level_jet", - template=jet_template, - server=self.server_test_1, - ) - - # Create waypoint template with Root level - waypoint_template_root = self.JetWaypointTemplate.create( - { - "name": "Root Level Template", - "reference": "root_level_template", - "jet_template_id": jet_template.id, - "access_level": "3", # Root level - } - ) - - record = self.JetWaypoint.create( - { - "name": "Root Level Fly To", - "reference": "root_level_fly_to", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template_root.id, - "access_level": "3", # Explicitly set Root level - "state": "ready", - } - ) - - # Manager should not be able to fly_to Root level waypoint - with self.assertRaises(AccessError): - record.with_user(self.manager).fly_to() - - def test_manager_prepare_success_with_write_access(self): - """Test Manager: Can prepare waypoint with write access""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Ensure manager has server access - self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) - - # Create jet from this template with manager in manager_ids - jet = self._create_jet( - name="Prepare Success Jet", - reference="prepare_success_jet", - template=jet_template, - server=self.server_test_1, - manager_ids=[(4, self.manager.id)], - ) - - # Create waypoint template - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template", - "reference": "test_waypoint_template", - "jet_template_id": jet_template.id, - } - ) - - record = self.JetWaypoint.create( - { - "name": "Prepare Success", - "reference": "prepare_success", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - "state": "draft", - } - ) - - # Manager should be able to prepare with write access - try: - result = record.with_user(self.manager).prepare() - self.assertTrue(result, "Manager should be able to prepare") - record.invalidate_recordset() - # State should be ready (no plan_create_id) - self.assertEqual(record.state, "ready", "State should be ready") - except AccessError: - self.fail( - "Manager should be able to prepare when in template's manager_ids" - ) - - def test_manager_fly_to_success_with_write_access(self): - """Test Manager: Can fly_to waypoint with write access""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Ensure manager has server access - self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) - - # Create jet from this template with manager in manager_ids - jet = self._create_jet( - name="Fly To Success Jet", - reference="fly_to_success_jet", - template=jet_template, - server=self.server_test_1, - manager_ids=[(4, self.manager.id)], - ) - - # Create waypoint template - waypoint_template = self.JetWaypointTemplate.create( - { - "name": "Test Waypoint Template", - "reference": "test_waypoint_template", - "jet_template_id": jet_template.id, - } - ) - - record = self.JetWaypoint.create( - { - "name": "Fly To Success", - "reference": "fly_to_success", - "jet_id": jet.id, - "waypoint_template_id": waypoint_template.id, - "state": "ready", - } - ) - - # Manager should be able to fly_to with write access - try: - result = record.with_user(self.manager).fly_to() - self.assertTrue(result, "Manager should be able to fly_to") - record.invalidate_recordset() - # State should be current (no previous waypoint, no plan_arrive_id) - self.assertEqual(record.state, "current", "State should be current") - except AccessError: - self.fail("Manager should be able to fly_to when in template's manager_ids") diff --git a/addons/cetmix_tower_server/tests/test_jet_waypoint_template_access.py b/addons/cetmix_tower_server/tests/test_jet_waypoint_template_access.py deleted file mode 100644 index 0102a5c..0000000 --- a/addons/cetmix_tower_server/tests/test_jet_waypoint_template_access.py +++ /dev/null @@ -1,504 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerJetWaypointTemplateAccess(TestTowerJetsCommon): - """ - Test access rules for Jet Waypoint Template model - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Use existing users from common.py (cls.user, cls.manager, cls.root) - # Create additional manager for multi-manager tests - cls.manager2 = cls.Users.create( - { - "name": "Test Manager 2", - "login": "test_manager_2", - "email": "test_manager_2@example.com", - "groups_id": [(6, 0, [cls.group_manager.id])], - } - ) - - # ====================== - # Manager Read Access Tests - # ====================== - - def test_manager_read_access_user_ids(self): - """Test Manager: Read when user is added in template's user_ids""" - # Create jet template with manager in user_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "user_ids": [(4, self.manager.id)], - } - ) - - record = self.JetWaypointTemplate.create( - { - "name": "Waypoint with User Access", - "reference": "waypoint_user_access", - "jet_template_id": jet_template.id, - "access_level": "2", # Manager level - } - ) - - records = self.JetWaypointTemplate.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should be able to read when added to template's user_ids", - ) - - def test_manager_read_access_manager_ids(self): - """Test Manager: Read when user is added in template's manager_ids""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - record = self.JetWaypointTemplate.create( - { - "name": "Waypoint with Manager Access", - "reference": "waypoint_manager_access", - "jet_template_id": jet_template.id, - "access_level": "2", # Manager level - } - ) - - records = self.JetWaypointTemplate.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should be able to read when added to template's manager_ids", - ) - - def test_manager_read_no_access_root_level(self): - """ - Test Manager: No read access for Root level (3) - without user_ids/manager_ids - """ - # Create jet template without manager access - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "user_ids": False, - "manager_ids": False, - } - ) - - record = self.JetWaypointTemplate.create( - { - "name": "Root Level Waypoint", - "reference": "root_level_waypoint", - "jet_template_id": jet_template.id, - "access_level": "3", # Root level - } - ) - - records = self.JetWaypointTemplate.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read access_level='3' " - "when not in template's user_ids or manager_ids", - ) - - def test_manager_read_no_access_not_in_template(self): - """Test Manager: No read access when not in template's Users or Managers""" - # Create jet template without manager access - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "user_ids": False, - "manager_ids": False, - } - ) - - record = self.JetWaypointTemplate.create( - { - "name": "No Access Waypoint", - "reference": "no_access_waypoint", - "jet_template_id": jet_template.id, - "access_level": "2", # Manager level - } - ) - - records = self.JetWaypointTemplate.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not read when not in template's user_ids or manager_ids", - ) - - # ====================== - # Manager Write/Create Access Tests - # ====================== - - def test_manager_write_access_level_and_manager_ids(self): - """Test Manager: Write when access_level <= 2 AND in template's manager_ids""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - record = self.JetWaypointTemplate.create( - { - "name": "Manager Can Write", - "reference": "manager_can_write", - "jet_template_id": jet_template.id, - "access_level": "2", - } - ) - - # Manager should be able to write - try: - record.with_user(self.manager).write({"name": "Updated Name"}) - record.invalidate_recordset() - self.assertEqual( - record.name, "Updated Name", "Manager should be able to update" - ) - except AccessError: - self.fail("Manager should be able to update when in template's manager_ids") - - def test_manager_write_forbidden_not_in_manager_ids(self): - """Test Manager: No write when not in template's manager_ids""" - # Create jet template with manager only in user_ids, not manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "user_ids": [(4, self.manager.id)], # Only in user_ids - "manager_ids": False, - } - ) - - record = self.JetWaypointTemplate.create( - { - "name": "No Write Access", - "reference": "no_write_access", - "jet_template_id": jet_template.id, - "access_level": "2", - } - ) - - with self.assertRaises(AccessError): - record.with_user(self.manager).write({"name": "Should Fail"}) - - def test_manager_write_forbidden_root_level(self): - """Test Manager: No write when access_level is Root (3)""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - record = self.JetWaypointTemplate.create( - { - "name": "Root Level No Write", - "reference": "root_level_no_write", - "jet_template_id": jet_template.id, - "access_level": "3", # Root level - } - ) - - with self.assertRaises(AccessError): - record.with_user(self.manager).write({"name": "Should Fail"}) - - def test_manager_create_access(self): - """Test Manager: Create when access_level <= 2 AND in template's manager_ids""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Try to create without being in manager_ids - should fail - jet_template_no_access = self.JetTemplate.create( - { - "name": "No Access Template", - "reference": "no_access_template", - "manager_ids": False, - } - ) - - with self.assertRaises(AccessError): - self.JetWaypointTemplate.with_user(self.manager).create( - { - "name": "Create Fail", - "reference": "create_fail", - "jet_template_id": jet_template_no_access.id, - "access_level": "2", - } - ) - - # Create with manager in template's manager_ids - should succeed - try: - record = self.JetWaypointTemplate.with_user(self.manager).create( - { - "name": "Create Success", - "reference": "create_success", - "jet_template_id": jet_template.id, - "access_level": "2", - } - ) - records = self.JetWaypointTemplate.search([("id", "=", record.id)]) - self.assertEqual(len(records), 1, "Manager should be able to create") - except AccessError: - self.fail("Manager should be able to create when in template's manager_ids") - - # ====================== - # Manager Delete Access Tests - # ====================== - - def test_manager_delete_own_record(self): - """Test Manager: Delete own record when in template's manager_ids""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - record = self.JetWaypointTemplate.with_user(self.manager).create( - { - "name": "My Record", - "reference": "my_record", - "jet_template_id": jet_template.id, - "access_level": "2", - } - ) - - try: - record.with_user(self.manager).unlink() - records = self.JetWaypointTemplate.search([("id", "=", record.id)]) - self.assertEqual( - len(records), 0, "Manager should be able to delete own record" - ) - except AccessError: - self.fail("Manager should be able to delete own record") - - def test_manager_delete_not_creator(self): - """Test Manager: Cannot delete record created by another user""" - # Create jet template with both managers in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id), (4, self.manager2.id)], - } - ) - - record = self.JetWaypointTemplate.with_user(self.manager2).create( - { - "name": "Other's Record", - "reference": "others_record", - "jet_template_id": jet_template.id, - "access_level": "2", - } - ) - - # Manager1 cannot delete Manager2's record - with self.assertRaises(AccessError): - record.with_user(self.manager).unlink() - - def test_manager_delete_not_in_manager_ids(self): - """Test Manager: Cannot delete when not in template's manager_ids""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - record = self.JetWaypointTemplate.with_user(self.manager).create( - { - "name": "Removed Manager", - "reference": "removed_manager", - "jet_template_id": jet_template.id, - "access_level": "2", - } - ) - - # Remove from manager_ids - jet_template.write({"manager_ids": False}) - - # Cannot delete anymore - with self.assertRaises(AccessError): - record.with_user(self.manager).unlink() - - def test_manager_delete_root_level(self): - """Test Manager: Cannot delete Root level record""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Create record with Root level as root (default user) - record = self.JetWaypointTemplate.create( - { - "name": "Root Level Delete", - "reference": "root_level_delete", - "jet_template_id": jet_template.id, - "access_level": "3", # Root level - } - ) - - with self.assertRaises(AccessError): - record.with_user(self.manager).unlink() - - # ====================== - # Root Access Tests - # ====================== - - def test_root_full_access(self): - """ - Test Root: Full CRUD access regardless of access_level or creator. - - Root has unrestricted access to all records via security rule - [(1, '=', 1)], so we test: - - Create records with all access levels - - Read records with all access levels - - Write to records with all access levels - - Delete records regardless of creator - """ - # Create jet template for testing - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - } - ) - - # Test CRUD operations for all access levels (only Manager and Root exist) - for access_level in ["2", "3"]: - # Root can create any level - record = self.JetWaypointTemplate.with_user(self.root).create( - { - "name": f"Root Level {access_level}", - "reference": f"root_level_{access_level}", - "jet_template_id": jet_template.id, - "access_level": access_level, - } - ) - - # Root can read any level - records = self.JetWaypointTemplate.with_user(self.root).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 1, - f"Root should be able to read access_level={access_level}", - ) - - # Root can write any level - record.with_user(self.root).write( - {"name": f"Root Updated Level {access_level}"} - ) - record.invalidate_recordset() - self.assertEqual( - record.name, - f"Root Updated Level {access_level}", - f"Root should be able to update access_level={access_level}", - ) - - # Test Root can delete records created by other users - # Add manager to template's manager_ids so they can create the record - jet_template.write({"manager_ids": [(4, self.manager.id)]}) - manager_record = self.JetWaypointTemplate.with_user(self.manager).create( - { - "name": "Manager's Record", - "reference": "managers_record", - "jet_template_id": jet_template.id, - "access_level": "2", - } - ) - manager_record.with_user(self.root).unlink() - records = self.JetWaypointTemplate.with_user(self.root).search( - [("id", "=", manager_record.id)] - ) - self.assertEqual( - len(records), - 0, - "Root should be able to delete records from any creator", - ) - - # ====================== - # Edge Cases - # ====================== - - def test_access_level_changes_visibility(self): - """Test that changing access_level affects visibility""" - # Create jet template with manager in manager_ids - jet_template = self.JetTemplate.create( - { - "name": "Test Template", - "reference": "test_template", - "manager_ids": [(4, self.manager.id)], - } - ) - - # Create with Manager level - record = self.JetWaypointTemplate.create( - { - "name": "Changing Level", - "reference": "changing_level", - "jet_template_id": jet_template.id, - "access_level": "2", - } - ) - - # Manager can read - records = self.JetWaypointTemplate.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual(len(records), 1, "Manager should read level 2") - - # Change to Root level - record.write({"access_level": "3"}) - - # Manager cannot read anymore - records = self.JetWaypointTemplate.with_user(self.manager).search( - [("id", "=", record.id)] - ) - self.assertEqual(len(records), 0, "Manager should not read level 3") diff --git a/addons/cetmix_tower_server/tests/test_key.py b/addons/cetmix_tower_server/tests/test_key.py deleted file mode 100644 index c633417..0000000 --- a/addons/cetmix_tower_server/tests/test_key.py +++ /dev/null @@ -1,919 +0,0 @@ -from odoo.exceptions import AccessError, ValidationError - -from .common import TestTowerCommon - - -class TestTowerKey(TestTowerCommon): - """Test class for tower key.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create another manager for testing - cls.manager_2 = cls.Users.create( - { - "name": "Second Manager", - "login": "manager2", - "email": "manager2@test.com", - "groups_id": [(4, cls.env.ref("cetmix_tower_server.group_manager").id)], - } - ) - - # Create test servers - cls.server_1 = cls.Server.create( - { - "name": "Test Server 1", - "ip_v4_address": "192.168.1.1", - "ssh_port": 22, - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - cls.server_2 = cls.Server.create( - { - "name": "Test Server 2", - "ip_v4_address": "192.168.1.2", - "ssh_port": 22, - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - } - ) - cls.test_key = cls.Key.create( - {"name": "Test Key", "key_type": "s", "secret_value": "test value"} - ) - - def test_key_creation(self): - """ - Test key creation. - We override create method so need to check if reference is generated properly - """ - - # -- 1-- - # Check new key values - key_one = self.Key.create( - {"name": " test key meme ", "secret_value": "test value", "key_type": "s"} - ) - self.assertEqual( - key_one.reference, "test_key_meme", "Reference must be 'test_key_meme'" - ) - self.assertEqual( - key_one.name, - "test key meme", - "Trailing and leading whitespaces must be removed from name", - ) - - def test_extract_key_strings(self): - """Check if key strings are extracted properly""" - code = ( - "Hey #!cxtower.secret.MEME_KEY!# & Doge #!cxtower.secret.DOGE_KEY !# so " - "like #!cxtower.secret.MEME_KEY!#!\n" - "They make #!memes together." - "And this is another string for the same #!cxtower.secret.MEME_KEY !#" - ) - key_strings = self.Key._extract_key_strings(code) - self.assertEqual(len(key_strings), 3, "Must be 3 key stings") - self.assertIn( - "#!cxtower.secret.MEME_KEY!#", - key_strings, - "Key string must be in key strings", - ) - self.assertIn( - "#!cxtower.secret.DOGE_KEY !#", - key_strings, - "Key string must be in key strings", - ) - self.assertIn( - "#!cxtower.secret.MEME_KEY !#", - key_strings, - "Key string must be in key strings", - ) - - def test_parse_key_string(self): - """Check if key string is parsed correctly""" - - # Test global key - doge_key = self.Key.create( - { - "name": "doge key", - "reference": "DOGE_KEY", - "secret_value": "Doge dog", - "key_type": "s", - } - ) - key_string = "#!cxtower.secret.DOGE_KEY!#" - key_value = self.Key._parse_key_string(key_string) - self.assertEqual(key_value, "Doge dog", "Key value doesn't match") - - # Test the same key string but with some spaces before the key terminator - key_string = "#!cxtower.secret.DOGE_KEY !#" - key_value = self.Key._parse_key_string(key_string) - self.assertEqual(key_value, "Doge dog", "Key value doesn't match") - - # Test partner specific key - self.KeyValue.create( - { - "key_id": doge_key.id, - "secret_value": "Doge partner", - "partner_id": self.user_bob.partner_id.id, - } - ) - # compose kwargs - kwargs = { - "partner_id": self.user_bob.partner_id.id, - "server_id": self.server_test_1.id, - } - key_value = self.Key._parse_key_string(key_string, **kwargs) - self.assertEqual(key_value, "Doge partner", "Key value doesn't match") - - # Test server specific key - self.KeyValue.create( - { - "key_id": doge_key.id, - "secret_value": "Doge server", - "server_id": self.server_test_1.id, - } - ) - key_value = self.Key._parse_key_string(key_string, **kwargs) - - # Test server and partner specific key - self.KeyValue.create( - { - "key_id": doge_key.id, - "secret_value": "Doge server and partner", - "server_id": self.server_test_1.id, - "partner_id": self.user_bob.partner_id.id, - } - ) - key_value = self.Key._parse_key_string(key_string, **kwargs) - self.assertEqual( - key_value, "Doge server and partner", "Key value doesn't match" - ) - - # Test missing key - key_string = "#!cxtower.secret.ANOTHER_KEY!#" - key_value = self.Key._parse_key_string(key_string) - self.assertIsNone(key_value, "Key value must be 'None'") - - # Test missformatted key - key_string = "#!cxtower.ANOTHER_KEY!#" - key_value = self.Key._parse_key_string(key_string) - self.assertIsNone(key_value, "Key value must be 'None'") - - # Test another missformatted key - key_string = "#!cxtower.notasecret.DOGE_KEY!#" - key_value = self.Key._parse_key_string(key_string) - self.assertIsNone(key_value, "Key value must be 'None'") - - def test_resolve_key(self): - """Check generic key resolver""" - self.Key.create( - { - "name": "doge key", - "reference": "DOGE_KEY", - "secret_value": "Doge dog", - "key_type": "s", - } - ) - - # Existing key - key_value = self.Key._resolve_key("secret", "DOGE_KEY") - self.assertEqual(key_value, "Doge dog", "Key value doesn't match") - - # Non existing key - key_value = self.Key._resolve_key("server", "PEPE_KEY") - self.assertIsNone(key_value, "Key value must be 'None'") - - def test_resolve_key_type_secret(self): - """Check 'secret' type key resolver""" - doge_key = self.Key.create( - { - "name": "doge key", - "reference": "DOGE_KEY", - "key_type": "s", - } - ) - - # 1. Test server and partner specific key - server_partner_value = self.KeyValue.create( - { - "key_id": doge_key.id, - "secret_value": "Doge server and partner", - "server_id": self.server_test_1.id, - "partner_id": self.user_bob.partner_id.id, - } - ) - kwargs = { - "partner_id": self.user_bob.partner_id.id, - "server_id": self.server_test_1.id, - } - key_value = self.Key._resolve_key_type_secret("DOGE_KEY", **kwargs) - self.assertEqual( - key_value, "Doge server and partner", "Key value doesn't match" - ) - - # 2. Global key - doge_key.write({"secret_value": "Doge dog"}) - key_value = self.Key._resolve_key_type_secret("DOGE_KEY") - self.assertEqual(key_value, "Doge dog", "Key value doesn't match") - - # 3. Non existing key - key_value = self.Key._resolve_key_type_secret("PEPE_KEY") - self.assertIsNone(key_value, "Key value must be 'None'") - - # 4. Partner specific key - self.KeyValue.create( - { - "key_id": doge_key.id, - "secret_value": "Doge partner", - "partner_id": self.user_bob.partner_id.id, - } - ) - kwargs = { - "partner_id": self.user_bob.partner_id.id, - } - key_value = self.Key._resolve_key_type_secret("DOGE_KEY", **kwargs) - self.assertEqual(key_value, "Doge partner", "Key value doesn't match") - - # 5. Test server specific key - self.KeyValue.create( - { - "key_id": doge_key.id, - "secret_value": "Doge server", - "server_id": self.server_test_1.id, - } - ) - kwargs = { - "server_id": self.server_test_1.id, - } - key_value = self.Key._resolve_key_type_secret("DOGE_KEY", **kwargs) - self.assertEqual(key_value, "Doge server", "Key value doesn't match") - - # 6. Test with non matching partner. Should return server specific value - kwargs = { - "partner_id": self.user.partner_id.id, - "server_id": self.server_test_1.id, - } - key_value = self.Key._resolve_key_type_secret("DOGE_KEY", **kwargs) - self.assertEqual(key_value, "Doge server", "Key value doesn't match") - - # 7. Change partner in the server-partner specific value. - # Should return server specific value - server_partner_value.write({"partner_id": self.manager.partner_id.id}) - kwargs = { - "server_id": self.server_test_1.id, - } - key_value = self.Key._resolve_key_type_secret("DOGE_KEY", **kwargs) - self.assertEqual(key_value, "Doge server", "Key value doesn't match") - - # 8. Test with the global key again - key_value = self.Key._resolve_key_type_secret("DOGE_KEY") - self.assertEqual(key_value, "Doge dog", "Key value doesn't match") - - def test_parse_code(self): - """Test code parsing""" - - def check_parsed_code( - code, code_parsed_expected, expected_key_values=None, **kwargs - ): - """Helper function for code parse testing - - Args: - code (Text): code to parse - code_parsed_expected (Text): expected parsed code - expected_key_values (list, optional): key values that are expected - to be returned. Defaults to None. - """ - code_parsed = self.Key._parse_code(code, **kwargs) - self.assertEqual( - code_parsed, - code_parsed_expected, - msg="Parsed code doesn't match expected one", - ) - if expected_key_values: - result = self.Key._parse_code_and_return_key_values(code, **kwargs) - code_parsed = result["code"] - key_values = result["key_values"] - self.assertEqual( - code_parsed, - code_parsed_expected, - msg="Parsed code doesn't match expected one", - ) - self.assertEqual( - len(key_values), - len(expected_key_values), - "Number of key values doesn't match number of expected ones", - ) - for expected_value in expected_key_values: - self.assertIn( - expected_value, - key_values, - f"Value {expected_value} must be in the returned key values", - ) - - # Create new key - self.Key.create( - { - "name": "Meme key", - "reference": "MEME_KEY", - "secret_value": "Pepe Frog", - "key_type": "s", - } - ) - - # Check key parser - - # 1 - single line - - code = "The key to understand this meme is #!cxtower.secret.MEME_KEY!#" - code_parsed_expected = "The key to understand this meme is Pepe Frog" - expected_key_values = ["Pepe Frog"] - check_parsed_code(code, code_parsed_expected, expected_key_values) - - # 2 - multi line - code = "Welcome #!cxtower.secret.MEME_KEY!#\nNew hero of this city!" - code_parsed_expected = "Welcome Pepe Frog\nNew hero of this city!" - expected_key_values = ["Pepe Frog"] - check_parsed_code(code, code_parsed_expected, expected_key_values) - - # 3 - Key not found - code = "Don't mess with #!cxtower.secret.DOGE_LIKE!# He will make you cry" - code_parsed_expected = "Don't mess with None He will make you cry" - expected_key_values = [] - check_parsed_code(code, code_parsed_expected, expected_key_values) - - check_parsed_code(code, code_parsed_expected) - - # 4 - Multi keys - # Create new key - doge_key = self.Key.create( - { - "name": "doge key", - "reference": "DOGE_KEY", - "secret_value": "Doge dog", - "key_type": "s", - } - ) - code = ( - "Hey #!cxtower.secret.MEME_KEY!# & Doge #!cxtower.secret.DOGE_KEY !# so " - "like #!cxtower.secret.MEME_KEY!#!\n" - "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" - "cxtower.secret.DOGE_KEY" - ) - code_parsed_expected = ( - "Hey Pepe Frog & Doge Doge dog so " - "like Pepe Frog!\n" - "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" - "cxtower.secret.DOGE_KEY" - ) - expected_key_values = ["Pepe Frog", "Doge dog"] - check_parsed_code(code, code_parsed_expected, expected_key_values) - - # 5 - Partner specific key - # Create new key for partner Bob - self.KeyValue.create( - { - "key_id": doge_key.id, - "secret_value": "Doge wow", - "partner_id": self.user_bob.partner_id.id, - } - ) - # compose kwargs - kwargs = {"partner_id": self.user_bob.partner_id.id} - code_parsed_expected = ( - "Hey Pepe Frog & Doge Doge wow so " - "like Pepe Frog!\n" - "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" - "cxtower.secret.DOGE_KEY" - ) - expected_key_values = ["Pepe Frog", "Doge wow"] - check_parsed_code(code, code_parsed_expected, expected_key_values, **kwargs) - - # 6 - Server specific key - # Create new key for server Test 1 - self.KeyValue.create( - { - "key_id": doge_key.id, - "secret_value": "Doge much", - "server_id": self.server_test_1.id, - } - ) - # compose kwargs - kwargs = { - "partner_id": self.user_bob.partner_id.id, # not needed but may keep it - "server_id": self.server_test_1.id, - } - code_parsed_expected = ( - "Hey Pepe Frog & Doge Doge much so " - "like Pepe Frog!\n" - "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" - "cxtower.secret.DOGE_KEY" - ) - expected_key_values = ["Pepe Frog", "Doge much"] - check_parsed_code(code, code_parsed_expected, expected_key_values, **kwargs) - - def test_replace_with_spoiler(self): - """Check if secrets are replaced with spoiler correctly""" - - code = ( - "Hey Pepe Frog & Doge Doge much so " - "like Pepe Frog!\n" - "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" - "cxtower.secret.DOGE_KEY" - ) - placeholder = self.Key.SECRET_VALUE_PLACEHOLDER - expected_code = ( - f"Hey {placeholder} & Doge {placeholder} so " - f"like {placeholder}!\n" - "They make #!memes together. Check #!cxtower.secret.MEME_KEY&#!" - "cxtower.secret.DOGE_KEY" - ) - key_values = ["Pepe Frog", "Doge much"] - - result = self.Key._replace_with_spoiler(code, key_values) - self.assertEqual(result, expected_code, "Result doesn't match expected code") - - # -------------------------------------- - # Check with some random key values now - # Original code should rename unchanged - # -------------------------------------- - - key_values = ["Wow much", "No like"] - result = self.Key._replace_with_spoiler(code, key_values) - self.assertEqual(result, code, "Result doesn't match expected code") - - def test_user_access(self): - """Test that regular users have no access to keys""" - user_key = self.Key.with_user(self.user) - - # Create test key - key = self.Key.create( - {"name": "Test Key", "secret_value": "test value", "key_type": "s"} - ) - - # Test CRUD operations - with self.assertRaises(AccessError): - user_key.create( - {"name": "New Key", "secret_value": "secret", "key_type": "s"} - ) - with self.assertRaises(AccessError): - user_key.browse(key.id).read(["name"]) - with self.assertRaises(AccessError): - user_key.browse(key.id).write({"name": "Updated Name"}) - with self.assertRaises(AccessError): - user_key.browse(key.id).unlink() - - def test_manager_read_access(self): - """Test manager read access rules""" - manager_key = self.Key.with_user(self.manager) - - # Create test keys - key_secret = self.Key.create( - {"name": "Secret Key", "secret_value": "secret value", "key_type": "s"} - ) - key_ssh = self.Key.create( - {"name": "SSH Key", "secret_value": "ssh key", "key_type": "k"} - ) - - # Test read access for secret key - should read (all managers can read secrets) - self.assertTrue(manager_key.search([("id", "=", key_secret.id)])) - - # Test read access for SSH key without server access - should not find - self.assertFalse(manager_key.search([("id", "=", key_ssh.id)])) - - # Add manager to server users and set SSH key - should find SSH key - self.write_and_invalidate( - self.server_1, - **{"user_ids": [(4, self.manager.id)], "ssh_key_id": key_ssh.id}, - ) - self.assertTrue(manager_key.search([("id", "=", key_ssh.id)])) - - # Remove key from server - should not find again - self.server_1.write({"ssh_key_id": False}) - self.assertFalse(manager_key.search([("id", "=", key_ssh.id)])) - - # Add as key user - should find both - key_secret.write({"user_ids": [(4, self.manager.id)]}) - key_ssh.write({"user_ids": [(4, self.manager.id)]}) - self.assertTrue(manager_key.search([("id", "=", key_secret.id)])) - self.assertTrue(manager_key.search([("id", "=", key_ssh.id)])) - - def test_manager_write_access(self): - """Test manager write/create access rules""" - manager_key = self.Key.with_user(self.manager) - - # Create test keys as root and ensure manager is not in manager_ids - key_secret = self.Key.create( - { - "name": "Secret Key", - "secret_value": "secret value", - "key_type": "s", - "manager_ids": [(5, 0)], # Clear manager_ids - } - ) - key_ssh = self.Key.create( - { - "name": "SSH Key", - "secret_value": "ssh key", - "key_type": "k", - "manager_ids": [(5, 0)], # Clear manager_ids - } - ) - - # Try write without being manager - should fail - with self.assertRaises(AccessError): - manager_key.browse(key_secret.id).write({"name": "Updated Secret"}) - with self.assertRaises(AccessError): - manager_key.browse(key_ssh.id).write({"name": "Updated SSH"}) - - # Add as key manager - should write to secret - key_secret.write({"manager_ids": [(4, self.manager.id)]}) - manager_key.browse(key_secret.id).write({"name": "Updated Secret"}) - self.assertEqual(key_secret.name, "Updated Secret") - - # Add as server manager and set SSH key - should write to SSH key - self.server_1.write( - {"manager_ids": [(4, self.manager.id)], "ssh_key_id": key_ssh.id} - ) - manager_key.browse(key_ssh.id).write({"name": "Updated SSH"}) - self.assertEqual(key_ssh.name, "Updated SSH") - - def test_manager_create_access(self): - """Test manager create access rules""" - manager_key = self.Key.with_user(self.manager) - manager_2_key = self.Key.with_user(self.manager_2) - - # Try create secret key when not a manager - should fail - with self.assertRaises(AccessError): - manager_2_key.create( - { - "name": "New Secret", - "secret_value": "secret", - "key_type": "s", - "manager_ids": [(5, 0)], # Prevent automatic manager addition - } - ) - - # Try create SSH key when not a server manager - should fail - with self.assertRaises(AccessError): - manager_2_key.create( - { - "name": "New SSH", - "secret_value": "ssh key", - "key_type": "k", - "manager_ids": [(5, 0)], # Prevent automatic manager addition - } - ) - - # Add as server manager - should create SSH key - self.server_1.write({"manager_ids": [(4, self.manager.id)]}) - new_ssh_key = manager_key.create( - {"name": "New SSH", "secret_value": "ssh key", "key_type": "k"} - ) - # Link key to server - self.server_1.write({"ssh_key_id": new_ssh_key.id}) - self.assertTrue(new_ssh_key.exists()) - - def test_manager_unlink_access(self): - """Test manager unlink access rules""" - manager_key = self.Key.with_user(self.manager) - - # Create keys as root - key_secret = self.Key.create( - {"name": "Secret Key", "secret_value": "secret value", "key_type": "s"} - ) - key_ssh = self.Key.create( - {"name": "SSH Key", "secret_value": "ssh key", "key_type": "k"} - ) - # Link SSH key to server - self.server_1.write({"ssh_key_id": key_ssh.id}) - - # Try delete without being manager and creator - should fail - with self.assertRaises(AccessError): - manager_key.browse(key_secret.id).unlink() - with self.assertRaises(AccessError): - manager_key.browse(key_ssh.id).unlink() - - # Add as manager but not creator - should still fail - key_secret.write({"manager_ids": [(4, self.manager.id)]}) - self.server_1.write({"manager_ids": [(4, self.manager.id)]}) - with self.assertRaises(AccessError): - manager_key.browse(key_secret.id).unlink() - with self.assertRaises(AccessError): - manager_key.browse(key_ssh.id).unlink() - - # Create own keys - should delete - own_secret = manager_key.create( - { - "name": "Own Secret", - "secret_value": "secret", - "key_type": "s", - "manager_ids": [(4, self.manager.id)], - } - ) - own_ssh = manager_key.create( - {"name": "Own SSH", "secret_value": "ssh key", "key_type": "k"} - ) - # Link own SSH key to server - self.server_1.write({"ssh_key_id": own_ssh.id}) - - own_secret.unlink() - own_ssh.unlink() - self.assertFalse(own_secret.exists()) - self.assertFalse(own_ssh.exists()) - - def test_root_access(self): - """Test root access rules""" - root_key = self.Key.with_user(self.root) - - # Create - key = root_key.create( - {"name": "Root Key", "secret_value": "root secret", "key_type": "s"} - ) - self.assertTrue(key.exists()) - - # Read - self.assertEqual(root_key.browse(key.id).name, "Root Key") - - # Write - root_key.browse(key.id).write({"name": "Updated Root Key"}) - self.assertEqual(key.name, "Updated Root Key") - - # Delete - key.unlink() - self.assertFalse(key.exists()) - - def test_key_value_user_access(self): - """Test that regular users have no access to key values""" - user_key_value = self.KeyValue.with_user(self.user) - - # Create test key and key value - key = self.Key.create({"name": "Test Key", "key_type": "s"}) - key_value = self.KeyValue.create( - {"key_id": key.id, "secret_value": "test value"} - ) - - # Test CRUD operations - with self.assertRaises(AccessError): - user_key_value.create({"key_id": key.id, "secret_value": "new value"}) - with self.assertRaises(AccessError): - user_key_value.browse(key_value.id).read(["secret_value"]) - with self.assertRaises(AccessError): - user_key_value.browse(key_value.id).write({"secret_value": "updated value"}) - with self.assertRaises(AccessError): - user_key_value.browse(key_value.id).unlink() - - def test_key_value_manager_read_access(self): - """Test manager read access rules for key values""" - manager_key_value = self.KeyValue.with_user(self.manager) - - # Create test key and key values - key = self.Key.create({"name": "Test Key", "key_type": "s"}) - global_value = self.KeyValue.create( - {"key_id": key.id, "secret_value": "global value"} - ) - server_value = self.KeyValue.create( - { - "key_id": key.id, - "secret_value": "server value", - "server_id": self.server_1.id, - } - ) - - # Test read access - should not find without proper access - self.assertTrue(manager_key_value.search([("id", "=", global_value.id)])) - self.assertFalse(manager_key_value.search([("id", "=", server_value.id)])) - - # Add as key user - should find global value and server value for that key - key.write({"user_ids": [(4, self.manager.id)]}) - self.assertTrue(manager_key_value.search([("id", "=", global_value.id)])) - self.assertTrue(manager_key_value.search([("id", "=", server_value.id)])) - - # Remove from key users - key.write({"user_ids": [(3, self.manager.id)]}) - self.assertTrue(manager_key_value.search([("id", "=", global_value.id)])) - self.assertFalse(manager_key_value.search([("id", "=", server_value.id)])) - - # Add as server user - should find server value - self.server_1.write({"user_ids": [(4, self.manager.id)]}) - self.assertTrue(manager_key_value.search([("id", "=", global_value.id)])) - self.assertTrue(manager_key_value.search([("id", "=", server_value.id)])) - - def test_key_value_manager_write_access(self): - """Test manager write/create access rules for key values""" - manager_key_value = self.KeyValue.with_user(self.manager) - - # Create test key and key values - key = self.Key.create({"name": "Test Key", "key_type": "s"}) - global_value = self.KeyValue.create( - {"key_id": key.id, "secret_value": "global value"} - ) - server_value = self.KeyValue.create( - { - "key_id": key.id, - "secret_value": "server value", - "server_id": self.server_1.id, - } - ) - - # Try write without proper access - should fail - with self.assertRaises(AccessError): - manager_key_value.browse(global_value.id).write( - {"secret_value": "new value"} - ) - with self.assertRaises(AccessError): - manager_key_value.browse(server_value.id).write( - {"secret_value": "new value"} - ) - - # Add as key manager - should write to global value - key.write({"manager_ids": [(4, self.manager.id)]}) - manager_key_value.browse(global_value.id).write( - {"secret_value": "updated global"} - ) - self.assertEqual( - global_value._get_secret_value("secret_value"), "updated global" - ) - - # Add as server manager - should write to server value - self.server_1.write({"manager_ids": [(4, self.manager.id)]}) - manager_key_value.browse(server_value.id).write( - {"secret_value": "updated server"} - ) - self.assertEqual( - server_value._get_secret_value("secret_value"), "updated server" - ) - - # Test create access - for_bob = manager_key_value.create( - { - "key_id": key.id, - "secret_value": "for bob", - "partner_id": self.user_bob.partner_id.id, - } - ) - self.assertTrue(for_bob.exists()) - - def test_key_value_manager_unlink_access(self): - """Test manager unlink access rules for key values""" - manager_key_value = self.KeyValue.with_user(self.manager) - - # Create test key and key values - key = self.Key.create({"name": "Test Key", "key_type": "s"}) - - # Create values as root - global_value = self.KeyValue.create( - {"key_id": key.id, "secret_value": "global value"} - ) - server_value = self.KeyValue.create( - { - "key_id": key.id, - "secret_value": "server value", - "server_id": self.server_1.id, - } - ) - - # Try delete without proper access - should fail - with self.assertRaises(AccessError): - manager_key_value.browse(global_value.id).unlink() - with self.assertRaises(AccessError): - manager_key_value.browse(server_value.id).unlink() - - # Add as manager but not creator - should still fail - key.write({"manager_ids": [(4, self.manager.id)]}) - self.server_1.write({"manager_ids": [(4, self.manager.id)]}) - with self.assertRaises(AccessError): - manager_key_value.browse(global_value.id).unlink() - with self.assertRaises(AccessError): - manager_key_value.browse(server_value.id).unlink() - - # Create own values - should delete - own_partner_value = manager_key_value.create( - { - "key_id": key.id, - "secret_value": "own partner", - "partner_id": self.user_bob.partner_id.id, - } - ) - - # Unlink server value first to avoid constraint error - server_value.unlink() - - # Create server value - own_server_value = manager_key_value.create( - { - "key_id": key.id, - "secret_value": "own server", - "server_id": self.server_1.id, - } - ) - - own_partner_value.unlink() - own_server_value.unlink() - self.assertFalse(own_partner_value.exists()) - self.assertFalse(own_server_value.exists()) - - def test_key_value_root_access(self): - """Test root access rules for key values""" - root_key_value = self.KeyValue.with_user(self.root) - - # Create test key - key = self.Key.create({"name": "Test Key", "key_type": "s"}) - - # Create - value = root_key_value.create({"key_id": key.id, "secret_value": "root value"}) - self.assertTrue(value.exists()) - - # Read - self.assertEqual( - root_key_value.browse(value.id)._get_secret_value("secret_value"), - "root value", - ) - - # Write - root_key_value.browse(value.id).write({"secret_value": "updated value"}) - self.assertEqual(value._get_secret_value("secret_value"), "updated value") - - # Delete - value.unlink() - self.assertFalse(value.exists()) - - def test_key_value_global_unique(self): - """Test global value uniqueness""" - - # Try to create a value for the same key - with self.assertRaises(ValidationError): - another_global_value = self.KeyValue.create( - {"key_id": self.test_key.id, "secret_value": "another test value"} - ) - # - another_global_value.unlink() - - def test_key_value_server_unique(self): - """Test server value uniqueness""" - # Create server tight value - - self.KeyValue.create( - { - "key_id": self.test_key.id, - "secret_value": "server related", - "server_id": self.server_1.id, - } - ) - - # Try create another value for the same server - with self.assertRaises(ValidationError): - self.KeyValue.create( - { - "key_id": self.test_key.id, - "secret_value": "another server related", - "server_id": self.server_1.id, - } - ) - - def test_key_value_partner_unique(self): - """Test partner value uniqueness""" - # Create partner tight value - self.KeyValue.create( - { - "key_id": self.test_key.id, - "secret_value": "partner related", - "partner_id": self.user_bob.partner_id.id, - } - ) - - # Try create another value for the same partner - with self.assertRaises(ValidationError): - self.KeyValue.create( - { - "key_id": self.test_key.id, - "secret_value": "another partner related", - "partner_id": self.user_bob.partner_id.id, - } - ) - - def test_key_value_server_partner_unique(self): - """Test server and partner value uniqueness""" - - # Create server and partner tight value - self.KeyValue.create( - { - "key_id": self.test_key.id, - "secret_value": "server related", - "server_id": self.server_1.id, - "partner_id": self.user_bob.partner_id.id, - } - ) - - # Try create another value for the same server and partner - with self.assertRaises(ValidationError): - self.KeyValue.create( - { - "key_id": self.test_key.id, - "secret_value": "another server related", - "server_id": self.server_1.id, - "partner_id": self.user_bob.partner_id.id, - } - ) diff --git a/addons/cetmix_tower_server/tests/test_partner_server_btn.py b/addons/cetmix_tower_server/tests/test_partner_server_btn.py deleted file mode 100644 index fcfb2dd..0000000 --- a/addons/cetmix_tower_server/tests/test_partner_server_btn.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright (C) 2022 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.tests.common import tagged - -from .common import TestTowerCommon - - -@tagged("partner_servers_btn") -class TestPartnerServers(TestTowerCommon): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.partner_a = cls.env["res.partner"].create({"name": "Partner A"}) - cls.partner_b = cls.env["res.partner"].create({"name": "Partner B"}) - cls.partner_b_child = cls.env["res.partner"].create( - { - "name": "Partner B Child", - "parent_id": cls.partner_b.id, - } - ) - - cls.server_defaults = { - "name": "Test Server", - "ssh_username": "root", - "ssh_port": 22, - "ssh_password": "Test-P@ssw0rd-123", - "ip_v4_address": "127.0.0.1", - "skip_host_key": True, - } - - cls.Server.create({"partner_id": cls.partner_b.id, **cls.server_defaults}) - cls.Server.create({"partner_id": cls.partner_b.id, **cls.server_defaults}) - cls.Server.create({"partner_id": cls.partner_b_child.id, **cls.server_defaults}) - - key = cls.Key.create({"name": "SSH Token", "key_type": "s"}) - cls.KeyValue.create( - { - "key_id": key.id, - "partner_id": cls.partner_b.id, - "secret_value": "TOPSECRET", - } - ) - - def test_server_count_compute(self): - """Server count: direct + one‑level child + zero if none.""" - self.assertEqual(self.partner_b.server_count, 3) - self.assertEqual(self.partner_b_child.server_count, 1) - self.assertEqual(self.partner_a.server_count, 0) - - def test_parent_with_only_child_servers(self): - """Parent without servers directs and with child_of.""" - parent = self.env["res.partner"].create({"name": "Parent Only"}) - child = self.env["res.partner"].create( - {"name": "Child with Server", "parent_id": parent.id} - ) - self.Server.create({"partner_id": child.id, **self.server_defaults}) - self.assertEqual(parent.server_count, 1) diff --git a/addons/cetmix_tower_server/tests/test_plan.py b/addons/cetmix_tower_server/tests/test_plan.py deleted file mode 100644 index da8970e..0000000 --- a/addons/cetmix_tower_server/tests/test_plan.py +++ /dev/null @@ -1,2899 +0,0 @@ -# Copyright (C) 2022 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from unittest.mock import patch - -from odoo import _, fields -from odoo.exceptions import AccessError, ValidationError -from odoo.tools.misc import mute_logger - -from ..models.constants import ( - ANOTHER_PLAN_RUNNING, - GENERAL_ERROR, - PLAN_IS_EMPTY, - PLAN_LINE_CONDITION_CHECK_FAILED, - PLAN_NOT_COMPATIBLE_WITH_SERVER, - PLAN_STOPPED, -) -from .common import TestTowerCommon - - -class TestTowerPlan(TestTowerCommon): - """Test the cx.tower.plan model.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Commands - cls.command_run_flight_plan_1 = cls.Command.create( - { - "name": "Run Flight Plan", - "action": "plan", - "flight_plan_id": cls.plan_1.id, - } - ) - cls.command_python_custom_variable_values_1 = cls.Command.create( - { - "name": "Python command to set custom variable values", - "action": "python_code", - "code": """ -custom_values['test_path_'] = '/test_path' -custom_values['test_dir'] = 'test_dir' -custom_values['_my_value'] = 'Just To Test' -""", - } - ) - cls.command_python_custom_variable_values_2 = cls.Command.create( - { - "name": "Python command to update custom variable values", - "action": "python_code", - "code": f""" -custom_values['test_path_'] = '/another_test_path' -custom_values['random_var_reference'] = 'random_var_value' -custom_values['{cls.variable_url.reference}'] = 'https://www.cetmix.com' -""", - } - ) - # Flight plan - cls.plan_2 = cls.Plan.create( - { - "name": "Test plan 2", - "note": "Run another flight plan", - } - ) - cls.plan_2_line_1 = cls.plan_line.create( - { - "sequence": 5, - "plan_id": cls.plan_2.id, - "command_id": cls.command_run_flight_plan_1.id, - } - ) - cls.plan_2_line_2 = cls.plan_line.create( - { - "sequence": 10, - "plan_id": cls.plan_2.id, - "command_id": cls.command_create_dir.id, - } - ) - # Flight plan with access level 1 to test user access rights - cls.plan_3 = cls.Plan.create( - { - "name": "Test plan 3", - "note": "Test user access rights", - "access_level": "1", - "line_ids": [ - (0, 0, {"command_id": cls.command_create_dir.id, "sequence": 1}), - ], - } - ) - # Create line for plan 3 - cls.plan_3_line_1 = cls.plan_line.create( - { - "plan_id": cls.plan_3.id, - "command_id": cls.command_create_dir.id, - "sequence": 10, - } - ) - cls.plan_3_line_1_action = cls.env["cx.tower.plan.line.action"].create( - { - "line_id": cls.plan_3_line_1.id, - "condition": "==", - "value_char": "test", - "action": "e", - } - ) - cls.variable_value = cls.env["cx.tower.variable.value"].create( - { - "variable_id": cls.variable_os.id, - "value_char": "Windows 2k", - "plan_line_action_id": cls.plan_3_line_1_action.id, - } - ) - cls.server = cls.Server.create( - { - "name": "Plan Test Server", - "ssh_username": "test", - "ssh_password": "test", - "ip_v4_address": "localhost", - "ssh_port": 22, - "user_ids": [(6, 0, [cls.user.id])], - "manager_ids": [(6, 0, [cls.manager.id])], - "skip_host_key": True, - } - ) - - def _create_plan(self, **kwargs): - """Helper method to create a flight plan.""" - vals = { - "name": "Test Flight Plan", - "access_level": "1", # override default for user tests - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - "server_ids": [(6, 0, [])], - } - if kwargs: - vals.update(kwargs) - return self.Plan.create(vals) - - def test_user_read_access(self): - """ - For a user: - Read access is allowed if access_level == "1" and - either the plan's own user_ids includes the user - OR at least one related server (via server_ids) - includes the user in its user_ids. - """ - # Case 1: Plan with access_level "1" and user - # included in plan.user_ids. - plan1 = self._create_plan( - **{ - "access_level": "1", - "user_ids": [(6, 0, [self.user.id])], - } - ) - recs1 = self.Plan.with_user(self.user).search([("id", "=", plan1.id)]) - self.assertIn( - plan1, - recs1, - "User should see the plan if in " "plan.user_ids and access_level == '1'.", - ) - - # Case 2: Plan with access_level "1" with no direct user_ids, - # but with a related server that grants access. - plan2 = self._create_plan( - **{ - "access_level": "1", - "user_ids": [(6, 0, [])], - "server_ids": [(6, 0, [self.server.id])], - } - ) - recs2 = self.Plan.with_user(self.user).search([("id", "=", plan2.id)]) - self.assertIn( - plan2, - recs2, - "User should see the plan if a " - "related server.user_ids includes the user.", - ) - - # Negative: Plan with access_level "1" - # with neither direct nor server-based access. - plan3 = self._create_plan( - **{ - "access_level": "1", - "user_ids": [(6, 0, [])], - "server_ids": [(6, 0, [])], - } - ) - recs3 = self.Plan.with_user(self.user).search([("id", "=", plan3.id)]) - self.assertNotIn( - plan3, - recs3, - "User should not see the plan if not granted access.", - ) - - # Also, a user should not be allowed to create a plan. - with self.assertRaises(AccessError): - self.Plan.with_user(self.user).create( - { - "name": "Test Plan", - "access_level": "1", - "user_ids": [(6, 0, [self.user.id])], - } - ) - # ...and modify a plan that they have access to. - with self.assertRaises(AccessError): - plan1.with_user(self.user).write({"name": "User Updated Plan"}) - - def test_manager_read_access(self): - """ - For a manager: - Read access is allowed if access_level <= "2" AND - EITHER the plan itself grants access - (its user_ids or manager_ids includes the manager) - OR either there are no related servers OR a related server - grants access (its user_ids or manager_ids includes the manager). - """ - # Case 1: Plan with access_level "2" and plan.manager_ids - # includes the manager. - plan1 = self._create_plan( - **{ - "access_level": "2", - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - recs1 = self.Plan.with_user(self.manager).search([("id", "=", plan1.id)]) - self.assertIn( - plan1, - recs1, - "Manager should see the plan if in " - "plan.manager_ids and access_level <= '2'.", - ) - - # Case 2: Plan with access_level "2" that does not grant direct access, - # but a related server grants access via its manager_ids. - plan2 = self._create_plan( - **{ - "access_level": "2", - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - "server_ids": [(6, 0, [self.server.id])], - } - ) - recs2 = self.Plan.with_user(self.manager).search([("id", "=", plan2.id)]) - self.assertIn( - plan2, - recs2, - "Manager should see the plan if related " - "server.manager_ids includes the manager.", - ) - - # Case 3 negative: Plan with access_level "2" with no granted access - # if it's linked to a server that does not grant access. - plan3 = self._create_plan( - **{ - "access_level": "2", - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - recs3 = self.Plan.with_user(self.manager).search([("id", "=", plan3.id)]) - self.assertNotIn( - plan3, - recs3, - "Manager should not see the plan " - "if not granted access to related server.", - ) - - # Case 4 positive: Plan with access_level "2" with no linked servers - # and no related servers that grant access. - plan4 = self._create_plan( - **{ - "access_level": "2", - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - "server_ids": [(6, 0, [])], - } - ) - recs4 = self.Plan.with_user(self.manager).search([("id", "=", plan4.id)]) - self.assertIn( - plan4, - recs4, - "Manager should see the plan if not linked to any servers.", - ) - - # Case 5 negative: raise access level to 3 - # and check if manager can see the plan - plan4.access_level = "3" - recs5 = self.Plan.with_user(self.manager).search([("id", "=", plan4.id)]) - self.assertNotIn( - plan4, - recs5, - "Manager should not see the plan " "if access level is raised to 3.", - ) - - def test_manager_write_create_access(self): - """ - For a manager: - Write (update) and create access are allowed if access_level <= "2" AND - the plan's own manager_ids includes the manager. - """ - # Case 1: Plan with access_level "2" and plan.manager_ids - # includes the manager should allow to update the plan. - plan1 = self._create_plan( - **{ - "access_level": "2", - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - try: - plan1.with_user(self.manager).write({"name": "Manager Updated Plan"}) - except AccessError: - self.fail( - "Manager should be able to update the plan if " "in plan.manager_ids.", - ) - self.assertEqual( - plan1.with_user(self.manager).name, - "Manager Updated Plan", - ) - - # Case 2: Attempt to create a plan as a manager without - # including their ID in manager_ids should fail. - with self.assertRaises(AccessError): - self.Plan.with_user(self.manager).create( - { - "name": "Manager Created Plan", - "access_level": "2", - "manager_ids": [(6, 0, [])], - } - ) - - # Case 3: Create a plan with manager added to manager_ids - # should be allowed. - try: - self.Plan.with_user(self.manager).create( - { - "name": "Manager Created Plan", - "access_level": "2", - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - except AccessError: - self.fail( - "Manager should be able to create a plan " - "with himself added to manager_ids.", - ) - - def test_manager_unlink_access(self): - """ - For a manager: - Unlink (delete) access is allowed if access_level <= "2", - the current user is the record creator, - AND the plan's own manager_ids includes the manager. - """ - # Scenario 1: Plan created by the manager with plan.manager_ids - # including the manager. - plan1 = self.Plan.with_user(self.manager).create( - { - "name": "Manager Created Plan", - "access_level": "2", - } - ) - try: - plan1.unlink() - except AccessError: - self.fail( - "Manager should be able to delete the plan " - "they created if in plan.manager_ids.", - ) - - # Scenario 2: Plan created by another user, even if - # plan.manager_ids includes the manager. - plan2 = self._create_plan( - **{ - "access_level": "2", - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - with self.assertRaises(AccessError): - plan2.with_user(self.manager).unlink() - - def test_root_unrestricted_access(self): - """ - For a root user: - Unlimited access: root can read, write, create, and delete plans - regardless of access_level or related servers. - """ - plan = self._create_plan( - **{ - "access_level": "3", # above threshold for managers - } - ) - recs = self.Plan.with_user(self.root).search([("id", "=", plan.id)]) - self.assertIn( - plan, - recs, - "Root should see the plan regardless of restrictions.", - ) - try: - plan.with_user(self.root).write({"name": "Root Updated Plan"}) - except AccessError: - self.fail("Root should be able to update the plan without restrictions.") - self.assertEqual(plan.with_user(self.root).name, "Root Updated Plan") - plan2 = self.Plan.with_user(self.root).create( - { - "name": "Root Created Plan", - "access_level": "3", - } - ) - self.assertTrue( - plan2, - "Root should be able to create a plan without restrictions.", - ) - plan2.with_user(self.root).unlink() - recs_after = self.Plan.with_user(self.root).search([("id", "=", plan2.id)]) - self.assertFalse( - recs_after, - "Root should be able to delete the plan without restrictions.", - ) - - def test_plan_line_action_name(self): - """Test plan line action naming""" - - # Add new line - plan_line_1 = self.plan_line.create( - { - "plan_id": self.plan_1.id, - "command_id": self.command_create_dir.id, - "sequence": 10, - } - ) - - # Add new action with custom - action_1 = self.plan_line_action.create( - { - "line_id": plan_line_1.id, - "condition": "==", - "value_char": "35", - "action": "e", - } - ) - - # Check if action name is composed correctly - expected_action_string = _( - "If exit code == 35 then Exit with command exit code" - ) - self.assertEqual( - action_1.name, - expected_action_string, - msg="Action name doesn't match expected one", - ) - - def test_plan_get_next_action_values(self): - """Test _get_next_action_values() - - NB: This test relies on demo data and might fail if it is modified - """ - # Ensure demo date integrity just in case demo date is modified - self.assertEqual( - self.plan_1.line_ids[0].action_ids[1].custom_exit_code, - 255, - "Plan 1 line #1 action #2 custom exit code must be equal to 255", - ) - - # Create a new plan log. - plan_line_1 = self.plan_1.line_ids[0] # Using command 1 from Plan 1 - plan_log = self.PlanLog.create( - { - "server_id": self.server_test_1.id, - "plan_id": self.plan_1.id, - "is_running": True, - "start_date": fields.Datetime.now(), - "plan_line_executed_id": plan_line_1.id, - } - ) - - # ************************ - # Test with exit code == 0 - # Must run the next command - # ************************ - command_log = self.CommandLog.create( - { - "plan_log_id": plan_log.id, - "server_id": self.server_test_1.id, - "command_id": plan_line_1.command_id.id, - "command_response": "Ok", - "command_status": 0, # Error code - } - ) - action, exit_code, next_line_id = self.plan_1._get_next_action_values( - command_log - ) - self.assertEqual(action, "n", msg="Action must be 'Run next action'") - self.assertEqual(exit_code, 0, msg="Exit code must be equal to 0") - self.assertEqual( - next_line_id, - self.plan_line_2, - msg="Next line must be Line #2", - ) - - # ************************ - # Test with exit code == 8 - # Must exit with custom code - # ************************ - command_log.command_status = 8 - - action, exit_code, next_line_id = self.plan_1._get_next_action_values( - command_log - ) - self.assertEqual(action, "ec", msg="Action must be 'Exit with custom code'") - self.assertEqual(exit_code, 255, msg="Exit code must be equal to 255") - self.assertIsNone(next_line_id, msg="Next line must be None") - - # ************************ - # Test with exit code == -12 - # Plan on error action must be triggered because no action condition is matched - # ************************ - command_log.command_status = -12 - - action, exit_code, next_line_id = self.plan_1._get_next_action_values( - command_log - ) - self.assertEqual(action, "e", msg="Action must be 'Exit with command code'") - self.assertEqual(exit_code, -12, msg="Exit code must be equal to -12") - self.assertIsNone(next_line_id, msg="Next line must be None") - - # ************************ - # Change Plan 'On error action' of the plan to 'Run next command' - # Next line must be Line #2 - # ************************ - - command_log.command_status = -12 - self.plan_1.on_error_action = "n" - - action, exit_code, next_line_id = self.plan_1._get_next_action_values( - command_log - ) - self.assertEqual(action, "n", msg="Action must be 'Run next action'") - self.assertEqual(exit_code, -12, msg="Exit code must be equal to -12") - self.assertEqual( - next_line_id, - self.plan_line_2, - msg="Next line must be Line #2", - ) - - # ************************ - # Run Line 2 (the last one). - # Action 2 will be triggered which is "Run next line". - # However because this is the last line of the plan must exit with command code. - # ************************ - - plan_line_2 = self.plan_1.line_ids[1] - plan_log.plan_line_executed_id = plan_line_2.id - command_log.command_status = 3 - - action, exit_code, next_line_id = self.plan_1._get_next_action_values( - command_log - ) - self.assertEqual(action, "e", msg="Action must be 'Exit with command code'") - self.assertEqual(exit_code, 3, msg="Exit code must be equal to 3") - self.assertIsNone(next_line_id, msg="Next line must be None") - - # ************************ - # Run Line 2 (the last one). - # Fallback plan action must be triggered because no action condition is matched - # However because this is the last line of the plan must exit with command code. - # ************************ - - command_log.command_status = 1 - - action, exit_code, next_line_id = self.plan_1._get_next_action_values( - command_log - ) - self.assertEqual(action, "e", msg="Action must be 'Exit with command code'") - self.assertEqual(exit_code, 1, msg="Exit code must be equal to 1") - self.assertIsNone(next_line_id, msg="Next line must be None") - - def test_plan_run_single(self): - """Test plan execution results""" - - # Add user as user to Server1 - self.server_test_1.user_ids = [(4, self.user_bob.id)] - - # Ensure that access error is raised - # Because user_bob is not in any Tower group - with self.assertRaises(AccessError): - self.plan_1.with_user(self.user_bob)._run_single(self.server_test_1) - - # Add user to the "User" group - self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") - - # Ensure that access error is raised - # Because plan access level is "Manager" and user_bob is in "User" group - with self.assertRaises(AccessError): - self.plan_1.with_user(self.user_bob)._run_single(self.server_test_1) - - # Set access level to 1 and link to server1 - # so Bob can execute the plan - self.write_and_invalidate( - self.plan_1, - **{"access_level": "1", "server_ids": [(4, self.server_test_1.id)]}, - ) - - self.env["ir.rule"].invalidate_model() - # Run plan - self.plan_1.with_user(self.user_bob)._run_single(self.server_test_1) - - # Check plan log - plan_log_rec = self.PlanLog.search([("server_id", "=", self.server_test_1.id)]) - - # Must be a single record - self.assertEqual(len(plan_log_rec), 1, msg="Must be a single plan record") - - # Ensure all commands were triggered - expected_command_count = 2 - self.assertEqual( - len(plan_log_rec.command_log_ids), - expected_command_count, - msg=f"Must run {expected_command_count} commands", - ) - - # Check plan status - expected_plan_status = 0 - self.assertEqual( - plan_log_rec.plan_status, - expected_plan_status, - msg=f"Plan status must be equal to {expected_plan_status}", - ) - - # ************************ - # Change condition in line #1. - # Action 1 will be triggered which is "Exit with custom code" 29. - # ************************ - action_to_tweak = self.plan_line_1_action_1 - action_to_tweak.write({"custom_exit_code": 29, "action": "ec"}) - - # Run plan - self.plan_1._run_single(self.server_test_1) - - # Check plan log - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - - # Must be two plan log record - self.assertEqual(len(plan_log_records), 2, msg="Must be 2 plan log records") - plan_log_rec = plan_log_records[0] - - # Ensure all commands were triggered - expected_command_count = 1 - self.assertEqual( - len(plan_log_rec.command_log_ids), - expected_command_count, - msg=f"Must run {expected_command_count} commands", - ) - - # Check plan status - expected_plan_status = 29 - self.assertEqual( - plan_log_rec.plan_status, - expected_plan_status, - msg=f"Plan status must be equal to {expected_plan_status}", - ) - - # Ensure 'path' was substituted with the plan line custom 'path' - self.assertEqual( - self.plan_line_1.path, - plan_log_rec.command_log_ids.path, - "Path in command log must be the same as in the flight plan line", - ) - - def test_plan_and_command_access_level(self): - # Remove userbob from all cxtower_server groups - self.remove_from_group( - self.user_bob, - [ - "cetmix_tower_server.group_user", - "cetmix_tower_server.group_manager", - "cetmix_tower_server.group_root", - ], - ) - - # Add user_bob to group_manager - self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") - - # Add user_bob as manager to the plan - self.plan_1.manager_ids = [(4, self.user_bob.id)] - - # check if plan and commands included has same access level - self.assertEqual(self.plan_1.access_level, "2") - self.assertEqual(self.command_create_dir.access_level, "2") - self.assertEqual(self.command_list_dir.access_level, "2") - - # check that if we modify plan access level to make it lower than the - # access_level of the commands related with it access level, - # access_level_warn_msg will be created - self.plan_1.with_user(self.user_bob).write({"access_level": "1"}) - self.assertTrue(self.plan_1.access_level_warn_msg) - - # Add user_bob to group_root - self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") - - # check if user_bob can make plan access leve higher than commands access level - self.plan_1.with_user(self.user_bob).write({"access_level": "3"}) - self.assertEqual(self.plan_1.access_level, "3") - - # check that if we create a new plan with an access_level lower than - # the access_level of the command related with access_level_warn_msg - # will be created - command_1 = self.Command.create( - {"name": "New Test Command", "access_level": "3"} - ) - - self.plan_2 = self.Plan.create( - { - "name": "Test plan 2", - "note": "Create directory and list its content", - } - ) - self.plan_line_2_1 = self.plan_line.create( - { - "sequence": 5, - "plan_id": self.plan_2.id, - "command_id": command_1.id, - } - ) - self.assertTrue(self.plan_2.access_level_warn_msg) - - def test_multiple_plan_create_write(self): - """Test multiple plan create/write cases""" - # Create multiple plans at once - plans_data = [ - { - "name": "Test Plan 1", - "note": "Plan 1 Note", - "tag_ids": [(6, 0, [self.tag_test_staging.id])], - }, - { - "name": "Test Plan 2", - "note": "Plan 2 Note", - "tag_ids": [(6, 0, [self.tag_test_production.id])], - }, - { - "name": "Test Plan 3", - "note": "Plan 3 Note", - "tag_ids": [(6, 0, [self.tag_test_staging.id])], - }, - ] - created_plans = self.Plan.create(plans_data) - # Check that all plans are created successfully - self.assertTrue(all(created_plans)) - # Update the access level of the created plans - created_plans.write({"access_level": "3"}) - # Check that all plans are updated successfully - self.assertTrue(all(plan.access_level == "3" for plan in created_plans)) - - def test_plan_with_first_not_executable_condition(self): - """ - Test plan with not executable condition for first plan line - """ - # Add condition for the first plan line - self.plan_line_1.condition = "{{ odoo_version }} == '14.0'" - # Run plan - self.plan_1._run_single(self.server_test_1) - # Check plan log - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual( - len(plan_log_records.command_log_ids), - 2, - msg="Must be two command records", - ) - self.assertTrue( - plan_log_records.command_log_ids[0].is_skipped, - msg="First command must be skipped", - ) - self.assertFalse( - plan_log_records.command_log_ids[1].is_skipped, - msg="Second command not must be skipped", - ) - - def test_plan_with_second_not_executable_condition(self): - """ - Test plan with not executable condition for second plan line - """ - # Add condition for second plan line - self.plan_line_2.condition = "{{ odoo_version }} == '14.0'" - # Run plan - self.plan_1._run_single(self.server_test_1) - # Check plan log - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual( - len(plan_log_records.command_log_ids), - 2, - msg="Must be two command records", - ) - self.assertTrue( - plan_log_records.command_log_ids[1].is_skipped, - msg="Second command must be skipped", - ) - self.assertFalse( - plan_log_records.command_log_ids[0].is_skipped, - msg="First command not must be skipped", - ) - - def test_plan_with_executable_condition(self): - """ - Test plan with executable condition for plan line - """ - # Add condition for first plan line - self.plan_line_1.condition = "1 == 1" - # Create a global value for the 'Version' variable - self.VariableValue.create( - {"variable_id": self.variable_version.id, "value_char": "14.0"} - ) - # Add condition with variable - self.plan_line_2.condition = ( - "{{ " + self.variable_version.name + " }} == '14.0'" - ) - # Run plan - self.plan_1._run_single(self.server_test_1) - # Check commands - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual( - len(plan_log_records.command_log_ids), - 2, - msg="Must be two command records", - ) - self.assertTrue( - all(not command.is_skipped for command in plan_log_records.command_log_ids), - msg="All command should be executed", - ) - - def test_plan_with_update_variables(self): - """ - Test plan updates custom (in-flight) values - """ - # Add new variable to server - self.VariableValue.create( - { - "variable_id": self.variable_version.id, - "value_char": "14.0", - "server_id": self.server_test_1.id, - } - ) - # Create new variable value on action - self.VariableValue.create( - { - "variable_id": self.variable_version.id, - "value_char": "16.0", - "plan_line_action_id": self.plan_line_1_action_1.id, - } - ) - # Add a new variable value on action for a variable absent on the server - self.VariableValue.create( - { - "variable_id": self.variable_os.id, - "value_char": "Ubuntu", - "plan_line_action_id": self.plan_line_1_action_1.id, - } - ) - # Pre-run sanity: server holds initial value and no OS value - exist_server_values = self.server_test_1.variable_value_ids.filtered( - lambda rec: rec.variable_id == self.variable_version - ) - self.assertEqual( - len(exist_server_values), - 1, - "The server should have only one value for the variable", - ) - self.assertEqual( - exist_server_values.value_char, - "14.0", - "The server variable value should be '14.0'", - ) - exist_server_values = self.server_test_1.variable_value_ids.filtered( - lambda rec: rec.variable_id == self.variable_os - ) - self.assertFalse( - exist_server_values, "The server should not have this variable" - ) - # Run plan - self.plan_1._run_single(self.server_test_1) - # After run: server values MUST remain unchanged - server_version_val = self.server_test_1.variable_value_ids.filtered( - lambda rec: rec.variable_id == self.variable_version - ) - self.assertEqual( - server_version_val.value_char, - "14.0", - "Server variable value must remain unchanged", - ) - self.assertFalse( - self.server_test_1.variable_value_ids.filtered( - lambda rec: rec.variable_id == self.variable_os - ), - "Server must not receive new variable from action", - ) - - # But custom (in-flight) values MUST be updated in logs - plan_log = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)], order="id desc", limit=1 - ) - self.assertTrue(plan_log, "Plan log should exist after run") - self.assertEqual( - plan_log.variable_values[self.variable_version.reference], - "16.0", - "Plan log must contain updated custom value", - ) - self.assertEqual( - plan_log.variable_values[self.variable_os.reference], - "Ubuntu", - "Plan log must contain new custom value", - ) - - last_command_log = plan_log.command_log_ids and plan_log.command_log_ids[-1] - self.assertTrue(last_command_log, "Command log should exist after run") - self.assertEqual( - last_command_log.variable_values[self.variable_version.reference], - "16.0", - "Command log must contain updated custom value", - ) - - def test_plan_with_action_variables_for_condition(self): - """ - Test plan with update server variables and use new - value as condition for next plan line - """ - # Add new variable to server - self.VariableValue.create( - { - "variable_id": self.variable_version.id, - "value_char": "14.0", - "server_id": self.server_test_1.id, - } - ) - # Create new variable value to action to update existing server variable - self.VariableValue.create( - { - "variable_id": self.variable_version.id, - "value_char": "16.0", - "plan_line_action_id": self.plan_line_1_action_1.id, - } - ) - # Add condition with variable - self.plan_line_2.condition = ( - "{{ " + self.variable_version.name + " }} == '14.0'" - ) - # Run plan - self.plan_1._run_single(self.server_test_1) - # Check commands - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - # The second line of the plan should be skipped because the - # first line of the plan updated the value of the variable - self.assertTrue( - plan_log_records.command_log_ids[1].is_skipped, - msg="Second command must be skipped", - ) - - # Change condition for plan line - self.plan_line_2.condition = ( - "{{ " + self.variable_version.name + " }} == '16.0'" - ) - # Run plan - self.plan_1._run_single(self.server_test_1) - # Check commands - new_plan_log_records = ( - self.PlanLog.search([("server_id", "=", self.server_test_1.id)]) - - plan_log_records - ) - # The second line of the plan should be skipped because the - # first line of the plan updated the value of the variable - self.assertFalse( - new_plan_log_records.command_log_ids[1].is_skipped, - msg="The second plan line should not be skipped", - ) - - def test_flight_plan_copy(self): - """Test duplicating a Flight Plan with lines, actions, and variable values""" - - # Create a Flight Plan - plan = self.Plan.create( - { - "name": "Test Flight Plan", - "note": "Test Note", - } - ) - - # Create a command for the plan line - command = self.Command.create( - { - "name": "Test Command", - # Command to get Linux kernel version - "code": "uname -r", - } - ) - - # Create a Flight Plan Line - plan_line = self.plan_line.create( - { - "plan_id": plan.id, - "command_id": command.id, - "path": "/test/path", - # Condition based on Linux version - "condition": '{{ test_linux_version }} >= "5.0"', - } - ) - - # Create a variable for the action - variable = self.Variable.create({"name": "test_linux_version"}) - - # Create an Action for the Plan Line - action = self.plan_line_action.create( - { - "line_id": plan_line.id, - "action": "n", # next action - "condition": "==", - "value_char": "0", # condition for success - } - ) - - # Create a Variable Value for the Action - self.env["cx.tower.variable.value"].create( - { - "variable_id": variable.id, - "value_char": "5.0", - "plan_line_action_id": action.id, - } - ) - - # Duplicate the Flight Plan - copied_plan = plan.copy() - - # Ensure the new Flight Plan was created with a new ID - self.assertNotEqual( - copied_plan.id, - plan.id, - "Copied plan should have a different ID from the original", - ) - - # Check that the copied plan has the same number of lines - self.assertEqual( - len(copied_plan.line_ids), - len(plan.line_ids), - "Copied plan should have the same number of lines as the original", - ) - - # Check that the copied plan's lines have the same actions as the original - original_line = plan.line_ids - copied_line = copied_plan.line_ids - - # Ensure the command, condition, and custom path are copied correctly - self.assertEqual( - copied_line.command_id.id, - original_line.command_id.id, - "Command should be the same in copied line", - ) - self.assertEqual( - copied_line.path, - original_line.path, - "Custom path should be the same in copied line", - ) - self.assertEqual( - copied_line.condition, - original_line.condition, - "Condition should be the same in copied line", - ) - - # Ensure actions were copied correctly - self.assertEqual( - len(copied_line.action_ids), - len(original_line.action_ids), - "Number of actions should be the same in the copied line", - ) - self.assertEqual( - copied_line.action_ids.action, - original_line.action_ids.action, - "Action should be the same in the copied line", - ) - self.assertEqual( - copied_line.action_ids.condition, - original_line.action_ids.condition, - "Action condition should be the same in the copied line", - ) - self.assertEqual( - copied_line.action_ids.value_char, - original_line.action_ids.value_char, - "Action value should be the same in the copied line", - ) - - # Check that variable values were copied correctly - original_action = original_line.action_ids - copied_action = copied_line.action_ids - - self.assertEqual( - len(copied_action.variable_value_ids), - len(original_action.variable_value_ids), - "Number of variable values should be the same in the copied action", - ) - - self.assertEqual( - copied_action.variable_value_ids.variable_id.id, - original_action.variable_value_ids.variable_id.id, - "Variable should be the same in the copied action", - ) - self.assertEqual( - copied_action.variable_value_ids.value_char, - original_action.variable_value_ids.value_char, - "Variable value should be the same in the copied action", - ) - - def test_plan_with_another_plan(self): - """ - Test to check running another plan from current plan - """ - # Check plan logs - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") - # Run plan - self.plan_2._run_single(self.server_test_1) - # Check plan logs after execute command with plan action - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual(len(plan_log_records), 2, msg="Should be 2 plan logs") - - parent_plan_log = plan_log_records.filtered( - lambda rec: rec.plan_id == self.plan_2 - ) - self.assertTrue(parent_plan_log, "The log for Plan 2 must exist!") - self.assertEqual( - parent_plan_log.plan_status, 0, "Plan log should success status" - ) - - child_plan_log = plan_log_records - parent_plan_log - self.assertEqual( - child_plan_log.parent_flight_plan_log_id, - parent_plan_log, - "Second plan log should contain parent log link", - ) - triggering = parent_plan_log.command_log_ids.filtered( - lambda log: log.triggered_plan_log_id - ) - self.assertEqual( - len(triggering), 1, "Expected exactly one triggering command log" - ) - self.assertEqual( - child_plan_log.plan_status, - triggering.command_status, - "Parent run-plan command status must equal child plan status", - ) - self.assertEqual( - parent_plan_log.command_log_ids.triggered_plan_log_id, - child_plan_log, - "The command triggered plan line should be equal to child plan", - ) - - # Check that we cannot add recursive plan - with self.assertRaisesRegex( - ValidationError, "Recursive plan call detected in plan.*" - ): - self.plan_line.create( - { - "sequence": 20, - "plan_id": self.plan_1.id, - "command_id": self.command_run_flight_plan_1.id, - } - ) - - # Delete plan lines from first plan - self.plan_1.line_ids = False - # Run plan - self.plan_2._run_single(self.server_test_1) - plan_log_records = ( - self.PlanLog.search([("server_id", "=", self.server_test_1.id)]) - - plan_log_records - ) - - parent_plan_log = plan_log_records.filtered( - lambda rec: rec.plan_id == self.plan_2 - ) - self.assertTrue(parent_plan_log, "The log for Plan 2 must exist!") - self.assertEqual( - parent_plan_log.plan_status, PLAN_IS_EMPTY, "Plan log should failed status" - ) - - child_plan_log = plan_log_records - parent_plan_log - self.assertEqual( - child_plan_log.parent_flight_plan_log_id, - parent_plan_log, - "Second plan log should contain parent log link", - ) - self.assertEqual( - child_plan_log.plan_status, - parent_plan_log.command_log_ids.command_status, - "The command status of parent plan should be equal " - "of status second flight plan", - ) - - def test_plan_with_two_plans(self): - """ - Test to check two plans from plan - """ - self.plan_line.create( - { - "sequence": 15, - "plan_id": self.plan_2.id, - "command_id": self.command_run_flight_plan_1.id, - } - ) - # Check plan logs - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") - # Run plan - self.plan_2._run_single(self.server_test_1) - # Check plan logs after execute command with plan action - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual(len(plan_log_records), 3, msg="Should be 3 plan logs") - - def test_plan_with_nested_plans(self): - """ - Test to check two plans from plan - """ - command_run_flight_plan_2 = self.Command.create( - { - "name": "Run Flight Plan", - "action": "plan", - "flight_plan_id": self.plan_2.id, - } - ) - plan_3 = self.Plan.create( - { - "name": "Test plan 3", - "note": "Run flight plan 2", - } - ) - self.plan_line.create( - { - "sequence": 5, - "plan_id": plan_3.id, - "command_id": command_run_flight_plan_2.id, - } - ) - # Check plan logs - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") - # Run plan - plan_3._run_single(self.server_test_1) - # Check plan logs after execute command with plan action - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual(len(plan_log_records), 3, msg="Should be 3 plan logs") - - last_child_plan_log = plan_log_records.filtered( - lambda rec: rec.plan_id == self.plan_1 - ) - self.assertTrue(last_child_plan_log, "The log for Plan 1 must exist!") - self.assertEqual( - last_child_plan_log.plan_status, 0, "Plan log should success status" - ) - - self.assertIn( - last_child_plan_log.parent_flight_plan_log_id, - plan_log_records, - "Parent plan logs should exist", - ) - self.assertEqual( - last_child_plan_log.parent_flight_plan_log_id.plan_id, - self.plan_2, - "Parent plan should be equal to plan 2", - ) - - child_plan_log = plan_log_records.filtered( - lambda rec: rec.plan_id == self.plan_2 - ) - self.assertIn( - child_plan_log.parent_flight_plan_log_id, - plan_log_records, - "Parent plan logs should exist", - ) - self.assertEqual( - child_plan_log.parent_flight_plan_log_id.plan_id, - plan_3, - "Parent plan should be equal to plan 3", - ) - self.assertEqual( - child_plan_log.command_log_ids.triggered_plan_log_id, - last_child_plan_log, - "The command triggered plan line should be equal to last child plan", - ) - self.assertEqual( - child_plan_log.command_log_ids.triggered_plan_log_id, - last_child_plan_log, - "The command triggered plan line should be equal to last child plan", - ) - parent_plan_log = plan_log_records - child_plan_log - last_child_plan_log - self.assertEqual( - parent_plan_log.command_log_ids.triggered_plan_log_id, - child_plan_log, - "The command triggered plan line from parent plan " - "should be equal to child plan", - ) - - # Check that we cannot change command with existing plan, - # because it's recursive plan - with self.assertRaisesRegex( - ValidationError, "Recursive plan call detected in plan.*" - ): - self.plan_line_1.write( - { - "command_id": command_run_flight_plan_2.id, - } - ) - - # Set the previous command back - - self.plan_line_1.write( - { - "command_id": self.command_create_dir.id, - } - ) - # --- Check server dependency handling - - # Remove all existing flight plan logs - self.PlanLog.search([]).unlink() - - # Set server dependency for plan 2 - self.plan_2.write( - { - "server_ids": [(6, 0, [self.server.id])], - } - ) - plan_log = self.server_test_1.run_flight_plan(self.plan_2) - self.assertEqual(plan_log.plan_status, PLAN_NOT_COMPATIBLE_WITH_SERVER) - - # Run plan on allowed server - plan_log = self.server.run_flight_plan(self.plan_2) - self.assertEqual(plan_log.plan_status, 0) - - def test_failed_first_child_plan_with_another_plan(self): - """ - Check that child plan was failed then parent plan is failed too - """ - # Add new plan line - self.plan_line.create( - { - "sequence": 15, - "plan_id": self.plan_2.id, - "command_id": self.command_run_flight_plan_1.id, - } - ) - # Check plan logs - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") - - # Simulate a failed Plan 1. To achieve this, we need to update the command - # associated with Plan 1 to apply the desired side effect. - self.plan_1.line_ids.command_id[0].code = "fail" - - # Run plan - self.plan_2._run_single(self.server_test_1) - - # Check plan logs after execute command with plan action - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - # 2 logs only because plan should exist with error after first failed command - self.assertEqual(len(plan_log_records), 2, msg="Should be 2 plan logs") - - parent_plan_log = plan_log_records.filtered( - lambda rec: rec.plan_id == self.plan_2 - ) - self.assertTrue(parent_plan_log, "The log for Plan 2 must exist!") - self.assertEqual( - parent_plan_log.plan_status, GENERAL_ERROR, "Plan log should failed status" - ) - - child_plan_log = plan_log_records - parent_plan_log - self.assertEqual( - child_plan_log.parent_flight_plan_log_id, - parent_plan_log, - "Second plan log should contain parent log link", - ) - self.assertEqual( - child_plan_log.plan_status, - parent_plan_log.command_log_ids.command_status, - "The command status of main plan should be equal " - "of status second flight plan", - ) - - def test_failed_second_child_plan_with_another_plan(self): - """ - Check that child plan was failed then parent plan is failed too - """ - # Add new plan line - line = self.plan_line.create( - { - "sequence": 15, - "plan_id": self.plan_2.id, - "command_id": self.command_run_flight_plan_1.id, - } - ) - - cx_tower_plan_obj = self.registry["cx.tower.plan"] - _run_single_super = cx_tower_plan_obj._run_single - - def _run_single(this, *args, **kwargs): - if ( - this == self.plan_1 - and this.env["cx.tower.plan.log"] - .browse(kwargs["log"]["plan_log_id"]) - .plan_line_executed_id - == line - ): - # Simulate a failed Plan 1. To achieve this, we need to update - # the command associated with Plan 1 to apply the desired side effect. - self.plan_1.line_ids.command_id[0].code = "fail" - return _run_single_super(this, *args, **kwargs) - - with patch.object(cx_tower_plan_obj, "_run_single", _run_single): - # Run plan - self.plan_2._run_single(self.server_test_1) - - # Check plan logs after execute command with plan action - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - # 3 logs because plan should exist with error after second failed command - self.assertEqual(len(plan_log_records), 3, msg="Should be 3 plan logs") - - parent_plan_log = plan_log_records.filtered( - lambda rec: rec.plan_id == self.plan_2 - ) - self.assertTrue(parent_plan_log, "The log for Plan 2 must exist!") - self.assertEqual( - parent_plan_log.plan_status, GENERAL_ERROR, "Plan log should failed status" - ) - - child_plan_log = plan_log_records - parent_plan_log - self.assertEqual( - child_plan_log.parent_flight_plan_log_id, - parent_plan_log, - "Second plan log should contain parent log link", - ) - self.assertEqual( - len(child_plan_log), - 2, - "Must be 2 child plan logs", - ) - self.assertIn( - GENERAL_ERROR, - child_plan_log.mapped("plan_status"), - "One of plan status of child plan must be GENERAL_ERROR", - ) - self.assertIn( - 0, - child_plan_log.mapped("plan_status"), - "One of plan status of child plan must be GENERAL_ERROR", - ) - - def test_plan_with_another_plan_with_condition(self): - """ - Test that parent plan will success finished - if child plan executable by condition - """ - # Add condition for first plan line - self.plan_line_1.condition = "1 == 1" - # Check plan logs - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") - # Run plan - self.plan_2._run_single(self.server_test_1) - # Check plan logs after execute command with plan action - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - - self.assertEqual(len(plan_log_records), 2, msg="Should be 2 plan logs") - - parent_plan_log = plan_log_records.filtered( - lambda rec: rec.plan_id == self.plan_2 - ) - self.assertTrue(parent_plan_log, "The log for Plan 2 must exist!") - self.assertEqual( - parent_plan_log.plan_status, 0, "Plan log should success status" - ) - - child_plan_log = plan_log_records - parent_plan_log - self.assertEqual( - child_plan_log.parent_flight_plan_log_id, - parent_plan_log, - "Second plan log should contain parent log link", - ) - self.assertEqual( - child_plan_log.plan_status, - parent_plan_log.command_log_ids.filtered( - lambda log: log.triggered_plan_log_id - ).command_status, - "The command status of main plan should be equal " - "of status second flight plan", - ) - - def test_plan_with_another_plan_with_not_executable_condition(self): - """ - Test plan with not executable condition for second plan line - """ - # Add condition for first plan line - self.plan_line_1.condition = "{{ odoo_version }} == '14.0'" - # Check plan logs - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual(len(plan_log_records), 0, "Plan logs should be empty") - # Run plan - self.plan_2._run_single(self.server_test_1) - - # Check plan logs after execute command with plan action - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - - self.assertEqual(len(plan_log_records), 2, msg="Should be 2 plan logs") - - self.assertIn( - PLAN_LINE_CONDITION_CHECK_FAILED, - plan_log_records.command_log_ids.mapped("command_status"), - "One of commands should be skipped", - ) - - def test_plan_with_another_plan_with_all_not_executable_condition(self): - """ - Test plan with not executable condition for second plan line - """ - # Add condition for all plan lines - self.plan_line_1.condition = "{{ odoo_version }} == '14.0'" - self.plan_line_2.condition = "{{ odoo_version }} == '14.0'" - - self.plan_2_line_1.condition = "{{ odoo_version }} == '14.0'" - self.plan_2_line_2.condition = "{{ odoo_version }} == '14.0'" - - self.plan_2._run_single(self.server_test_1) - - # Check plan logs after execute command with plan action - plan_log_records = self.PlanLog.search( - [("server_id", "=", self.server_test_1.id)] - ) - - self.assertEqual(len(plan_log_records), 1, msg="Should be 1 plan logs") - self.assertEqual( - PLAN_LINE_CONDITION_CHECK_FAILED, - plan_log_records.command_log_ids.filtered( - lambda log: log.command_id == self.command_run_flight_plan_1 - ).command_status, - "Command status should be skipped", - ) - - def test_plan_unlink(self): - plan = self.plan_1.copy() - plan_id = plan.id - plan_line_ids = plan.line_ids - plan_line_action_ids = plan.mapped("line_ids.action_ids") - - plan.unlink() - - self.assertFalse( - self.Plan.search([("id", "=", plan_id)]), msg="Plan should be deleted" - ) - self.assertFalse( - self.plan_line.search([("id", "in", plan_line_ids.ids)]), - msg="Plan line should be deleted when Plan is deleted", - ) - self.assertFalse( - self.plan_line_action.search([("id", "in", plan_line_action_ids.ids)]), - msg="Plan line action should be deleted when Plan line is deleted", - ) - - def test_plan_command_server_compatibility(self): - """Test plan execution with server-restricted flight plans""" - # Create a new test server - test_server = self.Server.create( - { - "name": "Test Server", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "host_key": "test_key", - } - ) - - # Create a flight plan restricted to the test server - plan = self.Plan.create( - { - "name": "Server Restricted Plan", - "server_ids": [(6, 0, [test_server.id])], - "line_ids": [ - (0, 0, {"command_id": self.command_create_dir.id, "sequence": 1}) - ], - } - ) - - # Should fail when executing on non-allowed server - plan_log = plan._run_single(self.server_test_1) - self.assertEqual(plan_log.plan_status, PLAN_NOT_COMPATIBLE_WITH_SERVER) - - # Should work on allowed server - plan._run_single(test_server) - plan_log = self.PlanLog.search( - [("plan_id", "=", plan.id), ("server_id", "=", test_server.id)], limit=1 - ) - self.assertEqual(plan_log.command_log_ids.command_status, 0) - - def test_another_plan_running(self): - """Test the parallel plan running""" - - # Ensure that the plan doesn't allow parallel running - self.plan_1.write({"allow_parallel_run": False}) - - # Create a new plan log with a plan that is already running - self.PlanLog.create( - { - "plan_id": self.plan_1.id, - "server_id": self.server_test_1.id, - "start_date": fields.Datetime.now(), - } - ) - - # Launch the same plan on the same server - plan_log = self.server_test_1.run_flight_plan(self.plan_1) - self.assertEqual(plan_log.plan_status, ANOTHER_PLAN_RUNNING) - - # Now allow parallel running - self.plan_1.write({"allow_parallel_run": True}) - - # Launch the same plan on the same server - plan_log = self.server_test_1.run_flight_plan(self.plan_1) - self.assertEqual(plan_log.plan_status, 0) - - def test_plan_custom_variables(self): - """Test plan with custom variables""" - command_python_1_id = self.command_python_custom_variable_values_1.id - command_python_2_id = self.command_python_custom_variable_values_2.id - - plan = self._create_plan( - **{ - "name": "Plan with custom variables", - "line_ids": [ - ( - 0, - 0, - { - "command_id": command_python_1_id, - "sequence": 1, - }, - ), - (0, 0, {"command_id": self.command_create_dir.id, "sequence": 2}), - ( - 0, - 0, - { - "command_id": command_python_2_id, - "sequence": 3, - }, - ), - (0, 0, {"command_id": self.command_create_dir.id, "sequence": 4}), - ], - } - ) - - # Run plan - plan_log = self.server_test_1.run_flight_plan(plan) - - # Check that custom variable values were updated correctly - # (The log of plan should contain the last updatedvalues) - self.assertEqual(plan_log.variable_values["test_path_"], "/another_test_path") - self.assertEqual(plan_log.variable_values["test_dir"], "test_dir") - self.assertEqual( - plan_log.variable_values["random_var_reference"], "random_var_value" - ) - self.assertEqual(plan_log.variable_values["_my_value"], "Just To Test") - - command_logs = plan_log.command_log_ids - self.assertEqual( - len(command_logs), - len(plan.line_ids), - f"Should be {len(plan.line_ids)} command logs.", - ) - - # Check that custom variable values were created correctly - # in first python command log - command_python_command_1_log = command_logs.filtered( - lambda log: log.command_id.id == command_python_1_id - ) - self.assertEqual( - command_python_command_1_log.variable_values["test_path_"], "/test_path" - ) - self.assertEqual( - command_python_command_1_log.variable_values["test_dir"], "test_dir" - ) - self.assertEqual( - command_python_command_1_log.variable_values["_my_value"], "Just To Test" - ) - - # Check that custom variable values used in rendered command code - command_create_dir_logs = command_logs.filtered( - lambda log: log.command_id == self.command_create_dir - ) - first_command_create_dir_log = command_create_dir_logs[0] - second_command_create_dir_log = command_create_dir_logs[1] - - # the first_command_create_dir_log.code is equal to - # 'cd /test_path && mkdir test_dir' - # because rendered code contains custom variable values updated - # from first python command - self.assertEqual( - first_command_create_dir_log.code, "cd /test_path && mkdir test_dir" - ) - - # Check that custom variable values were updated correctly in command logs - command_python_command_2_log = command_logs.filtered( - lambda log: log.command_id.id == command_python_2_id - ) - self.assertEqual( - command_python_command_2_log.variable_values["test_path_"], - "/another_test_path", - ) - self.assertEqual( - command_python_command_2_log.variable_values["test_dir"], "test_dir" - ) - self.assertEqual( - command_python_command_2_log.variable_values["random_var_reference"], - "random_var_value", - ) - self.assertEqual( - command_python_command_2_log.variable_values["_my_value"], "Just To Test" - ) - self.assertEqual( - command_python_command_2_log.variable_values[self.variable_url.reference], - "https://www.cetmix.com", - ) - - # the second_command_create_dir_log.code is equal to - # 'cd /another_test_path && mkdir test_dir' - # because rendered code contains custom variable values updated - # from second python command - self.assertEqual( - second_command_create_dir_log.code, - "cd /another_test_path && mkdir test_dir", - ) - - def test_plan_custom_variables_wizard(self): - """Test plan with custom variables from wizard""" - command_python_1_id = self.command_python_custom_variable_values_1.id - command_python_2_id = self.command_python_custom_variable_values_2.id - plan = self._create_plan( - **{ - "name": "Plan with custom variables", - "line_ids": [ - ( - 0, - 0, - { - "command_id": command_python_1_id, - "sequence": 1, - }, - ), - (0, 0, {"command_id": self.command_create_dir.id, "sequence": 2}), - ( - 0, - 0, - { - "command_id": command_python_2_id, - "sequence": 3, - }, - ), - (0, 0, {"command_id": self.command_create_dir.id, "sequence": 4}), - ], - } - ) - - # Create wizard with custom variable values - wizard = self.env["cx.tower.plan.run.wizard"].create( - { - "plan_id": plan.id, - "server_ids": [(6, 0, [self.server_test_1.id])], - "custom_variable_value_ids": [ - ( - 0, - 0, - { - "variable_id": self.variable_version.id, - "value_char": "16.0", - }, - ), - ], - } - ) - - # Run wizard - action = wizard.run_flight_plan() - plan_log = self.PlanLog.search( - [("label", "=", action["context"]["search_default_label"])], - limit=1, - ) - self.assertTrue(plan_log, "Plan log should be created") - - # Check that custom variable values were updated correctly - # (The log of plan should contain the last updated - # values + custom variable value from wizard) - self.assertEqual(plan_log.variable_values["test_path_"], "/another_test_path") - self.assertEqual(plan_log.variable_values["test_dir"], "test_dir") - self.assertEqual( - plan_log.variable_values["random_var_reference"], "random_var_value" - ) - self.assertEqual(plan_log.variable_values["_my_value"], "Just To Test") - self.assertEqual( - plan_log.variable_values[self.variable_version.reference], "16.0" - ) - - def test_plan_with_another_plan_custom_variables(self): - """Test plan with another plan with custom variables""" - # Create plan with next structure: - # Plan 1: - # - Command 1: Run plan 2 - # - Command 2: Run Python command to set custom variable values - # - Command 3: Create directory - # Plan 2: - # - Command 1: Python command to set custom variable values - # - Command 2: Create directory - # - Command 3: Python command to update custom variable values - - command_python_1_id = self.command_python_custom_variable_values_1.id - command_python_2_id = self.command_python_custom_variable_values_2.id - plan2 = self._create_plan( - **{ - "name": "Plan 2", - "line_ids": [ - ( - 0, - 0, - { - "command_id": command_python_1_id, - "sequence": 1, - }, - ), - (0, 0, {"command_id": self.command_create_dir.id, "sequence": 2}), - ( - 0, - 0, - { - "command_id": command_python_2_id, - "sequence": 3, - }, - ), - ], - } - ) - - command_run_plan_2 = self.Command.create( - { - "name": "Run Flight Plan", - "action": "plan", - "flight_plan_id": plan2.id, - } - ) - command_python_custom_variable_values_3 = self.Command.create( - { - "name": "Python command to update custom variable values", - "action": "python_code", - "code": """ -custom_values['random_var_reference'] = 'another_random_var_value' -""", - } - ) - - plan1 = self._create_plan( - **{ - "name": "Plan 1", - "line_ids": [ - (0, 0, {"command_id": command_run_plan_2.id, "sequence": 1}), - ( - 0, - 0, - { - "command_id": command_python_custom_variable_values_3.id, - "sequence": 2, - }, - ), - (0, 0, {"command_id": self.command_create_dir.id, "sequence": 3}), - ], - } - ) - - # Create wizard with custom variable values - wizard = self.env["cx.tower.plan.run.wizard"].create( - { - "plan_id": plan1.id, - "server_ids": [(6, 0, [self.server_test_1.id])], - "custom_variable_value_ids": [ - ( - 0, - 0, - { - "variable_id": self.variable_version.id, - "value_char": "16.0", - }, - ), - ( - 0, - 0, - { - "variable_id": self.variable_url.id, - "value_char": "https://www.test.com", - }, - ), - ], - } - ) - - # Run wizard - action = wizard.run_flight_plan() - plan_log = self.PlanLog.search( - [("label", "=", action["context"]["search_default_label"])], - limit=1, - ) - self.assertTrue(plan_log, "Plan log should be created") - - command_logs = plan_log.command_log_ids - self.assertEqual( - len(command_logs), - len(plan1.line_ids), - f"Should be {len(plan1.line_ids)} command logs.", - ) - - # First command log is run plan 2 log that contains custom variable values - # updated from plan 2. This command log should contain the same custom - # variable values as from plan 2 log - run_plan2_command_log = command_logs[0] - run_plan2_command_log_variable_values = run_plan2_command_log.variable_values - - plan2_log = run_plan2_command_log.triggered_plan_log_id - plan2_log_variable_values = plan2_log.variable_values - - # check that variable values are the same - self.assertEqual( - run_plan2_command_log_variable_values, plan2_log_variable_values - ) - - # Before finished command (run child plan): we have next variable values: - # {'test_version': '16.0', 'test_url': 'https://www.test.com'} - - # After finished command (run child plan): we have next variable values: - # { - # 'test_version': '16.0', - # 'test_url': 'https://www.cetmix.com', - # 'test_path_': '/another_test_path', - # 'test_dir': 'test_dir', - # '_my_value': 'Just To Test', - # 'random_var_reference': 'random_var_value' - # } - self.assertEqual( - run_plan2_command_log_variable_values["test_path_"], "/another_test_path" - ) - self.assertEqual(run_plan2_command_log_variable_values["test_dir"], "test_dir") - self.assertEqual( - run_plan2_command_log_variable_values["_my_value"], "Just To Test" - ) - self.assertEqual( - run_plan2_command_log_variable_values["random_var_reference"], - "random_var_value", - ) - self.assertEqual( - run_plan2_command_log_variable_values[self.variable_version.reference], - "16.0", - ) - self.assertEqual( - run_plan2_command_log_variable_values[self.variable_url.reference], - "https://www.cetmix.com", - ) - - # After finished main plan: we have next variable values: - # { - # 'test_version': '16.0', - # 'test_url': 'https://www.cetmix.com', - # 'test_path_': '/another_test_path', - # 'test_dir': 'test_dir', - # '_my_value': 'Just To Test', - # 'random_var_reference': 'another_random_var_value' - # } - self.assertEqual(plan_log.variable_values["test_path_"], "/another_test_path") - self.assertEqual(plan_log.variable_values["test_dir"], "test_dir") - self.assertEqual(plan_log.variable_values["_my_value"], "Just To Test") - self.assertEqual( - plan_log.variable_values["random_var_reference"], "another_random_var_value" - ) - self.assertEqual( - plan_log.variable_values[self.variable_version.reference], "16.0" - ) - self.assertEqual( - plan_log.variable_values[self.variable_url.reference], - "https://www.cetmix.com", - ) - - @mute_logger("odoo.addons.cetmix_tower_server.models.cetmix_tower") - def test_plan_render_jet_template(self): - """Test plan rendering jet template""" - plan_log_record_count = self.PlanLog.search_count( - [("server_id", "=", self.server_test_1.id)] - ) - self.assertEqual(plan_log_record_count, 0, "Plan logs should be empty") - - # Set variable values for the server - res = self.CetmixTower.server_set_variable_value( - self.server_test_1.reference, "test_path_", "/opt/tower" - ) - self.assertEqual(res["exit_code"], 0, "Variable 'test_path_' not found/updated") - res = self.CetmixTower.server_set_variable_value( - self.server_test_1.reference, "test_dir", "server1" - ) - self.assertEqual(res["exit_code"], 0, "Variable 'test_dir' not found/updated") - - # -- 1-- - # Run plan without jet template - self.server_test_1.run_flight_plan(self.plan_2) - - plan_log = self.PlanLog.search( - [ - ("plan_id", "=", self.plan_2.id), - ("server_id", "=", self.server_test_1.id), - ], - ) - self.assertEqual(len(plan_log), 1, "A single plan log should be created") - self.assertEqual( - len(plan_log.command_log_ids), 2, "Two commands should be executed" - ) - self.assertFalse(plan_log.jet_template_id, "Jet template should be empty") - - # Check the SSH command output. Second command - rendered_code_expected = "cd /opt/tower && mkdir server1" - ssh_command_log = plan_log.command_log_ids[1] - self.assertEqual( - ssh_command_log.code, rendered_code_expected, "SSH command should succeed" - ) - - # Check the nested plan command output. - # This is needed to ensure that the nested plan commands - # are rendered properly. - nested_ssh_command_log = plan_log.command_log_ids[ - 0 - ].triggered_plan_log_id.command_log_ids[0] - self.assertEqual( - nested_ssh_command_log.code, - rendered_code_expected, - "SSH command should succeed", - ) - - # -- 2 -- - # Run plan with jet template - - # Delete previous plan log - plan_log.unlink() - - self.server_test_1.run_flight_plan( - self.plan_2, jet_template=self.jet_template_sample - ) - - plan_log = self.PlanLog.search( - [ - ("plan_id", "=", self.plan_2.id), - ("server_id", "=", self.server_test_1.id), - ], - ) - self.assertEqual(len(plan_log), 1, "A single plan log should be created") - self.assertEqual( - len(plan_log.command_log_ids), 2, "Two commands should be executed" - ) - self.assertEqual( - plan_log.jet_template_id, - self.jet_template_sample, - "Jet template doesn't match", - ) - - # Check the SSH command output. Second command - rendered_code_expected = "cd /jets/templates/template1 && mkdir jet_templates" - ssh_command_log = plan_log.command_log_ids[1] - self.assertEqual( - ssh_command_log.code, rendered_code_expected, "SSH command should succeed" - ) - - # Check the nested plan command output. - # This is needed to ensure that the nested plan commands - # are rendered properly. - nested_ssh_command_log = plan_log.command_log_ids[ - 0 - ].triggered_plan_log_id.command_log_ids[0] - self.assertEqual( - nested_ssh_command_log.code, - rendered_code_expected, - "SSH command should succeed", - ) - - # -- 3 -- - # Run plan with jet - # Delete previous plan log - plan_log.unlink() - - self.server_test_1.run_flight_plan(self.plan_2, jet=self.jet_sample) - - plan_log = self.PlanLog.search( - [ - ("plan_id", "=", self.plan_2.id), - ("server_id", "=", self.server_test_1.id), - ], - ) - self.assertEqual(len(plan_log), 1, "A single plan log should be created") - self.assertEqual( - len(plan_log.command_log_ids), 2, "Two commands should be executed" - ) - self.assertEqual( - plan_log.jet_template_id, - self.jet_template_sample, - "Jet template doesn't match", - ) - self.assertEqual(plan_log.jet_id, self.jet_sample, "Jet doesn't match") - - # Check the SSH command output. Second command - rendered_code_expected = "cd /jets/jet1 && mkdir jet_templates" - ssh_command_log = plan_log.command_log_ids[1] - self.assertEqual( - ssh_command_log.code, rendered_code_expected, "SSH command should succeed" - ) - - # Check the nested plan command output. - # This is needed to ensure that the nested plan commands - # are rendered properly. - nested_ssh_command_log = plan_log.command_log_ids[ - 0 - ].triggered_plan_log_id.command_log_ids[0] - self.assertEqual( - nested_ssh_command_log.code, - rendered_code_expected, - "SSH command should succeed", - ) - - def test_plan_with_custom_values_in_condition(self): - """ - Ensure that plan line conditions see updated custom_values - produced by previous commands. - - 1) python sets test_path_ = '/test_path' - 2) create_dir with condition "{{ test_path_ }} == '/test_path'" -> executes - 3) python updates test_path_ = '/another_test_path' - 4) create_dir with condition "{{ test_path_ }} == '/another_test_path'" - -> executes - Then invert conditions and check both lines are skipped appropriately. - """ - command_python_1_id = self.command_python_custom_variable_values_1.id - command_python_2_id = self.command_python_custom_variable_values_2.id - - plan = self._create_plan( - **{ - "name": "Plan with custom_values in condition", - "line_ids": [ - (0, 0, {"command_id": command_python_1_id, "sequence": 1}), - ( - 0, - 0, - { - "command_id": self.command_create_dir.id, - "sequence": 2, - "condition": "{{ test_path_ }} == '/test_path'", - }, - ), - (0, 0, {"command_id": command_python_2_id, "sequence": 3}), - ( - 0, - 0, - { - "command_id": self.command_create_dir.id, - "sequence": 4, - "condition": "{{ test_path_ }} == '/another_test_path'", - }, - ), - ], - } - ) - - plan_log = self.server_test_1.run_flight_plan(plan) - - logs = plan_log.command_log_ids - self.assertEqual(len(logs), 4, "Should be 4 command logs") - - create_dir_logs = logs.filtered( - lambda line: line.command_id == self.command_create_dir - ) - self.assertEqual(len(create_dir_logs), 2, "Should be 2 create_dir logs") - - self.assertFalse( - create_dir_logs[0].is_skipped, "First create_dir must be executed" - ) - self.assertFalse( - create_dir_logs[1].is_skipped, "Second create_dir must be executed" - ) - - self.assertIn("/test_path", create_dir_logs[0].code) - self.assertIn("/another_test_path", create_dir_logs[1].code) - - def test_plan_stop_mid_execution(self): - """ - Test that plan is correctly marked as stopped and - further commands are not executed. - """ - plan = self._create_plan( - name="Test Plan Stop", - line_ids=[ - (0, 0, {"command_id": self.command_create_dir.id, "sequence": 1}), - (0, 0, {"command_id": self.command_list_dir.id, "sequence": 2}), - ], - ) - server = self.server_test_1 - - cx_tower_plan_line_obj = self.registry["cx.tower.plan.line"] - _run_super = cx_tower_plan_line_obj._run - - # Save plan_log for control is_running - plan_log_holder = {} - - def fake_run(self, server, plan_log_record, **kwargs): - # Save plan_log for control is_running - plan_log_holder["log"] = plan_log_record - - # Call stop() after first command - if len(plan_log_record.command_log_ids) == 0: - plan_log_record.stop() - # After this call plan_log should be stopped, - # and finish_date should be filled - # Continue execution in standard way - return _run_super(self, server, plan_log_record, **kwargs) - - with patch.object(cx_tower_plan_line_obj, "_run", new=fake_run): - plan_log = plan._run_single(server) - - self.assertTrue(plan_log.is_stopped, "Plan should be stopped") - self.assertFalse(plan_log.is_running, "Plan should not be in running status") - self.assertEqual( - plan_log.plan_status, PLAN_STOPPED, "Status should be PLAN_STOPPED" - ) - self.assertTrue(plan_log.finish_date, "Finish date should be filled") - self.assertLessEqual( - len(plan_log.command_log_ids), - 1, - "There should be maximum one command in the log", - ) - - def test_flight_plan_reference_update(self): - """Test flight plan reference update cascades to dependent models""" - # 1. Add a variable value to plan_line_1_action_2 - variable_value = self.VariableValue.create( - { - "variable_id": self.variable_os.id, - "value_char": "Ubuntu 20.04", - "plan_line_action_id": self.plan_line_1_action_2.id, - } - ) - - # Store original references for comparison - original_plan_reference = self.plan_1.reference - original_plan_line_1_reference = self.plan_line_1.reference - original_plan_line_2_reference = self.plan_line_2.reference - original_plan_line_1_action_1_reference = self.plan_line_1_action_1.reference - original_plan_line_1_action_2_reference = self.plan_line_1_action_2.reference - original_plan_line_2_action_1_reference = self.plan_line_2_action_1.reference - original_plan_line_2_action_2_reference = self.plan_line_2_action_2.reference - original_variable_value_reference = variable_value.reference - - # 2. Change the reference for plan_1 to "nice_new_plan" - self.plan_1.write({"reference": "nice_new_plan"}) - - # 3. Verify that references are updated for plan lines - # Invalidate models to refresh all references - self.env["cx.tower.plan"].invalidate_model(["reference"]) - self.env["cx.tower.plan.line"].invalidate_model(["reference"]) - self.env["cx.tower.plan.line.action"].invalidate_model(["reference"]) - self.env["cx.tower.variable.value"].invalidate_model(["reference"]) - - # Check that plan reference was updated - self.assertEqual(self.plan_1.reference, "nice_new_plan") - self.assertNotEqual(self.plan_1.reference, original_plan_reference) - - # Check that plan line references were updated to include the new plan reference - self.assertIn("nice_new_plan", self.plan_line_1.reference) - self.assertIn("nice_new_plan", self.plan_line_2.reference) - self.assertNotEqual(self.plan_line_1.reference, original_plan_line_1_reference) - self.assertNotEqual(self.plan_line_2.reference, original_plan_line_2_reference) - - # Check that plan line action references were updated - self.assertIn("nice_new_plan", self.plan_line_1_action_1.reference) - self.assertIn("nice_new_plan", self.plan_line_1_action_2.reference) - self.assertIn("nice_new_plan", self.plan_line_2_action_1.reference) - self.assertIn("nice_new_plan", self.plan_line_2_action_2.reference) - self.assertNotEqual( - self.plan_line_1_action_1.reference, original_plan_line_1_action_1_reference - ) - self.assertNotEqual( - self.plan_line_1_action_2.reference, original_plan_line_1_action_2_reference - ) - self.assertNotEqual( - self.plan_line_2_action_1.reference, original_plan_line_2_action_1_reference - ) - self.assertNotEqual( - self.plan_line_2_action_2.reference, original_plan_line_2_action_2_reference - ) - - # Check that variable value reference was updated - # to include the new plan reference - self.assertIn("nice_new_plan", variable_value.reference) - self.assertNotEqual(variable_value.reference, original_variable_value_reference) - - # Verify the reference pattern for variable value follows the expected format: - # ___ # noqa: E501 - expected_pattern = ( - f"{self.variable_os.reference}_variable_value_plan_line_action_" - f"{self.plan_line_1_action_2.reference}" - ) - self.assertEqual(variable_value.reference, expected_pattern) - - def test_flight_plan_with_child_plan_command_exception(self): - """ - Test flight plan with child plan where command exception occurs. - - Scenario: - - Main flight plan has 2 commands: - 1. Simple python command (success) - 2. Child flight plan with 2 commands where first fails with command exception - - Verify error propagation: command -> child plan -> main plan - - The command exception is simulated using the existing mocking system - that raises exceptions when commands contain "raise" - """ - - # Create child flight plan with 2 commands - child_plan = self.Plan.create( - { - "name": "Child Plan with Error", - "note": "Child plan that will fail on first command", - } - ) - - # Command 1 of child plan - will fail with command exception - child_command_1 = self.Command.create( - { - "name": "Child Command 1 - Command Exception", - "action": "ssh_command", - "code": "raise", # This will trigger command exception in mock - } - ) - - # Command 2 of child plan - should not execute due to error in command 1 - child_command_2 = self.Command.create( - { - "name": "Child Command 2 - Should Not Run", - "action": "python_code", - "code": """ -result = { - "exit_code": 0, - "message": "This should not execute" -} - """, - } - ) - - # Create plan lines for child plan - self.plan_line.create( - { - "sequence": 10, - "plan_id": child_plan.id, - "command_id": child_command_1.id, - } - ) - self.plan_line.create( - { - "sequence": 20, - "plan_id": child_plan.id, - "command_id": child_command_2.id, - } - ) - - # Create command to run child plan - run_child_plan_command = self.Command.create( - { - "name": "Run Child Plan", - "action": "plan", - "flight_plan_id": child_plan.id, - } - ) - - # Create main flight plan with 2 commands - main_plan = self.Plan.create( - { - "name": "Main Plan with Child Plan", - "note": "Main plan with python command and child plan", - } - ) - - # Command 1 of main plan - simple python command (should succeed) - main_command_1 = self.Command.create( - { - "name": "Main Command 1 - Python Success", - "action": "python_code", - "code": """ -result = { - "exit_code": 0, - "message": "Main plan python command executed successfully" -} - """, - } - ) - - # Command 2 of main plan - run child plan (will fail) - main_command_2 = run_child_plan_command - - # Create plan lines for main plan - self.plan_line.create( - { - "sequence": 10, - "plan_id": main_plan.id, - "command_id": main_command_1.id, - } - ) - self.plan_line.create( - { - "sequence": 20, - "plan_id": main_plan.id, - "command_id": main_command_2.id, - } - ) - # Run the first command again - self.plan_line.create( - { - "sequence": 30, - "plan_id": main_plan.id, - "command_id": main_command_1.id, - } - ) - - # Run the main flight plan - plan_log = self.server_test_1.run_flight_plan(main_plan) - - # Verify main plan finished with error - self.assertNotEqual( - plan_log.plan_status, 0, "Main plan should not finish successfully" - ) - - # Get all plan logs for verification - all_plan_logs = plan_log | self.PlanLog.search( - [("parent_flight_plan_log_id", "=", plan_log.id)] - ) - - # Should have 2 plan logs: main plan and child plan - self.assertEqual( - len(all_plan_logs), 2, "Should have 2 plan logs: main and child" - ) - - main_plan_log = all_plan_logs.filtered(lambda log: log.plan_id == main_plan) - child_plan_log = all_plan_logs.filtered( - lambda log: log.parent_flight_plan_log_id == main_plan_log - ) - - self.assertTrue(main_plan_log, "Main plan log should exist") - self.assertTrue(child_plan_log, "Child plan log should exist") - - # Verify child plan finished with error - # The child plan should finish with an error - # (either SSH_CONNECTION_ERROR or GENERAL_ERROR) - self.assertNotEqual( - child_plan_log.plan_status, - 0, - "Child plan should not finish successfully", - ) - - # Get command logs for verification - all_command_logs = self.CommandLog.search( - [("plan_log_id", "in", all_plan_logs.ids)] - ) - - # Should have 3 command logs: main python, - # run child plan, child command exception - self.assertEqual(len(all_command_logs), 3, "Should have 3 command logs") - - # Find specific command logs - main_python_log = all_command_logs.filtered( - lambda log: log.command_id == main_command_1 - ) - run_child_plan_log = all_command_logs.filtered( - lambda log: log.command_id == main_command_2 - ) - child_ssh_error_log = all_command_logs.filtered( - lambda log: log.command_id == child_command_1 - ) - - # Verify main python command succeeded - self.assertEqual( - main_python_log.command_status, 0, "Main python command should succeed" - ) - self.assertEqual( - main_python_log.command_response, - "Main plan python command executed successfully", - "Main python command should have correct response", - ) - - # Verify run child plan command failed - # The command should fail with an error - # (either SSH_CONNECTION_ERROR or GENERAL_ERROR) - self.assertNotEqual( - run_child_plan_log.command_status, - 0, - "Run child plan command should fail", - ) - - # Verify child SSH command failed - # The SSH command should fail with an error status - # (could be GENERAL_ERROR -100 or 255 depending on how the exception is handled) - self.assertNotEqual( - child_ssh_error_log.command_status, 0, "Child SSH command should fail" - ) - # The error message should contain information about - # the SSH connection failure - # The exact error message may vary depending - # on how the exception is handled - self.assertTrue( - child_ssh_error_log.command_error, - "Child SSH command should have an error message", - ) - - # Verify that child command 2 was not executed (no log for it) - child_command_2_log = all_command_logs.filtered( - lambda log: log.command_id == child_command_2 - ) - self.assertFalse( - child_command_2_log, "Child command 2 should not have been executed" - ) - - # Verify plan log relationships - self.assertEqual( - main_plan_log.command_log_ids, - main_python_log | run_child_plan_log, - "Main plan should have correct command logs", - ) - - self.assertEqual( - child_plan_log.command_log_ids, - child_ssh_error_log, - "Child plan should have only the failed command log", - ) - - # Verify that the error propagated correctly through the hierarchy - # The error should propagate from command -> child plan -> main plan - # The specific error codes may vary depending - # on how the system handles the error - self.assertNotEqual( - main_plan_log.plan_status, - 0, - "Error should propagate from child to main plan", - ) - self.assertNotEqual( - child_plan_log.plan_status, 0, "Error should be present in child plan" - ) - self.assertNotEqual( - child_ssh_error_log.command_status, - 0, - "SSH command should have an error status", - ) - self.assertEqual( - child_ssh_error_log.command_status, - child_plan_log.plan_status, - "Child plan should have the same error status as the SSH command", - ) - self.assertEqual( - child_ssh_error_log.command_status, - main_plan_log.plan_status, - "Main plan should have the same error status as the SSH command", - ) - - def test_skip_command_error_flow(self): - """Plan flow: - 1) success, 2) success, 3) error -> sets command_error variable, - 4) skipped if not var, 5) runs if var and exits -1. - """ - # Create commands - command_success = self.Command.create( - { - "name": "Command -> Success", - "action": "python_code", - "code": "# Just return default values", - } - ) - command_error = self.Command.create( - { - "name": "Command -> Error", - "action": "python_code", - "code": "result = {'exit_code': -100, 'message': 'Error'}", - } - ) - command_after_failed = self.Command.create( - { - "name": "Command -> After failed", - "action": "python_code", - "code": ( - "name = server.name + ' --after-failed-- '\n" - "server.write({'name': name})" - ), - } - ) - command_last_one = self.Command.create( - { - "name": "Command -> The last one", - "action": "python_code", - "code": ( - "name = server.name + ' --last-one-- '\n" - "server.write({'name': name})" - ), - } - ) - - # Variable used in conditions - variable_command_error = self.Variable.create( - { - "name": "command_error", - "reference": "test_command_error", - "variable_type": "s", - } - ) - - # Plan and lines - plan = self.Plan.create( - { - "name": "Test skip command error", - "on_error_action": "e", - "custom_exit_code": 0, - } - ) - - self.plan_line.create( - {"sequence": 10, "plan_id": plan.id, "command_id": command_success.id} - ) - self.plan_line.create( - {"sequence": 20, "plan_id": plan.id, "command_id": command_success.id} - ) - - line3 = self.plan_line.create( - {"sequence": 30, "plan_id": plan.id, "command_id": command_error.id} - ) - action3 = self.plan_line_action.create( - { - "line_id": line3.id, - "sequence": 10, - "condition": "!=", - "value_char": "0", - "action": "n", - } - ) - - self.VariableValue.create( - { - "variable_id": variable_command_error.id, - "value_char": "1", - "plan_line_action_id": action3.id, - } - ) - - self.plan_line.create( - { - "sequence": 40, - "plan_id": plan.id, - "command_id": command_after_failed.id, - "condition": "not {{ test_command_error }}", - "variable_ids": [(6, 0, [variable_command_error.id])], - } - ) - - line5 = self.plan_line.create( - { - "sequence": 50, - "plan_id": plan.id, - "command_id": command_last_one.id, - "condition": "{{ test_command_error }}", - "variable_ids": [(6, 0, [variable_command_error.id])], - } - ) - self.plan_line_action.create( - { - "line_id": line5.id, - "sequence": 10, - "condition": "==", - "value_char": "0", - "action": "ec", - "custom_exit_code": -1, - } - ) - - plan_log = self.server_test_1.run_flight_plan(plan) - - self.assertEqual(len(plan_log.command_log_ids), 5) - logs = plan_log.command_log_ids - self.assertTrue( - all( - log.command_status == 0 - for log in logs.filtered(lambda log: log.command_id == command_success) - ) - ) - - error_log = logs.filtered(lambda log: log.command_id == command_error) - self.assertIn(variable_command_error.reference, error_log.variable_values) - self.assertTrue(error_log.command_status == GENERAL_ERROR) - - self.assertTrue( - logs.filtered(lambda log: log.command_id == command_after_failed).mapped( - "command_status" - )[0] - == PLAN_LINE_CONDITION_CHECK_FAILED - ) - self.assertTrue( - logs.filtered(lambda log: log.command_id == command_last_one).mapped( - "command_status" - )[0] - == 0 - ) - - # Final plan status must be custom exit code -1 from line 5 action - self.assertEqual(plan_log.plan_status, -1) - - def test_plan_line_condition_error(self): - """Test plan line condition error - First line is skipped because of condition error - Second line is executed successfully - """ - # Create commands - command_success = self.Command.create( - { - "name": "Command -> Success", - "action": "python_code", - "code": "# Just return default values", - } - ) - - # Plan and lines - plan = self.Plan.create( - { - "name": "Test plan line condition error", - } - ) - - self.plan_line.create( - { - "sequence": 10, - "plan_id": plan.id, - "command_id": command_success.id, - "condition": "=q", - }, - ) - self.plan_line.create( - {"sequence": 20, "plan_id": plan.id, "command_id": command_success.id} - ) - - with mute_logger("odoo.addons.cetmix_tower_server.models.cx_tower_plan_line"): - plan_log = self.server_test_1.run_flight_plan(plan) - - # Must be 2 command logs - self.assertEqual(len(plan_log.command_log_ids), 2) - logs = plan_log.command_log_ids - self.assertTrue(logs[0].is_skipped) - self.assertTrue(logs[1].command_status == 0) - - def test_custom_values_not_defined_but_updated(self): - """Test custom values not defined but updated - First command is executed successfully - Second command is executed successfully and updates custom values - """ - # Create commands - command_1 = self.Command.create( - { - "name": "Command -> Success", - "action": "python_code", - "code": "# Just return default values", - } - ) - command_2 = self.Command.create( - { - "name": "Command -> Success", - "action": "python_code", - "code": "custom_values.update({'some_value': '1'})", - } - ) - - # Plan and lines - plan = self.Plan.create( - { - "name": "Test custom values not defined but updated", - } - ) - - self.plan_line.create( - { - "sequence": 10, - "plan_id": plan.id, - "command_id": command_1.id, - }, - ) - - self.plan_line.create( - { - "sequence": 20, - "plan_id": plan.id, - "command_id": command_2.id, - }, - ) - plan_log = self.server_test_1.run_flight_plan(plan) - - # Must be 2 command logs - self.assertEqual(len(plan_log.command_log_ids), 2) - logs = plan_log.command_log_ids - # Both commands should be successful - self.assertEqual(logs[0].command_status, 0) - self.assertEqual(logs[1].command_status, 0) - # Custom values should be updated - self.assertEqual(plan_log.variable_values, {"some_value": "1"}) - - def test_last_flight_plan_line_post_run_action_is_executed(self): - """ - Test last flight plan line post run action is executed - """ - # Create commands - command_error = self.Command.create( - { - "name": "Command -> Error", - "action": "python_code", - "code": "result = {'exit_code': -100, 'message': 'Error'}", - } - ) - - # Plan and lines - plan = self.Plan.create( - { - "name": "Test post run action", - "on_error_action": "e", - "custom_exit_code": 0, - } - ) - - line1 = self.plan_line.create( - {"sequence": 10, "plan_id": plan.id, "command_id": command_error.id} - ) - self.plan_line_action.create( - { - "line_id": line1.id, - "sequence": 10, - "condition": "!=", - "value_char": "0", - "action": "n", - } - ) - line2 = self.plan_line.create( - {"sequence": 20, "plan_id": plan.id, "command_id": command_error.id} - ) - self.plan_line_action.create( - { - "line_id": line2.id, - "sequence": 10, - "condition": "!=", - "value_char": "0", - "action": "ec", - "custom_exit_code": 0, - } - ) - - plan_log = self.server_test_1.run_flight_plan(plan) - - self.assertEqual(len(plan_log.command_log_ids), 2) - - # Final plan status must be custom exit code 0 - self.assertEqual(plan_log.plan_status, 0) diff --git a/addons/cetmix_tower_server/tests/test_plan_line.py b/addons/cetmix_tower_server/tests/test_plan_line.py deleted file mode 100644 index 7025a85..0000000 --- a/addons/cetmix_tower_server/tests/test_plan_line.py +++ /dev/null @@ -1,540 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common import TestTowerCommon - - -class TestTowerPlanLine(TestTowerCommon): - """Test the cx.tower.plan.line model access rights.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create a test plan with access level 1 for user tests - cls.test_plan = cls.Plan.create( - { - "name": "Test Access Plan", - "access_level": "1", - "user_ids": [(6, 0, [cls.user.id])], - "manager_ids": [(6, 0, [cls.manager.id])], - } - ) - - # Create a test plan line - cls.test_line = cls.plan_line.create( - { - "plan_id": cls.test_plan.id, - "command_id": cls.command_create_dir.id, - "sequence": 10, - } - ) - - # Create additional servers for testing server-based access - cls.server_2 = cls.Server.create( - { - "name": "Test Server 2", - "ip_v4_address": "localhost", - "ssh_username": "test2", - "ssh_password": "test2", - "ssh_port": 22, - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - } - ) - - cls.server_3 = cls.Server.create( - { - "name": "Test Server 3", - "ip_v4_address": "localhost", - "ssh_username": "test3", - "ssh_password": "test3", - "ssh_port": 22, - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - } - ) - - def test_user_read_access(self): - """Test user read access to plan lines""" - # Case 1: User should be able to read line when: - # - access_level == "1" - # - user is in plan's user_ids OR server's user_ids - recs = self.plan_line.with_user(self.user).search( - [("id", "=", self.test_line.id)] - ) - self.assertIn( - self.test_line, - recs, - "User should be able to read line when conditions are met", - ) - - # Case 2: User should not be able to read when access_level > "1" - self.test_plan.write( - { - "access_level": "2", - } - ) - recs = self.plan_line.with_user(self.user).search( - [("id", "=", self.test_line.id)] - ) - self.assertNotIn( - self.test_line, - recs, - "User should not be able to read line when access_level > '1'", - ) - - # Case 3: User should be able to read when in server's user_ids - self.test_plan.write( - { - "access_level": "1", - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - self.server_test_1.write( - { - "user_ids": [(6, 0, [self.user.id])], - } - ) - recs = self.plan_line.with_user(self.user).search( - [("id", "=", self.test_line.id)] - ) - self.assertIn( - self.test_line, - recs, - "User should be able to read line when in server's user_ids", - ) - - def test_user_write_create_unlink_access(self): - """Test user write/create/unlink access restrictions""" - # Users should not be able to create lines - with self.assertRaises(AccessError): - self.plan_line.with_user(self.user).create( - { - "plan_id": self.test_plan.id, - "command_id": self.command_create_dir.id, - "sequence": 20, - } - ) - - # Users should not be able to write lines - with self.assertRaises(AccessError): - self.test_line.with_user(self.user).write({"sequence": 30}) - - # Users should not be able to unlink lines - with self.assertRaises(AccessError): - self.test_line.with_user(self.user).unlink() - - def test_manager_read_access(self): - """Test manager read access to plan lines""" - # Case 1: Manager should be able to read when: - # - access_level <= "2" - # - manager is in plan's manager_ids OR user_ids - recs = self.plan_line.with_user(self.manager).search( - [("id", "=", self.test_line.id)] - ) - self.assertIn( - self.test_line, - recs, - "Manager should be able to read line when conditions are met", - ) - - # Case 2: Manager should not be able to read when access_level > "2" - self.test_plan.write( - { - "access_level": "3", - "manager_ids": [(5, 0, 0)], # Remove all managers - } - ) - recs = self.plan_line.with_user(self.manager).search( - [("id", "=", self.test_line.id)] - ) - self.assertNotIn( - self.test_line, - recs, - "Manager should not be able to read line when access_level > '2'", - ) - - # Case 2.5: Manager not not be able to read when not in plan managers - self.test_plan.write( - { - "access_level": "2", - "manager_ids": [(5, 0, 0)], # Remove all managers - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - self.server_test_1.write( - { - "user_ids": [(5, 0, 0)], # Remove all users - "manager_ids": [(5, 0, 0)], # Remove all managers - } - ) - recs = self.plan_line.with_user(self.manager).search( - [("id", "=", self.test_line.id)] - ) - self.assertNotIn( - self.test_line, - recs, - "Manager should not be able to read line when access_level > '2'", - ) - - # Case 3: Manager should be able to read when in server's manager_ids - self.test_plan.write( - { - "access_level": "2", - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - self.server_test_1.write( - { - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - recs = self.plan_line.with_user(self.manager).search( - [("id", "=", self.test_line.id)] - ) - self.assertIn( - self.test_line, - recs, - "Manager should be able to read line when in server's manager_ids", - ) - - def test_manager_write_create_access(self): - """Test manager write/create access to plan lines""" - # Case 1: Manager should be able to create/write when: - # - access_level <= "2" - # - manager is in plan's manager_ids - try: - # Test create - self.plan_line.with_user(self.manager).create( - { - "plan_id": self.test_plan.id, - "command_id": self.command_create_dir.id, - "sequence": 20, - } - ) - # Test write - self.test_line.with_user(self.manager).write({"sequence": 30}) - except AccessError: - self.fail("Manager should be able to create/write when conditions are met") - - # Case 2: Manager should not be able to create/write when access_level > "2" - self.test_plan.write( - { - "access_level": "3", - } - ) - with self.assertRaises(AccessError): - self.plan_line.with_user(self.manager).create( - { - "plan_id": self.test_plan.id, - "command_id": self.command_create_dir.id, - "sequence": 40, - } - ) - with self.assertRaises(AccessError): - self.test_line.with_user(self.manager).write({"sequence": 50}) - - def test_manager_unlink_access(self): - """Test manager unlink access to plan lines""" - # Create line as manager to test unlink rights - line = self.plan_line.with_user(self.manager).create( - { - "plan_id": self.test_plan.id, - "command_id": self.command_create_dir.id, - "sequence": 20, - } - ) - - # Case 1: Manager should be able to unlink when: - # - access_level <= "2" - # - manager is the creator - # - manager is in plan's manager_ids - try: - line.unlink() - except AccessError: - self.fail("Manager should be able to unlink when conditions are met") - - # Case 2: Manager should not be able to unlink lines created by others - line = self.test_line # Created by admin in setUp - with self.assertRaises(AccessError): - line.with_user(self.manager).unlink() - - def test_root_unrestricted_read_access(self): - """Test root user unrestricted read access""" - # Set most restrictive conditions - self.test_plan.write( - { - "access_level": "3", - "user_ids": [(5, 0, 0)], - "manager_ids": [(5, 0, 0)], - "server_ids": [(6, 0, [self.server_2.id, self.server_3.id])], - } - ) - - # Root should still be able to read - recs = self.plan_line.with_user(self.root).search( - [("id", "=", self.test_line.id)] - ) - self.assertIn( - self.test_line, - recs, - "Root should be able to read regardless of access restrictions", - ) - - # Root should be able to read all records - all_recs = self.plan_line.with_user(self.root).search([]) - self.assertIn( - self.test_line, - all_recs, - "Root should be able to read all records", - ) - - def test_root_unrestricted_write_access(self): - """Test root user unrestricted write access""" - # Set most restrictive conditions - self.test_plan.write( - { - "access_level": "3", - "user_ids": [(5, 0, 0)], - "manager_ids": [(5, 0, 0)], - "server_ids": [(6, 0, [self.server_2.id, self.server_3.id])], - } - ) - - try: - # Test single field update - self.test_line.with_user(self.root).write({"sequence": 100}) - - # Test multiple field update - self.test_line.with_user(self.root).write( - { - "sequence": 200, - "path": "/test/path", - "use_sudo": True, - } - ) - except AccessError: - self.fail("Root should be able to write regardless of access restrictions") - - def test_root_unrestricted_create_access(self): - """Test root user unrestricted create access""" - # Set most restrictive conditions - self.test_plan.write( - { - "access_level": "3", - "user_ids": [(5, 0, 0)], - "manager_ids": [(5, 0, 0)], - "server_ids": [(6, 0, [self.server_2.id, self.server_3.id])], - } - ) - - try: - # Test create with minimal values - new_line_1 = self.plan_line.with_user(self.root).create( - { - "plan_id": self.test_plan.id, - "command_id": self.command_create_dir.id, - } - ) - - # Test create with all values - new_line_2 = self.plan_line.with_user(self.root).create( - { - "plan_id": self.test_plan.id, - "command_id": self.command_create_dir.id, - "sequence": 300, - "path": "/another/test/path", - "use_sudo": True, - "condition": "{{ test_condition }}", - } - ) - - # Verify created records are readable - recs = self.plan_line.with_user(self.root).search( - [("id", "in", [new_line_1.id, new_line_2.id])] - ) - self.assertEqual( - len(recs), - 2, - "Root should be able to read newly created records", - ) - except AccessError: - self.fail("Root should be able to create regardless of access restrictions") - - def test_root_unrestricted_unlink_access(self): - """Test root user unrestricted unlink access""" - # Set most restrictive conditions - self.test_plan.write( - { - "access_level": "3", - "user_ids": [(5, 0, 0)], - "manager_ids": [(5, 0, 0)], - "server_ids": [(6, 0, [self.server_2.id, self.server_3.id])], - } - ) - - # Create test records - test_lines = self.plan_line.with_user(self.root).create( - [ - { - "plan_id": self.test_plan.id, - "command_id": self.command_create_dir.id, - "sequence": seq, - } - for seq in range(400, 403) - ] - ) - - try: - # Test single record unlink - test_lines[0].with_user(self.root).unlink() - - # Test multiple record unlink - test_lines[1:].with_user(self.root).unlink() - - # Verify records are deleted - recs = self.plan_line.with_user(self.root).search( - [("id", "in", test_lines.ids)] - ) - self.assertEqual( - len(recs), - 0, - "Root should be able to delete records completely", - ) - except AccessError: - self.fail("Root should be able to unlink regardless of access restrictions") - - def test_manager_server_based_read_access(self): - """Test manager read access based on server relationships""" - # Remove direct manager access from plan - self.test_plan.write( - { - "manager_ids": [(5, 0, 0)], # Clear manager_ids - "access_level": "2", - } - ) - - # Case 1: No servers linked - should have access - recs = self.plan_line.with_user(self.manager).search( - [("id", "=", self.test_line.id)] - ) - self.assertIn( - self.test_line, - recs, - "Manager should be able to read when no servers are linked", - ) - - # Case 2: Server linked but manager not in server's users/managers - self.test_plan.write( - { - "server_ids": [(6, 0, [self.server_2.id])], - } - ) - recs = self.plan_line.with_user(self.manager).search( - [("id", "=", self.test_line.id)] - ) - self.assertNotIn( - self.test_line, - recs, - "Manager should not be able to read when not in server's users/managers", - ) - - # Case 3: Manager in server's user_ids - self.server_2.write( - { - "user_ids": [(6, 0, [self.manager.id])], - } - ) - recs = self.plan_line.with_user(self.manager).search( - [("id", "=", self.test_line.id)] - ) - self.assertIn( - self.test_line, - recs, - "Manager should be able to read when in server's user_ids", - ) - - # Case 4: Manager in server's manager_ids - self.server_2.write( - { - "user_ids": [(5, 0, 0)], - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - recs = self.plan_line.with_user(self.manager).search( - [("id", "=", self.test_line.id)] - ) - self.assertIn( - self.test_line, - recs, - "Manager should be able to read when in server's manager_ids", - ) - - # Case 5: Multiple servers - access through one server - self.test_plan.write( - { - "server_ids": [(6, 0, [self.server_2.id, self.server_3.id])], - } - ) - recs = self.plan_line.with_user(self.manager).search( - [("id", "=", self.test_line.id)] - ) - self.assertIn( - self.test_line, - recs, - "Manager should be able to read when in at least one server's manager_ids", - ) - - # Case 6: Multiple servers - no access - self.server_2.write( - { - "manager_ids": [(5, 0, 0)], - } - ) - recs = self.plan_line.with_user(self.manager).search( - [("id", "=", self.test_line.id)] - ) - self.assertNotIn( - self.test_line, - recs, - "Manager should not be able to read when not " - "in any server's users/managers", - ) - - def test_manager_server_based_write_access(self): - """Test manager write access based on server relationships""" - # Remove direct manager access from plan - self.test_plan.write( - { - "manager_ids": [(5, 0, 0)], # Clear manager_ids - "access_level": "2", - "server_ids": [(6, 0, [self.server_2.id])], - } - ) - - # Case 1: No server access - should not be able to write - with self.assertRaises(AccessError): - self.test_line.with_user(self.manager).write({"sequence": 40}) - - # Case 2: Manager in server's manager_ids - still should not be able to write - self.server_2.write( - { - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - with self.assertRaises(AccessError): - self.test_line.with_user(self.manager).write({"sequence": 50}) - - # Case 3: Manager in plan's manager_ids - should be able to write - self.test_plan.write( - { - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - try: - self.test_line.with_user(self.manager).write({"sequence": 60}) - except AccessError: - self.fail("Manager should be able to write when in plan's manager_ids") diff --git a/addons/cetmix_tower_server/tests/test_plan_line_action.py b/addons/cetmix_tower_server/tests/test_plan_line_action.py deleted file mode 100644 index ddd694d..0000000 --- a/addons/cetmix_tower_server/tests/test_plan_line_action.py +++ /dev/null @@ -1,255 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common import TestTowerCommon - - -class TestTowerPlanLineAction(TestTowerCommon): - """Test the cx.tower.plan.line.action model access rights.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create a test server - cls.server = cls.Server.create( - { - "name": "Test Server", - "ip_v4_address": "localhost", - "ssh_username": "test", - "ssh_password": "test", - "ssh_port": 22, - "user_ids": [(6, 0, [cls.user.id])], - "manager_ids": [(6, 0, [cls.manager.id])], - } - ) - - # Create a test plan with access level 1 for user tests - cls.test_plan = cls.Plan.create( - { - "name": "Test Access Plan", - "access_level": "1", - "user_ids": [(6, 0, [cls.user.id])], - "manager_ids": [(6, 0, [cls.manager.id])], - } - ) - - # Create a test plan line - cls.test_plan_line = cls.plan_line.create( - { - "plan_id": cls.test_plan.id, - "command_id": cls.command_create_dir.id, - "sequence": 10, - } - ) - - # Create a test action - cls.test_action = cls.plan_line_action.create( - { - "line_id": cls.test_plan_line.id, - "condition": "==", - "value_char": "0", - "action": "n", - } - ) - - def test_user_read_access(self): - """Test user read access to plan line actions""" - # Case 1: User should be able to read action when: - # - access_level == "1" - # - user is in plan's user_ids OR server's user_ids - recs = self.plan_line_action.with_user(self.user).search( - [("id", "=", self.test_action.id)] - ) - self.assertIn( - self.test_action, - recs, - "User should be able to read action when conditions are met", - ) - - # Case 2: User should not be able to read when access_level > "1" - self.test_plan.access_level = "2" - recs = self.plan_line_action.with_user(self.user).search( - [("id", "=", self.test_action.id)] - ) - self.assertNotIn( - self.test_action, - recs, - "User should not be able to read action when access_level > '1'", - ) - - # Case 3: User should not be able to read when not in user_ids - self.test_plan.access_level = "1" - self.test_plan.user_ids = [(5, 0, 0)] # Remove all users - recs = self.plan_line_action.with_user(self.user).search( - [("id", "=", self.test_action.id)] - ) - self.assertNotIn( - self.test_action, - recs, - "User should not be able to read action when not in user_ids", - ) - - # Case 4: User should be able to read when in server's user_ids - self.test_plan.server_ids = [(6, 0, [self.server.id])] - recs = self.plan_line_action.with_user(self.user).search( - [("id", "=", self.test_action.id)] - ) - self.assertIn( - self.test_action, - recs, - "User should be able to read action when in server's user_ids", - ) - - def test_user_write_create_unlink_access(self): - """Test user write/create/unlink access restrictions""" - # Users should not be able to create actions - with self.assertRaises(AccessError): - self.plan_line_action.with_user(self.user).create( - { - "line_id": self.test_plan_line.id, - "condition": "==", - "value_char": "0", - "action": "n", - } - ) - - # Users should not be able to write actions - with self.assertRaises(AccessError): - self.test_action.with_user(self.user).write({"value_char": "1"}) - - # Users should not be able to unlink actions - with self.assertRaises(AccessError): - self.test_action.with_user(self.user).unlink() - - def test_manager_read_access(self): - """Test manager read access to plan line actions""" - # Case 1: Manager should be able to read when: - # - access_level <= "2" - # - manager is in plan's manager_ids - recs = self.plan_line_action.with_user(self.manager).search( - [("id", "=", self.test_action.id)] - ) - self.assertIn( - self.test_action, - recs, - "Manager should be able to read action when conditions are met", - ) - - # Case 2: Manager should not be able to read when access_level > "2" - self.test_plan.access_level = "3" - recs = self.plan_line_action.with_user(self.manager).search( - [("id", "=", self.test_action.id)] - ) - self.assertNotIn( - self.test_action, - recs, - "Manager should not be able to read action when access_level > '2'", - ) - - # Case 3: Manager should be able to read when in server's manager_ids - self.test_plan.access_level = "2" - self.test_plan.manager_ids = [(5, 0, 0)] # Remove all managers - self.test_plan.server_ids = [(6, 0, [self.server.id])] - recs = self.plan_line_action.with_user(self.manager).search( - [("id", "=", self.test_action.id)] - ) - self.assertIn( - self.test_action, - recs, - "Manager should be able to read when in server's manager_ids", - ) - - def test_manager_write_create_access(self): - """Test manager write/create access to plan line actions""" - # Case 1: Manager should be able to create/write when: - # - access_level <= "2" - # - manager is in plan's manager_ids - try: - # Test create - self.plan_line_action.with_user(self.manager).create( - { - "line_id": self.test_plan_line.id, - "condition": "==", - "value_char": "1", - "action": "n", - } - ) - # Test write - self.test_action.with_user(self.manager).write({"value_char": "2"}) - except AccessError: - self.fail("Manager should be able to create/write when conditions are met") - - # Case 2: Manager should not be able to create/write when access_level > "2" - self.test_plan.access_level = "3" - with self.assertRaises(AccessError): - self.plan_line_action.with_user(self.manager).create( - { - "line_id": self.test_plan_line.id, - "condition": "==", - "value_char": "1", - "action": "n", - } - ) - with self.assertRaises(AccessError): - self.test_action.with_user(self.manager).write({"value_char": "3"}) - - def test_manager_unlink_access(self): - """Test manager unlink access to plan line actions""" - # Create action as manager to test unlink rights - action = self.plan_line_action.with_user(self.manager).create( - { - "line_id": self.test_plan_line.id, - "condition": "==", - "value_char": "0", - "action": "n", - } - ) - - # Case 1: Manager should be able to unlink when: - # - access_level <= "2" - # - manager is the creator - # - manager is in plan's manager_ids - try: - action.unlink() - except AccessError: - self.fail("Manager should be able to unlink when conditions are met") - - # Case 2: Manager should not be able to unlink actions created by others - action = self.test_action # Created by admin in setUp - with self.assertRaises(AccessError): - action.with_user(self.manager).unlink() - - def test_root_unrestricted_access(self): - """Test root user unrestricted access""" - # Root should have full access regardless of conditions - try: - # Test read - recs = self.plan_line_action.with_user(self.root).search( - [("id", "=", self.test_action.id)] - ) - self.assertIn( - self.test_action, - recs, - "Root should be able to read action without restrictions", - ) - - # Test create - new_action = self.plan_line_action.with_user(self.root).create( - { - "line_id": self.test_plan_line.id, - "condition": "==", - "value_char": "1", - "action": "n", - } - ) - - # Test write - self.test_action.with_user(self.root).write({"value_char": "2"}) - - # Test unlink - new_action.unlink() - except AccessError: - self.fail("Root user should have unrestricted access") diff --git a/addons/cetmix_tower_server/tests/test_plan_log.py b/addons/cetmix_tower_server/tests/test_plan_log.py deleted file mode 100644 index a80f934..0000000 --- a/addons/cetmix_tower_server/tests/test_plan_log.py +++ /dev/null @@ -1,274 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import fields -from odoo.exceptions import AccessError - -from .common import TestTowerCommon - - -class TestTowerPlanLog(TestTowerCommon): - """Test the cx.tower.plan.log model access rights.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create plans with different access levels - cls.plan_level_1 = cls.Plan.create( - { - "name": "Test Plan L1", - "access_level": "1", - } - ) - - cls.plan_level_2 = cls.Plan.create( - { - "name": "Test Plan L2", - "access_level": "2", - } - ) - - cls.plan_level_3 = cls.Plan.create( - { - "name": "Test Plan L3", - "access_level": "3", - } - ) - - # Create test plan logs with specific users - cls.plan_log_1 = ( - cls.PlanLog.with_user(cls.user) - .sudo() - .create( - { - "server_id": cls.server_test_1.id, - "plan_id": cls.plan_level_1.id, - "start_date": fields.Datetime.now(), - } - ) - ) - - cls.plan_log_2 = ( - cls.PlanLog.with_user(cls.manager) - .sudo() - .create( - { - "server_id": cls.server_test_1.id, - "plan_id": cls.plan_level_1.id, - "start_date": fields.Datetime.now(), - } - ) - ) - - # Create additional server for testing - cls.server_2 = cls.Server.create( - { - "name": "Test Server 2", - "ip_v4_address": "localhost", - "ssh_username": "test2", - "ssh_password": "test2", - "ssh_port": 22, - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - } - ) - - def test_user_read_access(self): - """Test user read access to plan logs""" - # Add user to server's user_ids to isolate creator check - self.server_test_1.write( - { - "user_ids": [(6, 0, [self.user.id])], - } - ) - - # Case 1: User should be able to read when: - # - access_level == "1" - # - created by user - # - user is in server's user_ids - recs = self.PlanLog.with_user(self.user).search( - [("id", "in", [self.plan_log_1.id, self.plan_log_2.id])] - ) - self.assertEqual( - len(recs), - 1, - "User should only be able to read their own logs", - ) - self.assertIn( - self.plan_log_1, - recs, - "User should be able to read own logs when conditions are met", - ) - self.assertNotIn( - self.plan_log_2, - recs, - "User should not be able to read logs created by others", - ) - - # Case 2: User should not be able to read when not in server's user_ids - self.server_test_1.write( - { - "user_ids": [(5, 0, 0)], # Remove all users - } - ) - recs = self.PlanLog.with_user(self.user).search( - [("id", "=", self.plan_log_1.id)] - ) - self.assertNotIn( - self.plan_log_1, - recs, - "User should not be able to read when not in server's user_ids", - ) - - # Case 3: User should not be able to read when access_level > "1" - self.server_test_1.write( - { - "user_ids": [(6, 0, [self.user.id])], - } - ) - high_access_log = ( - self.PlanLog.with_user(self.user) - .sudo() - .create( - { - "server_id": self.server_test_1.id, - "plan_id": self.plan_level_2.id, - "start_date": fields.Datetime.now(), - } - ) - ) - recs = self.PlanLog.with_user(self.user).search( - [("id", "=", high_access_log.id)] - ) - self.assertNotIn( - high_access_log, - recs, - "User should not be able to read logs with access_level > '1'" - " even if created by them", - ) - - def test_manager_read_access(self): - """Test manager read access to plan logs""" - # Case 1: Manager should be able to read when: - # - access_level <= "2" - # - manager is in server's manager_ids - self.server_test_1.write( - { - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - recs = self.PlanLog.with_user(self.manager).search( - [("id", "in", [self.plan_log_1.id, self.plan_log_2.id])] - ) - self.assertEqual( - len(recs), - 2, - "Manager should be able to read all logs when in server's manager_ids", - ) - - # Case 2: Manager should be able to read when in server's user_ids - self.server_test_1.write( - { - "manager_ids": [(5, 0, 0)], # Remove all managers - "user_ids": [(6, 0, [self.manager.id])], - } - ) - recs = self.PlanLog.with_user(self.manager).search( - [("id", "in", [self.plan_log_1.id, self.plan_log_2.id])] - ) - self.assertEqual( - len(recs), - 2, - "Manager should be able to read all logs when in server's user_ids", - ) - - # Case 3: Manager should not be able to read when access_level > "2" - high_access_log = ( - self.PlanLog.with_user(self.manager) - .sudo() - .create( - { - "server_id": self.server_test_1.id, - "plan_id": self.plan_level_3.id, - "start_date": fields.Datetime.now(), - } - ) - ) - recs = self.PlanLog.with_user(self.manager).search( - [("id", "=", high_access_log.id)] - ) - self.assertNotIn( - high_access_log, - recs, - "Manager should not be able to read logs with access_level > '2'", - ) - - # Case 4: Manager should not be able to read when he is not - # in users_ids or manager_ids - self.server_test_1.write( - { - "user_ids": [(5, 0, 0)], - "manager_ids": [(5, 0, 0)], - } - ) - recs = self.PlanLog.with_user(self.manager).search( - [("id", "in", [self.plan_log_1.id, self.plan_log_2.id])] - ) - self.assertNotIn( - self.plan_log_1, - recs, - "Manager should not be able to read logs when he is not" - " in users_ids or manager_ids", - ) - - def test_root_read_only_access(self): - """Root can read all plan logs, but cannot create/modify/delete""" - # Create test logs with sudo() - test_logs = self.PlanLog.sudo().create( - [ - { - "server_id": self.server_2.id, - "plan_id": plan.id, - "start_date": fields.Datetime.now(), - } - for plan in [self.plan_level_1, self.plan_level_2, self.plan_level_3] - ] - ) - - # Root should be able to read all logs regardless of: - # - access_level - # - server relationships - # - who created them - recs = self.PlanLog.with_user(self.root).search([("id", "in", test_logs.ids)]) - self.assertEqual( - len(recs), - 3, - "Root should have unrestricted read access to all logs", - ) - - # Root can't create logs - with self.assertRaises(AccessError): - self.PlanLog.with_user(self.root).create( - { - "server_id": self.server_2.id, - "plan_id": self.plan_level_1.id, - "start_date": fields.Datetime.now(), - } - ) - - # Root cannot modify logs - with self.assertRaises(AccessError): - test_logs.with_user(self.root).write({"start_date": fields.Datetime.now()}) - - # Root cannot delete logs - with self.assertRaises(AccessError): - test_logs.with_user(self.root).unlink() - - # Test read on all records - all_recs = self.PlanLog.with_user(self.root).search([]) - self.assertGreater( - len(all_recs), - 0, - "Root should be able to read all plan logs", - ) diff --git a/addons/cetmix_tower_server/tests/test_reference_mixin.py b/addons/cetmix_tower_server/tests/test_reference_mixin.py deleted file mode 100644 index e2dad2c..0000000 --- a/addons/cetmix_tower_server/tests/test_reference_mixin.py +++ /dev/null @@ -1,310 +0,0 @@ -import re - -from .common import TestTowerCommon - - -class TestTowerReference(TestTowerCommon): - """Test reference generation. - We are using ServerTemplate for that. - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.plan_test_mixin = cls.Plan.create( - {"name": "Test Plan reference mixin", "note": "Test Note reference mixin"} - ) - - cls.plan_line_reference_mixin = cls.plan_line.create( - { - "plan_id": cls.plan_test_mixin.id, - "sequence": 1, - "command_id": cls.command_list_dir.id, - } - ) - - def test_reference_generation(self): - """Test reference generation""" - - # --- 1 --- - # Check if auto generated reference matches the pattern - reference_pattern = self.ServerTemplate._get_reference_pattern() - self.assertTrue( - re.match(rf"{reference_pattern}", self.server_template_sample.reference), - "Reference doesn't match template", - ) - - # --- 2 --- - # Create a new server template with custom reference - # and ensure that it's fixed according to the pattern - new_template = self.ServerTemplate.create( - {"name": "Such Much Template", "reference": " Some reference x*((*)) "} - ) - self.assertEqual(new_template.reference, "some_reference_x") - - # --- 3 --- - # Try to create another server template with the same reference and ensure - # that its reference is corrected automatically - yet_another_template = self.ServerTemplate.create( - {"name": "Yet another template", "reference": "some_reference_x"} - ) - self.assertEqual(yet_another_template.reference, "some_reference_x_2") - - # -- 4 --- - # Duplicate the server template and ensure that its name and reference - # are generated properly - yet_another_template_copy = yet_another_template.copy() - self.assertEqual(yet_another_template_copy.name, "Yet another template (copy)") - self.assertEqual( - yet_another_template_copy.reference, "yet_another_template_copy" - ) - - # -- 5 --- - # Update reference and ensure that updated value is correct - yet_another_template_copy.write({"reference": " Some reference x*((*)) "}) - self.assertEqual(yet_another_template_copy.reference, "some_reference_x_3") - - # -- 6 --- - # Update template with a new name and remove reference simultaneously - yet_another_template_copy.write({"name": "Doge so like", "reference": False}) - self.assertEqual(yet_another_template_copy.reference, "doge_so_like") - - # -- 7 --- - # Rename the template and ensure reference is not affected - yet_another_template_copy.write({"name": "Chad"}) - self.assertEqual(yet_another_template_copy.reference, "doge_so_like") - - # -- 8 --- - # Remove the reference and ensure it's regenerated from the name - yet_another_template_copy.write({"reference": False}) - self.assertEqual(yet_another_template_copy.reference, "chad") - - # -- 9 -- - # Update record with the same reference name and ensure it remains the same - yet_another_template_copy.write({"reference": "chad"}) - self.assertEqual(yet_another_template_copy.reference, "chad") - - # -- 10 -- - # Create new template with reference set to False - expected_reference = self.ServerTemplate._generate_or_fix_reference( - "Such Much False Template" - ) - new_template_with_false = self.ServerTemplate.create( - {"name": "Such Much False Template", "reference": False} - ) - self.assertEqual( - new_template_with_false.reference, - expected_reference, - "Reference doesn't match expected one", - ) - - # -- 11 -- - # Create new template with reference and name set to a non valid symbol - # Generic model reference should be used as a reference - expected_reference = self.ServerTemplate._get_model_generic_reference() - new_template_with_non_valid_reference = self.ServerTemplate.create( - {"name": "/", "reference": "/"} - ) - self.assertEqual( - new_template_with_non_valid_reference.reference, - expected_reference, - "Reference doesn't match expected one", - ) - - def test_search_by_reference(self): - """Search record by its reference""" - - # Create a new server template with custom reference - server_template = self.ServerTemplate.create( - {"name": "Such Much Template", "reference": "such_much_template"} - ) - - # Search using correct template reference - search_result = self.ServerTemplate.get_by_reference("such_much_template") - self.assertEqual(server_template, search_result, "Template must be found") - - # Search using malformed (case sensitive) - search_result = self.ServerTemplate.get_by_reference("not_much_template") - self.assertEqual(len(search_result), 0, "Result should be empty") - - def test_prepare_references_valid_input(self): - """ - Ensure references are correctly prepared for valid input. - """ - - vals_list = [{"plan_id": self.plan_test_mixin.id}] - result = self.plan_line._prepare_references( - "cx.tower.plan", "plan_id", vals_list - ) - - # Verify the result contains the expected reference - self.assertIn( - self.plan_test_mixin.id, - result, - "The reference ID should be in the result.", - ) - self.assertEqual( - result[self.plan_test_mixin.id], - self.plan_test_mixin.reference, - "The reference should match the expected value.", - ) - - def test_prepare_references_invalid_model_name(self): - """ - Check that an error is raised for an invalid model name. - """ - - vals_list = [{"plan_id": self.plan_test_mixin.id}] - with self.assertRaises(ValueError) as cm: - self.plan_line._prepare_references("invalid.model", "plan_id", vals_list) - - # Confirm the exception message is as expected - self.assertEqual( - str(cm.exception), - "Model 'invalid.model' does not exist. Please provide a valid model name.", - "The error message should indicate an invalid model name.", - ) - - def test_prepare_references_empty_vals_list(self): - """ - Verify that an empty vals_list returns an empty dictionary. - """ - result = self.plan_line._prepare_references("cx.tower.plan", "plan_id", []) - self.assertEqual( - result, - {}, - "The result should be an empty dictionary when vals_list is empty.", - ) - - def test_populate_references_with_valid_input(self): - """ - Ensure references are populated correctly in the provided values list. - """ - vals_list = [{"plan_id": self.plan_test_mixin.id}] - updated_vals = self.plan_line._pre_populate_references( - "cx.tower.plan", "plan_id", vals_list - ) - - # Check the updated values contain the expected reference format - self.assertEqual( - updated_vals[0]["reference"], - f"{self.plan_test_mixin.reference}_plan_line_1", - "The reference should be correctly populated with the suffix.", - ) - - def test_populate_references_missing_field(self): - """ - Confirm that entries missing the required field are handled properly. - """ - - vals_list_with_missing_field = [{"another_key": 123}] - updated_vals_with_missing = self.plan_line._pre_populate_references( - "cx.tower.plan", "plan_id", vals_list_with_missing_field - ) - self.assertEqual( - updated_vals_with_missing[0]["reference"], - "no_plan_line_1", - "Entries missing the required field should have a default reference.", - ) - - def test_populate_references_duplicate_ids(self): - """ - Ensure that duplicate IDs in the input list are correctly - handled and referenced. - """ - vals_list = [ - {"plan_id": self.plan_test_mixin.id}, - {"plan_id": self.plan_test_mixin.id}, - ] - updated_vals = self.plan_line._pre_populate_references( - "cx.tower.plan", "plan_id", vals_list - ) - - # Verify that each duplicate entry has a unique suffix - self.assertEqual( - updated_vals[0]["reference"], - f"{self.plan_test_mixin.reference}_plan_line_1", - "The first duplicate reference should have the correct suffix.", - ) - self.assertEqual( - updated_vals[1]["reference"], - f"{self.plan_test_mixin.reference}_plan_line_2", - "The second duplicate reference should have the correct suffix.", - ) - - def test_populate_references_empty_vals_list(self): - """ - Check that an empty input list returns an empty result - when populating references. - """ - updated_vals = self.plan_line._pre_populate_references( - "cx.tower.plan", "plan_id", [] - ) - self.assertEqual( - updated_vals, - [], - "The result should be an empty list when vals_list is empty.", - ) - - def test_populate_references_reference_present(self): - """ - Check that reference is preserver when present in vals - """ - - vals_list = [ - {"reference": "my_custom_line_1"}, - {"reference": "my_custom_line_2"}, - ] - updated_vals = self.plan_line._pre_populate_references( - "cx.tower.plan", "plan_id", vals_list - ) - self.assertEqual( - updated_vals[0]["reference"], - "my_custom_line_1", - "Original reference must be preserved", - ) - self.assertEqual( - updated_vals[1]["reference"], - "my_custom_line_2", - "Original reference must be preserved", - ) - - def test_populate_references_mixed_scenarios(self): - """Test mixed scenarios with existing and missing references""" - vals_list = [ - {"reference": "my_custom_line_1"}, - {"plan_id": self.plan_test_mixin.id}, # No reference - {"reference": " "}, # Whitespace reference - {"reference": ""}, # Empty reference - {"reference": "\n_"}, # Some irrelevant symbols - ] - updated_vals = self.plan_line._pre_populate_references( - "cx.tower.plan", "plan_id", vals_list - ) - - self.assertEqual( - updated_vals[0]["reference"], - "my_custom_line_1", - "Original reference must be preserved", - ) - self.assertEqual( - updated_vals[1]["reference"], - f"{self.plan_test_mixin.reference}_plan_line_1", - "Missing reference should be generated", - ) - self.assertEqual( - updated_vals[2]["reference"], - "no_plan_line_1", - "Missing reference should be generated", - ) - self.assertEqual( - updated_vals[3]["reference"], - "no_plan_line_2", - "Missing reference should be generated", - ) - self.assertEqual( - updated_vals[4]["reference"], - "no_plan_line_3", - "Missing reference should be generated", - ) diff --git a/addons/cetmix_tower_server/tests/test_scheduled_task.py b/addons/cetmix_tower_server/tests/test_scheduled_task.py deleted file mode 100644 index f93f5a7..0000000 --- a/addons/cetmix_tower_server/tests/test_scheduled_task.py +++ /dev/null @@ -1,893 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from datetime import datetime - -from odoo import fields -from odoo.exceptions import AccessError, ValidationError - -from .common import TestTowerCommon - - -class TestTowerScheduledTask(TestTowerCommon): - """Test the cx.tower.scheduled.task model.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create an additional server for multi-server command test - cls.server_test_2 = cls.Server.create( - { - "name": "Test 2", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "host_key": "test_key", - "os_id": cls.os_debian_10.id, - } - ) - - # Scheduled task: command (multi-server) - cls.command_scheduled_task = cls.ScheduledTask.create( - { - "name": "Test Command Scheduled Task", - "action": "command", - "command_id": cls.command_list_dir.id, - "interval_number": 1, - "interval_type": "days", - "next_call": fields.Datetime.now(), - "server_ids": [(6, 0, [cls.server_test_1.id, cls.server_test_2.id])], - } - ) - - # Scheduled task: plan (single server) - cls.plan_scheduled_task = cls.ScheduledTask.create( - { - "name": "Test Plan Scheduled Task", - "action": "plan", - "plan_id": cls.plan_1.id, - "interval_number": 1, - "interval_type": "days", - "next_call": fields.Datetime.now(), - "server_ids": [(6, 0, [cls.server_test_1.id])], - } - ) - - # Custom variable for task (option type) - cls.variable_odoo_versions = cls.Variable.create( - { - "name": "odoo_versions", - "variable_type": "o", - } - ) - cls.variable_option_16_0 = cls.VariableOption.create( - { - "name": "16.0", - "value_char": "16.0", - "variable_id": cls.variable_odoo_versions.id, - } - ) - - # Add custom variables to tasks - cls.scheduled_task_cv_os = cls.ScheduledTaskCv.create( - { - "scheduled_task_id": cls.command_scheduled_task.id, - "variable_id": cls.variable_os.id, - "value_char": "Windows 2k", - } - ) - cls.scheduled_task_cv_version = cls.ScheduledTaskCv.create( - { - "scheduled_task_id": cls.command_scheduled_task.id, - "variable_id": cls.variable_odoo_versions.id, - "option_id": cls.variable_option_16_0.id, - } - ) - cls.scheduled_task_cv_version_plan = cls.ScheduledTaskCv.create( - { - "scheduled_task_id": cls.plan_scheduled_task.id, - "variable_id": cls.variable_odoo_versions.id, - "option_id": cls.variable_option_16_0.id, - } - ) - - # Create additional Jet Template for access testing - cls.jet_template_test_access = cls.JetTemplate.create( - { - "name": "Test Jet Template for Access", - "server_ids": [(4, cls.server_test_1.id)], - } - ) - - # Create additional Jet for access testing - cls.jet_test_access = cls.Jet.create( - { - "name": "Test Jet for Access", - "jet_template_id": cls.jet_template_test_access.id, - "server_id": cls.server_test_1.id, - } - ) - - # Scheduled task with Jet and Jet Template for access testing - cls.jet_scheduled_task = cls.ScheduledTask.create( - { - "name": "Test Jet Scheduled Task", - "action": "command", - "command_id": cls.command_list_dir.id, - "interval_number": 1, - "interval_type": "days", - "next_call": fields.Datetime.now(), - "jet_ids": [(6, 0, [cls.jet_test_access.id])], - "jet_template_ids": [(6, 0, [cls.jet_template_test_access.id])], - } - ) - - def _assert_log_records(self, log_model, scheduled_task, expected_count): - """Helper: Assert that log records exist for the task""" - logs = log_model.search([("scheduled_task_id", "=", scheduled_task.id)]) - self.assertTrue(logs, f"{log_model._name} logs should be created after run.") - self.assertEqual( - len(logs), - expected_count, - f"Expected {expected_count} logs for {scheduled_task.display_name}, " - f"got {len(logs)}.", - ) - - def _assert_next_and_last_call_changed( - self, task, last_call_before, next_call_before - ): - """Helper: Assert next_call and last_call changed after run""" - task.invalidate_recordset() - self.assertNotEqual( - task.last_call, last_call_before, "last_call must be changed after run." - ) - self.assertNotEqual( - task.next_call, next_call_before, "next_call must be changed after run." - ) - - def _clear_all_access( - self, - scheduled_task, - jet=None, - jet_template=None, - server=None, - server_template=None, - ): - """Helper: Clear all access paths for a scheduled task and related objects.""" - scheduled_task.manager_ids = [(5, 0, 0)] - scheduled_task.user_ids = [(5, 0, 0)] - if jet: - jet.manager_ids = [(5, 0, 0)] - jet.user_ids = [(5, 0, 0)] - if jet_template: - jet_template.manager_ids = [(5, 0, 0)] - jet_template.user_ids = [(5, 0, 0)] - if server: - server.manager_ids = [(5, 0, 0)] - server.user_ids = [(5, 0, 0)] - if server_template: - server_template.manager_ids = [(5, 0, 0)] - server_template.user_ids = [(5, 0, 0)] - - def test_reserve_tasks_atomic(self): - """Scheduled Task: reserve_tasks must only lock available""" - tasks = self.command_scheduled_task + self.plan_scheduled_task - reserved = tasks._reserve_tasks() - self.assertEqual( - set(reserved.ids), set(tasks.ids), "Both tasks should be reserved" - ) - # Repeated reservation should return empty (already running) - tasks.invalidate_recordset() - reserved_again = tasks._reserve_tasks() - self.assertFalse( - reserved_again, "Already reserved tasks must not be reserved again" - ) - - def test_run_task_command(self): - """Running a scheduled command task creates logs per server.""" - logs_before = self.CommandLog.search( - [("scheduled_task_id", "=", self.command_scheduled_task.id)] - ) - self.assertFalse(logs_before, "No command logs should exist before run.") - - last_call_before = self.command_scheduled_task.last_call - next_call_before = self.command_scheduled_task.next_call - - self.command_scheduled_task._run() - self._assert_next_and_last_call_changed( - self.command_scheduled_task, last_call_before, next_call_before - ) - self._assert_log_records( - self.CommandLog, - self.command_scheduled_task, - expected_count=len(self.command_scheduled_task.server_ids), - ) - - def test_run_task_plan(self): - """Running a scheduled plan task creates one log per server.""" - logs_before = self.PlanLog.search( - [("scheduled_task_id", "=", self.plan_scheduled_task.id)] - ) - self.assertFalse(logs_before, "No plan logs should exist before run.") - - last_call_before = self.plan_scheduled_task.last_call - next_call_before = self.plan_scheduled_task.next_call - - self.plan_scheduled_task._run() - self._assert_next_and_last_call_changed( - self.plan_scheduled_task, last_call_before, next_call_before - ) - self._assert_log_records( - self.PlanLog, - self.plan_scheduled_task, - expected_count=len(self.plan_scheduled_task.server_ids), - ) - - def test_user_write_create_unlink_access(self): - """User: cannot create, write or unlink scheduled tasks.""" - with self.assertRaises(AccessError): - self.ScheduledTask.with_user(self.user).create( - { - "name": "Test", - "action": "command", - "command_id": self.command_list_dir.id, - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - with self.assertRaises(AccessError): - self.command_scheduled_task.with_user(self.user).write({"sequence": 33}) - with self.assertRaises(AccessError): - self.command_scheduled_task.with_user(self.user).unlink() - - def test_manager_read_access(self): - """Manager: can read scheduled task if in manager_ids or in server's - manager_ids/user_ids.""" - self.command_scheduled_task.manager_ids = [(6, 0, [self.manager.id])] - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", self.command_scheduled_task.id)] - ) - self.assertIn( - self.command_scheduled_task, - tasks, - "Manager should be able to read their task.", - ) - - # Remove from manager_ids, but add to server manager_ids - self.command_scheduled_task.manager_ids = [(5, 0, 0)] - self.server_test_1.manager_ids = [(6, 0, [self.manager.id])] - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", self.command_scheduled_task.id)] - ) - self.assertIn( - self.command_scheduled_task, - tasks, - "Manager should be able to read task via server manager_ids.", - ) - - # Test server user_ids access - self.server_test_1.manager_ids = [(5, 0, 0)] - self.server_test_1.user_ids = [(6, 0, [self.manager.id])] - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", self.command_scheduled_task.id)] - ) - self.assertIn( - self.command_scheduled_task, - tasks, - "Manager should be able to read task via server user_ids.", - ) - - # Remove manager from everywhere - self._clear_all_access(self.command_scheduled_task, server=self.server_test_1) - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", self.command_scheduled_task.id)] - ) - self.assertNotIn( - self.command_scheduled_task, - tasks, - "Manager should NOT be able to read task without relation.", - ) - - def test_manager_read_access_via_jet(self): - """Manager: can read scheduled task if in jet's user_ids/manager_ids.""" - # Test access via jet manager_ids - self.jet_test_access.manager_ids = [(6, 0, [self.manager.id])] - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", self.jet_scheduled_task.id)] - ) - self.assertIn( - self.jet_scheduled_task, - tasks, - "Manager should be able to read task via jet manager_ids.", - ) - - # Test access via jet user_ids - self.jet_test_access.manager_ids = [(5, 0, 0)] - self.jet_test_access.user_ids = [(6, 0, [self.manager.id])] - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", self.jet_scheduled_task.id)] - ) - self.assertIn( - self.jet_scheduled_task, - tasks, - "Manager should be able to read task via jet user_ids.", - ) - - # Test access via jet_template manager_ids - self.jet_test_access.user_ids = [(5, 0, 0)] - self.jet_template_test_access.manager_ids = [(6, 0, [self.manager.id])] - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", self.jet_scheduled_task.id)] - ) - self.assertIn( - self.jet_scheduled_task, - tasks, - "Manager should be able to read task via jet_template manager_ids.", - ) - - # Test access via jet_template user_ids - self.jet_template_test_access.manager_ids = [(5, 0, 0)] - self.jet_template_test_access.user_ids = [(6, 0, [self.manager.id])] - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", self.jet_scheduled_task.id)] - ) - self.assertIn( - self.jet_scheduled_task, - tasks, - "Manager should be able to read task via jet_template user_ids.", - ) - - # Remove manager from everywhere - self._clear_all_access( - self.jet_scheduled_task, - jet=self.jet_test_access, - jet_template=self.jet_template_test_access, - server=self.server_test_1, - ) - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", self.jet_scheduled_task.id)] - ) - self.assertNotIn( - self.jet_scheduled_task, - tasks, - "Manager should NOT be able to read task without relation.", - ) - - def test_manager_read_access_via_server_template(self): - """Manager: can read scheduled task if in server_template's - user_ids/manager_ids.""" - # Create scheduled task with server template - server_template_task = self.ScheduledTask.create( - { - "name": "Test Server Template Scheduled Task", - "action": "command", - "command_id": self.command_list_dir.id, - "interval_number": 1, - "interval_type": "days", - "next_call": fields.Datetime.now(), - "server_template_ids": [(6, 0, [self.server_template_sample.id])], - } - ) - - # Test access via server_template manager_ids - self.server_template_sample.manager_ids = [(6, 0, [self.manager.id])] - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", server_template_task.id)] - ) - self.assertIn( - server_template_task, - tasks, - "Manager should be able to read task via server_template manager_ids.", - ) - - # Test access via server_template user_ids - self.server_template_sample.manager_ids = [(5, 0, 0)] - self.server_template_sample.user_ids = [(6, 0, [self.manager.id])] - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", server_template_task.id)] - ) - self.assertIn( - server_template_task, - tasks, - "Manager should be able to read task via server_template user_ids.", - ) - - # Remove manager from everywhere - self._clear_all_access( - server_template_task, - server_template=self.server_template_sample, - server=self.server_test_1, - ) - tasks = self.ScheduledTask.with_user(self.manager).search( - [("id", "=", server_template_task.id)] - ) - self.assertNotIn( - server_template_task, - tasks, - "Manager should NOT be able to read task without relation.", - ) - - def test_manager_write_create_access(self): - """Manager: can create/write if in manager_ids, else denied.""" - # Create as manager - task = self.ScheduledTask.with_user(self.manager).create( - { - "name": "Test", - "action": "command", - "command_id": self.command_list_dir.id, - "manager_ids": [(6, 0, [self.manager.id])], - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - try: - task.with_user(self.manager).write({"sequence": 77}) - except AccessError: - self.fail("Manager should be able to write their own scheduled tasks.") - - # Should fail if not in manager_ids - self.command_scheduled_task.manager_ids = [(5, 0, 0)] - with self.assertRaises(AccessError): - self.command_scheduled_task.with_user(self.manager).write({"sequence": 11}) - - def test_manager_unlink_access(self): - """Manager: can unlink only their own tasks (in manager_ids & creator).""" - # Create as manager - task = self.ScheduledTask.with_user(self.manager).create( - { - "name": "Test", - "action": "command", - "command_id": self.command_list_dir.id, - "manager_ids": [(6, 0, [self.manager.id])], - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - try: - task.with_user(self.manager).unlink() - except AccessError: - self.fail("Manager should be able to unlink their own task.") - - # Not creator - with self.assertRaises(AccessError): - self.command_scheduled_task.with_user(self.manager).unlink() - - def test_root_unrestricted_access(self): - """Root: full unrestricted access to all scheduled tasks.""" - # Read - tasks = self.ScheduledTask.with_user(self.root).search( - [("id", "=", self.command_scheduled_task.id)] - ) - self.assertIn( - self.command_scheduled_task, tasks, "Root should be able to read any task." - ) - - # Create - task = self.ScheduledTask.with_user(self.root).create( - { - "name": "Test", - "action": "command", - "command_id": self.command_list_dir.id, - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - try: - task.with_user(self.root).write({"sequence": 123}) - task.with_user(self.root).unlink() - except AccessError: - self.fail("Root should be able to write/unlink any scheduled task.") - - def test_get_next_call_dow_wednesday(self): - """Test _get_next_call_dow when today is Wednesday. - Task runs Monday, Wednesday, Friday -> should return Friday.""" - # Create task with Monday, Wednesday, Friday selected - task = self.ScheduledTask.create( - { - "name": "Test DOW Task", - "action": "command", - "command_id": self.command_list_dir.id, - "interval_type": "dow", - "monday": True, - "wednesday": True, - "friday": True, - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - - # Create a Wednesday datetime (2024-01-03 is a Wednesday) - # Set time to 10:30:45 - wednesday_date = datetime(2024, 1, 3, 10, 30, 45) - - # Calculate next call - next_call = task._get_next_call_dow(task, wednesday_date) - - # Should be Friday (2 days ahead) at the same time - expected_friday = datetime(2024, 1, 5, 10, 30, 45) - self.assertEqual( - next_call, - expected_friday, - "Next call from Wednesday should be Friday at the same time.", - ) - - def test_get_next_call_dow_friday(self): - """Test _get_next_call_dow when today is Friday. - Task runs Monday, Wednesday, Friday -> should return Monday (next week).""" - # Create task with Monday, Wednesday, Friday selected - task = self.ScheduledTask.create( - { - "name": "Test DOW Task", - "action": "command", - "command_id": self.command_list_dir.id, - "interval_type": "dow", - "monday": True, - "wednesday": True, - "friday": True, - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - - # Create a Friday datetime (2024-01-05 is a Friday) - # Set time to 14:15:30 - friday_date = datetime(2024, 1, 5, 14, 15, 30) - - # Calculate next call - next_call = task._get_next_call_dow(task, friday_date) - - # Should be Monday next week (3 days ahead) at the same time - expected_monday = datetime(2024, 1, 8, 14, 15, 30) - self.assertEqual( - next_call, - expected_monday, - "Next call from Friday should be Monday next week at the same time.", - ) - - def test_check_days_of_week_constraint(self): - """ - Test _check_days_of_week constraint: - no days selected should raise ValidationError. - """ - # Try to create a task with interval_type="dow" but no days selected - with self.assertRaises(ValidationError) as context: - self.ScheduledTask.create( - { - "name": "Test DOW Task No Days", - "action": "command", - "command_id": self.command_list_dir.id, - "interval_type": "dow", - "monday": False, - "tuesday": False, - "wednesday": False, - "thursday": False, - "friday": False, - "saturday": False, - "sunday": False, - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - self.assertIn( - "At least one day of week must be selected", - str(context.exception), - "ValidationError should mention that at " "least one day must be selected.", - ) - - # Try to update an existing task to have no days selected - task = self.ScheduledTask.create( - { - "name": "Test DOW Task", - "action": "command", - "command_id": self.command_list_dir.id, - "interval_type": "dow", - "monday": True, - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - with self.assertRaises(ValidationError): - task.write( - { - "monday": False, - "tuesday": False, - "wednesday": False, - "thursday": False, - "friday": False, - "saturday": False, - "sunday": False, - } - ) - - def test_get_next_call_dow_single_day_monday(self): - """Test _get_next_call_dow edge case: only Monday selected, - current day is Monday. - Should wrap to next week's Monday.""" - # Create task with only Monday selected - task = self.ScheduledTask.create( - { - "name": "Test DOW Task Single Day", - "action": "command", - "command_id": self.command_list_dir.id, - "interval_type": "dow", - "monday": True, - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - - # Create a Monday datetime (2024-01-01 is a Monday) - # Set time to 09:00:00 - monday_date = datetime(2024, 1, 1, 9, 0, 0) - - # Calculate next call - next_call = task._get_next_call_dow(task, monday_date) - - # Should be Monday next week (7 days ahead) at the same time - expected_next_monday = datetime(2024, 1, 8, 9, 0, 0) - self.assertEqual( - next_call, - expected_next_monday, - "Next call from Monday (only day selected) should be" - " next Monday at the same time.", - ) - - def test_scheduled_task_cv_manager_read_access(self): - """Manager: can read scheduled task CV if in scheduled task's - manager_ids/user_ids or via server's manager_ids/user_ids.""" - # Test access via scheduled task manager_ids - self.command_scheduled_task.manager_ids = [(6, 0, [self.manager.id])] - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", self.scheduled_task_cv_os.id)] - ) - self.assertIn( - self.scheduled_task_cv_os, - cvs, - "Manager should be able to read CV via scheduled task manager_ids.", - ) - - # Test access via scheduled task user_ids - self.command_scheduled_task.manager_ids = [(5, 0, 0)] - self.command_scheduled_task.user_ids = [(6, 0, [self.manager.id])] - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", self.scheduled_task_cv_os.id)] - ) - self.assertIn( - self.scheduled_task_cv_os, - cvs, - "Manager should be able to read CV via scheduled task user_ids.", - ) - - # Test access via server manager_ids - self.command_scheduled_task.user_ids = [(5, 0, 0)] - self.server_test_1.manager_ids = [(6, 0, [self.manager.id])] - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", self.scheduled_task_cv_os.id)] - ) - self.assertIn( - self.scheduled_task_cv_os, - cvs, - "Manager should be able to read CV via server manager_ids.", - ) - - # Test access via server user_ids - self.server_test_1.manager_ids = [(5, 0, 0)] - self.server_test_1.user_ids = [(6, 0, [self.manager.id])] - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", self.scheduled_task_cv_os.id)] - ) - self.assertIn( - self.scheduled_task_cv_os, - cvs, - "Manager should be able to read CV via server user_ids.", - ) - - # Remove manager from everywhere - self.server_test_1.user_ids = [(5, 0, 0)] - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", self.scheduled_task_cv_os.id)] - ) - self.assertNotIn( - self.scheduled_task_cv_os, - cvs, - "Manager should NOT be able to read CV without relation.", - ) - - def test_scheduled_task_cv_manager_read_access_via_jet(self): - """Manager: can read scheduled task CV if in jet's user_ids/manager_ids.""" - # Create CV for jet scheduled task - jet_cv = self.ScheduledTaskCv.create( - { - "scheduled_task_id": self.jet_scheduled_task.id, - "variable_id": self.variable_os.id, - "value_char": "Linux", - } - ) - - # Test access via jet manager_ids - self.jet_test_access.manager_ids = [(6, 0, [self.manager.id])] - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", jet_cv.id)] - ) - self.assertIn( - jet_cv, - cvs, - "Manager should be able to read CV via jet manager_ids.", - ) - - # Test access via jet user_ids - self.jet_test_access.manager_ids = [(5, 0, 0)] - self.jet_test_access.user_ids = [(6, 0, [self.manager.id])] - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", jet_cv.id)] - ) - self.assertIn( - jet_cv, - cvs, - "Manager should be able to read CV via jet user_ids.", - ) - - # Test access via jet_template manager_ids - self.jet_test_access.user_ids = [(5, 0, 0)] - self.jet_template_test_access.manager_ids = [(6, 0, [self.manager.id])] - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", jet_cv.id)] - ) - self.assertIn( - jet_cv, - cvs, - "Manager should be able to read CV via jet_template manager_ids.", - ) - - # Test access via jet_template user_ids - self.jet_template_test_access.manager_ids = [(5, 0, 0)] - self.jet_template_test_access.user_ids = [(6, 0, [self.manager.id])] - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", jet_cv.id)] - ) - self.assertIn( - jet_cv, - cvs, - "Manager should be able to read CV via jet_template user_ids.", - ) - - # Remove manager from everywhere - self._clear_all_access( - self.jet_scheduled_task, - jet=self.jet_test_access, - jet_template=self.jet_template_test_access, - server=self.server_test_1, - ) - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", jet_cv.id)] - ) - self.assertNotIn( - jet_cv, - cvs, - "Manager should NOT be able to read CV without relation.", - ) - - def test_scheduled_task_cv_manager_read_access_via_server_template(self): - """Manager: can read scheduled task CV if in server_template's - user_ids/manager_ids.""" - # Create scheduled task with server template - server_template_task = self.ScheduledTask.create( - { - "name": "Test Server Template Scheduled Task for CV", - "action": "command", - "command_id": self.command_list_dir.id, - "interval_number": 1, - "interval_type": "days", - "next_call": fields.Datetime.now(), - "server_template_ids": [(6, 0, [self.server_template_sample.id])], - } - ) - server_template_cv = self.ScheduledTaskCv.create( - { - "scheduled_task_id": server_template_task.id, - "variable_id": self.variable_os.id, - "value_char": "Debian", - } - ) - - # Test access via server_template manager_ids - self.server_template_sample.manager_ids = [(6, 0, [self.manager.id])] - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", server_template_cv.id)] - ) - self.assertIn( - server_template_cv, - cvs, - "Manager should be able to read CV via server_template manager_ids.", - ) - - # Test access via server_template user_ids - self.server_template_sample.manager_ids = [(5, 0, 0)] - self.server_template_sample.user_ids = [(6, 0, [self.manager.id])] - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", server_template_cv.id)] - ) - self.assertIn( - server_template_cv, - cvs, - "Manager should be able to read CV via server_template user_ids.", - ) - - # Remove manager from everywhere - self._clear_all_access( - server_template_task, - server_template=self.server_template_sample, - server=self.server_test_1, - ) - cvs = self.ScheduledTaskCv.with_user(self.manager).search( - [("id", "=", server_template_cv.id)] - ) - self.assertNotIn( - server_template_cv, - cvs, - "Manager should NOT be able to read CV without relation.", - ) - - def test_scheduled_task_cv_manager_write_create_access(self): - """Manager: can create/write CV if in scheduled task's manager_ids.""" - # Create CV as manager - self.command_scheduled_task.manager_ids = [(6, 0, [self.manager.id])] - cv = self.ScheduledTaskCv.with_user(self.manager).create( - { - "scheduled_task_id": self.command_scheduled_task.id, - "variable_id": self.variable_os.id, - "value_char": "Ubuntu", - } - ) - try: - cv.with_user(self.manager).write({"value_char": "Fedora"}) - except AccessError: - self.fail( - "Manager should be able to write CV if in scheduled task manager_ids." - ) - - # Should fail if not in manager_ids - self.command_scheduled_task.manager_ids = [(5, 0, 0)] - with self.assertRaises(AccessError): - self.scheduled_task_cv_os.with_user(self.manager).write( - {"value_char": "CentOS"} - ) - - def test_scheduled_task_cv_manager_unlink_access(self): - """Manager: can unlink CV only if in scheduled task's manager_ids & creator.""" - # Create CV as manager - self.command_scheduled_task.manager_ids = [(6, 0, [self.manager.id])] - cv = self.ScheduledTaskCv.with_user(self.manager).create( - { - "scheduled_task_id": self.command_scheduled_task.id, - "variable_id": self.variable_os.id, - "value_char": "Arch", - } - ) - try: - cv.with_user(self.manager).unlink() - except AccessError: - self.fail("Manager should be able to unlink CV they created.") - - # Not creator - self.command_scheduled_task.manager_ids = [(6, 0, [self.manager.id])] - with self.assertRaises(AccessError): - self.scheduled_task_cv_os.with_user(self.manager).unlink() - - def test_scheduled_task_cv_root_unrestricted_access(self): - """Root: full unrestricted access to all scheduled task CVs.""" - # Read - cvs = self.ScheduledTaskCv.with_user(self.root).search( - [("id", "=", self.scheduled_task_cv_os.id)] - ) - self.assertIn( - self.scheduled_task_cv_os, - cvs, - "Root should be able to read any CV.", - ) - - # Create - cv = self.ScheduledTaskCv.with_user(self.root).create( - { - "scheduled_task_id": self.command_scheduled_task.id, - "variable_id": self.variable_os.id, - "value_char": "SUSE", - } - ) - try: - cv.with_user(self.root).write({"value_char": "OpenSUSE"}) - cv.with_user(self.root).unlink() - except AccessError: - self.fail("Root should be able to write/unlink any scheduled task CV.") diff --git a/addons/cetmix_tower_server/tests/test_server.py b/addons/cetmix_tower_server/tests/test_server.py deleted file mode 100644 index d279423..0000000 --- a/addons/cetmix_tower_server/tests/test_server.py +++ /dev/null @@ -1,890 +0,0 @@ -from odoo.exceptions import AccessError, ValidationError - -from ..models.constants import COMMAND_NOT_COMPATIBLE_WITH_SERVER -from .common import TestTowerCommon - - -class TestTowerServer(TestTowerCommon): - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.os_ubuntu_20_04 = cls.env["cx.tower.os"].create({"name": "Ubuntu 20.04"}) - - # Define model variables to avoid unsubscriptable errors - Key = cls.env["cx.tower.key"] - Server = cls.env["cx.tower.server"] - - secret_1 = Key.create( - { - "name": "Secret 1", - "secret_value": "secret_value_1", - "key_type": "s", - }, - ) - secret_2 = Key.create( - { - "name": "Secret 2", - "secret_value": "secret_value_2", - "key_type": "s", - }, - ) - cls.server_test_2 = Server.create( - { - "name": "Test Server #2", - "color": 2, - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "k", - "host_key": "test_key", - "use_sudo": "p", - "ssh_key_id": cls.key_1.id, - "os_id": cls.os_ubuntu_20_04.id, - "secret_ids": [ - ( - 0, - 0, - { - "key_id": secret_1.id, - "secret_value": "secret_value_1", - }, - ), - ( - 0, - 0, - { - "key_id": secret_2.id, - "secret_value": "secret_value_2", - }, - ), - ], - "tag_ids": [(6, 0, [cls.tag_test_production.id])], - } - ) - - # Files - File = cls.env["cx.tower.file"] - cls.server_test_2_file = File.create( - { - "name": "tower_demo_without_template_{{ branch }}.txt", - "source": "tower", - "server_id": cls.server_test_2.id, - "server_dir": "{{ test_path }}", - "code": "Please, check url: {{ url }}", - } - ) - - # Flight plan to delete the server - Command = cls.env["cx.tower.command"] - Plan = cls.env["cx.tower.plan"] - - # Add a command to delete the server - cls.command_delete_server = Command.create( - { - "name": "Python command for deleting server", - "action": "python_code", - "code": """ -partner = env["res.partner"].create({"name": "Partner 1", "ref": "delete_server"}) -result = { - "exit_code": 0, - "message": partner.name, -} - """, - } - ) - - cls.plan_delete_server = Plan.create( - { - "name": "Delete server", - "line_ids": [ - (0, 0, {"command_id": cls.command_delete_server.id, "sequence": 1}), - ], - } - ) - - # Create two test users that belong only to the "User" group. - cls.user1 = cls.Users.create( - { - "name": "Test User 1", - "login": "test_user1", - "email": "test_user1@example.com", - "groups_id": [(6, 0, [cls.group_user.id])], - } - ) - cls.user2 = cls.Users.create( - { - "name": "Test User 2", - "login": "test_user2", - "email": "test_user2@example.com", - "groups_id": [(6, 0, [cls.group_user.id])], - } - ) - # Create two "Manager" group users. - cls.manager1 = cls.Users.create( - { - "name": "Manager 1", - "login": "manager1", - "email": "manager1@example.com", - "groups_id": [(6, 0, [cls.group_manager.id])], - } - ) - cls.manager2 = cls.Users.create( - { - "name": "Manager 2", - "login": "manager2", - "email": "manager2@example.com", - "groups_id": [(6, 0, [cls.group_manager.id])], - } - ) - - def test_server_copy(self): - """Test server copy""" - - # Let's say we have auto sync enabled on one of the files in server 2 - self.server_test_2_file.auto_sync = True - fields_to_check = [ - "ip_v4_address", - "ip_v6_address", - "ssh_username", - "ssh_password", - "ssh_key_id", - ] - - # Crete a log from file of type 'server' - file_for_log = self.File.create( - { - "source": "server", - "name": "test.log", - "server_dir": "/tmp", - "server_id": self.server_test_2.id, - "code": "Some log record - server", - } - ) - - server_log_server = self.ServerLog.create( - { - "name": "Log from file", - "server_id": self.server_test_2.id, - "log_type": "file", - "file_id": file_for_log.id, - } - ) - # Add variable values to server - self.env["cx.tower.variable.value"].create( - { - "server_id": self.server_test_2.id, - "variable_id": self.variable_dir.id, - "value_char": "test", - } - ) - - # Copy server 2 - server_test_2_copy = self.server_test_2.copy() - - # The name of copy should contain '~ (copy)' suffix - self.assertTrue( - server_test_2_copy.name == self.server_test_2.name + " (copy)", - msg="Server name should contain '~ (copy)' suffix!", - ) - - # Check server logs - # Check that the copied server has the same number of server logs - self.assertEqual( - len(server_test_2_copy.server_log_ids), - len(self.server_test_2.server_log_ids), - ( - "Copied template should have the same " - "number of server logs as the original" - ), - ) - - # Ensure the first server log in the copied server matches the original - copied_log = server_test_2_copy.server_log_ids - self.assertEqual( - copied_log.name, - server_log_server.name, - "Server log name should be the same in the copied server", - ) - self.assertEqual( - copied_log.command_id.id, - server_log_server.command_id.id, - "Command ID should be the same in the copied server log", - ) - self.assertEqual( - copied_log.command_id.code, - server_log_server.command_id.code, - "Command code should be the same in the copied server log", - ) - - # Check fields match list - for field_ in fields_to_check: - self.assertTrue( - getattr(server_test_2_copy, field_) - == getattr(self.server_test_2, field_), - msg=( - f"Field {field_} value on server copy " - "does not match with the source!" - ), - ) - - # Check if auto sync is disabled on the all the files - # in the copied server - self.assertTrue( - all([not file.auto_sync for file in server_test_2_copy.file_ids]), - msg="Auto sync should be disabled on all the files in the copied server!", - ) - - # Check if 'keep_when_deleted' option is enabled on all the files - # in the copied server - self.assertTrue( - all([file.keep_when_deleted for file in server_test_2_copy.file_ids]), - msg=( - "keep_when_deleted option should be enabled on all the files " - "in the copied server!" - ), - ) - - # Check if secret values of keys in the copied server are the same - # as in source server - self.assertTrue( - all( - [ - key_copy.secret_value == key_src.secret_value - for key_src, key_copy in zip( # noqa: B905 we need to run on Python 3.10 - self.server_test_2.secret_ids.sudo(), - server_test_2_copy.secret_ids.sudo(), - ) - ] - ), - msg=( - "Secret values of keys in the copied server " - "should be the same as in source server!" - ), - ) - - # Variable names and values in server copy should be the same - # as in source server - self.assertTrue( - all( - [ - var_copy.variable_reference == var_src.variable_reference - and var_copy.value_char == var_src.value_char - for var_src, var_copy in zip( # noqa: B905 we need to run on Python 3.10 - self.server_test_2.variable_value_ids, - server_test_2_copy.variable_value_ids, - ) - ] - ), - msg=( - "Variable names and values in server copy " - "should be the same as in source server!" - ), - ) - - # Copy copied server - server_test_2_new_copy = server_test_2_copy.copy() - # Variable names and values in server copy should be the same - # as in source server - self.assertTrue( - all( - [ - var_copy.variable_reference == var_src.variable_reference - and var_copy.value_char == var_src.value_char - and var_copy.reference == f"{var_src.reference}_copy" - for var_src, var_copy in zip( # noqa: B905 we need to run on Python 3.10 - server_test_2_copy.variable_value_ids, - server_test_2_new_copy.variable_value_ids, - ) - ] - ), - msg=( - "Variable names and values in server copy " - "should be the same as in source server!" - ), - ) - - def test_server_archive_unarchive(self): - """Test Server archived/unarchived""" - server = self.server_test_1.copy() - self.assertTrue(server, msg="Server must be unarchived") - server.toggle_active() - server.toggle_active() - self.assertTrue(server, msg="Server must be unarchived") - - def test_server_unlink(self): - """ - Test cascading deletion of server and its related records. - """ - secret_1 = self.Key.create( - { - "name": "Secret 1", - "secret_value": "secret_value_1", - "key_type": "s", - }, - ) - # Create a test server - server = self.Server.create( - { - "name": "Test Server #3", - "color": 3, - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "k", - "use_sudo": "p", - "ssh_key_id": self.key_1.id, - "host_key": "test_key", - "os_id": self.os_ubuntu_20_04.id, - "secret_ids": [ - ( - 0, - 0, - { - "key_id": secret_1.id, - "secret_value": "secret_value_1", - }, - ), - ], - } - ) - - # Create related file - file = self.File.create( - {"name": "Test File", "server_id": server.id, "source": "server"} - ) - - # Related secret - secret = server.secret_ids[0] - - variable_meme = self.Variable.create({"name": "meme"}) - - # Create related variable value - variable_value = self.env["cx.tower.variable.value"].create( - { - "variable_id": variable_meme.id, # Replace with valid reference - "value_char": "Test Value", - "server_id": server.id, - } - ) - plan_1 = self.Plan.create( - { - "name": "Test plan", - "note": "Create directory and list its content", - } - ) - # Create a related plan log - plan_log = self.PlanLog.create( - { - "server_id": server.id, - "plan_id": plan_1.id, # Replace with valid reference - } - ) - - # Check that all records are created - self.assertTrue(server, "Server should be created successfully") - self.assertTrue(file, "File should be created successfully") - self.assertTrue(secret, "Secret should be created successfully") - self.assertTrue(variable_value, "Variable Value should be created successfully") - self.assertTrue(plan_log, "Plan Log should be created successfully") - - # Collect IDs for verification post-deletion - file_id = file.id - variable_value_id = variable_value.id - plan_log_id = plan_log.id - - # Delete the server - server.unlink() - - # Verify that the server is deleted - self.assertFalse( - self.Server.search([("id", "=", server.id)]), - msg="Server should be deleted", - ) - # Verify that related records are deleted - self.assertFalse( - self.File.search([("id", "=", file_id)]), - msg="File should be deleted when server is deleted", - ) - # Verify that unrelated records are not affected - self.assertTrue( - self.Plan.search([("id", "=", plan_1.id)]), - msg="Unrelated plan should not be deleted when server is deleted", - ) - self.assertFalse( - self.KeyValue.search([("id", "=", secret.id)]), - msg="Secret should be deleted when server is deleted", - ) - self.assertFalse( - self.VariableValue.search([("id", "=", variable_value_id)]), - msg="Variable Value should be deleted when server is deleted", - ) - self.assertFalse( - self.PlanLog.search([("id", "=", plan_log_id)]), - msg="Plan Log should be deleted when server is deleted", - ) - - def test_server_delete_plan_success(self): - """Test server delete plan""" - - # Set plan to delete the server - self.server_test_2.plan_delete_id = self.plan_delete_server.id - - # Delete the server - self.server_test_2.unlink() - - # Check if the server has been deleted - self.assertFalse( - self.server_test_2.exists(), - msg="Server should be deleted", - ) - - # Check if the partner has been created - self.assertTrue( - self.env["res.partner"].search([("ref", "=", "delete_server")]), - msg="Partner should be created", - ) - - def test_server_delete_plan_error(self): - """Test server delete plan error""" - - # Modify the command to fail - self.command_delete_server.code = """ -result = { - "exit_code": 4, - "message": 'Such much error', -} - """ - # Set plan to delete the server - self.server_test_2.plan_delete_id = self.plan_delete_server.id - - # Delete the server - self.server_test_2.unlink() - - # Check if the server has been deleted - self.assertTrue( - self.server_test_2.exists(), - msg="Server should not be deleted", - ) - - self.assertEqual( - self.server_test_2.status, - "delete_error", - msg="Server status should be delete_error", - ) - - # ------------------------------------------------------------ - # ---- Access - # ------------------------------------------------------------ - def test_user_record_not_visible_without_user_ids(self): - """ - Test that a user in the 'cetmix_tower_server.group_user' group cannot see - a Tower Server record if not added to user_ids. - """ - # Create a Tower Server record without any user_ids. - record = self.Server.create( - { - "name": "User Visibility Test", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "user_ids": [(5, 0, 0)], - } - ) - # As user1, search for the record. Since user1's partner is not subscribed, - # the record should not be returned. - records = self.Server.with_user(self.user1).search([("id", "=", record.id)]) - self.assertFalse( - records, - "User1 should not see the record if not added to user_ids.", - ) - - def test_user_record_visible_after_added_to_user_ids(self): - """ - Test that a user sees a Tower Server record after being added to user_ids. - """ - record = self.Server.create( - { - "name": "User Visibility Test", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "user_ids": [(4, self.user1.id)], - } - ) - # Now, as user1 the record should be visible. - records = self.Server.with_user(self.user1).search([("id", "=", record.id)]) - self.assertTrue( - records, - "User1 should see the record after being added to message_partner_ids.", - ) - - def test_only_added_user_can_see(self): - """ - Test that only the added user can see the Tower Server record. - """ - record = self.Server.create( - { - "name": "User Visibility Test", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "user_ids": [(4, self.user1.id)], - } - ) - # Subscribe only user1's partner. - records_user1 = self.Server.with_user(self.user1).search( - [("id", "=", record.id)] - ) - records_user2 = self.Server.with_user(self.user2).search( - [("id", "=", record.id)] - ) - self.assertTrue( - records_user1, "User1 should see the record after being added to user_ids." - ) - self.assertFalse( - records_user2, - "User2 should not see the record if they are not added to user_ids.", - ) - - def test_manager_read_access_as_follower(self): - """A manager should be able to read a record if his partner is a follower.""" - - # Create a record without any managers in manager_ids. - record = self.Server.create( - { - "name": "Test Server (Follower)", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - # Explicitly clear manager_ids - "manager_ids": [(6, 0, [])], - } - ) - # Subscribe manager1 to the record so that his partner becomes a follower. - record.write({"user_ids": [(4, self.manager1.id)]}) - - # As manager1 (a follower) the record should be visible. - records = self.Server.with_user(self.manager1).search([("id", "=", record.id)]) - self.assertTrue(records, "Manager1 (user) must be able to read the record.") - - # As manager2 (not a follower and not in manager_ids) - # the record should not be visible. - records = self.Server.with_user(self.manager2).search([("id", "=", record.id)]) - self.assertFalse( - records, - "Manager2 (not user_ids and not in manager_ids) must not see the record.", - ) - - def test_manager_read_access_as_manager_ids(self): - """A manager should be able to read a record if he is added to manager_ids.""" - - # Create a record with manager2 added to manager_ids. - record = self.Server.create( - { - "name": "Test Server (Manager)", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "manager_ids": [(6, 0, [self.manager2.id])], - } - ) - # Without adding to user_ids, manager2 should be able to see the record. - records = self.Server.with_user(self.manager2).search([("id", "=", record.id)]) - self.assertTrue( - records, "Manager2 (in manager_ids) must be able to read the record." - ) - - # Manager1 is not added to user_ids nor in manager_ids - # so should not see the record. - records = self.Server.with_user(self.manager1).search([("id", "=", record.id)]) - self.assertFalse( - records, - "Manager1 (neither user_ids nor in manager_ids) must not see the record.", - ) - - # Add manager1 to user_ids - record.write({"user_ids": [(4, self.manager1.id)]}) - records = self.Server.with_user(self.manager1).search([("id", "=", record.id)]) - self.assertTrue( - records, - "Manager1 (added to user_ids) must be able to see the record.", - ) - - def test_manager_write_access(self): - """A manager should be able to update a record only if he is in manager_ids.""" - - # Create a record with no managers. - record = self.Server.create( - { - "name": "Test Server (Write)", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "manager_ids": [(6, 0, [])], - } - ) - - # Manager1 (not in manager_ids) tries to update: should raise an AccessError. - with self.assertRaises(AccessError): - record.with_user(self.manager1).write({"name": "Updated Name"}) - - # Update the record to include manager1 in manager_ids. - record.write({"manager_ids": [(4, self.manager1.id)]}) - try: - record.with_user(self.manager1).write({"name": "Updated Name"}) - except AccessError: - self.fail( - "Manager1 must be able to update the " - "record after being added to manager_ids." - ) - - def test_manager_create_access(self): - """ - A manager should be allowed to create a record only if he is added - in the "Managers". - """ - # Manager1 attempts to create a record without including himself in manager_ids. - with self.assertRaises(AccessError): - self.Server.with_user(self.manager1).create( - { - "name": "Test Server (Create Denied)", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "manager_ids": [(6, 0, [])], - } - ) - - # Manager1 creates a record with himself added to manager_ids. - try: - record = self.Server.with_user(self.manager1).create( - { - "name": "Test Server (Create Allowed)", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "manager_ids": [(6, 0, [self.manager1.id])], - } - ) - self.assertTrue( - record, - "Manager1 must be able to create the record if he is in manager_ids.", - ) - except AccessError: - self.fail( - "Manager1 should be allowed to create a " - "record when included in manager_ids." - ) - - def test_manager_delete_access(self): - """ - A manager should be allowed to delete a record only if: - - He is in the manager_ids field, and - - He is the creator of the record. - """ - - # -- Scenario 1: Manager1 creates a record with himself in manager_ids. - record = self.Server.with_user(self.manager1).create( - { - "name": "Test Server (Delete Allowed)", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "manager_ids": [(6, 0, [self.manager1.id])], - } - ) - # Manager1 should be able to delete his own record. - try: - record.with_user(self.manager1).unlink() - except AccessError: - self.fail( - "Manager1 must be able to delete his own record if in manager_ids." - ) - - # -- Scenario 2: Manager2 creates a record (with himself in manager_ids). - record2 = self.Server.with_user(self.manager2).create( - { - "name": "Test Server (Delete Denied - Not Creator)", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "manager_ids": [(6, 0, [self.manager2.id, self.manager1.id])], - } - ) - # Manager1, should not be able to delete record2. - with self.assertRaises(AccessError): - record2.with_user(self.manager1).unlink() - - # Remove manager2 from manager_ids. - record2.write({"manager_ids": [(6, 0, [])]}) - - # Manager2 should not be able to delete record2 now - # because he is not in manager_ids. - with self.assertRaises(AccessError): - record2.with_user(self.manager2).unlink() - - def test_command_server_compatibility(self): - """Test command compatibility with servers""" - # Create a command restricted to specific servers - command = self.Command.create( - { - "name": "Restricted Command", - "action": "ssh_command", - "code": "echo 'test'", - "server_ids": [(6, 0, [self.server_test_1.id])], - } - ) - - # Should work on allowed server - try: - self.server_test_1.run_command(command) - except Exception as e: - self.fail(f"Command should execute on allowed server but failed: {e}") - - # Should fail on non-allowed server - command_result = self.server_test_2.with_context( - no_command_log=True - ).run_command(command) - self.assertEqual( - command_result["status"], - COMMAND_NOT_COMPATIBLE_WITH_SERVER, - "Command should not execute on non-allowed server", - ) - - # Clear all existing command logs - self.CommandLog.search([]).unlink() - # Same test but with command log - self.server_test_2.run_command(command) - - command_log = self.CommandLog.search([]) - self.assertEqual(len(command_log), 1, "Must be a single log record") - self.assertEqual( - command_log.command_status, - COMMAND_NOT_COMPATIBLE_WITH_SERVER, - "Command should not execute on non-allowed server", - ) - - # Command without server restrictions should work on any server - unrestricted_command = self.Command.create( - { - "name": "Unrestricted Command", - "action": "ssh_command", - "code": "echo 'test'", - } - ) - - try: - self.server_test_1.run_command(unrestricted_command) - self.server_test_2.run_command(unrestricted_command) - except Exception as e: - self.fail( - f"Unrestricted command should execute on any server but failed: {e}" - ) - - def test_server_host_key_validation(self): - """Test server host key validation""" - server = self.Server.create( - { - "name": "Test Server", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "host_key": "test_key", - "skip_host_key": False, - } - ) - # Test with host key - server.test_ssh_connection() - - # Test without host key - server.host_key = None - with self.assertRaises(ValidationError): - server.test_ssh_connection() - - # Test with skip_host_key - server.skip_host_key = True - server.test_ssh_connection() - - def test_server_reference_update(self): - """Test server reference update cascades to dependent models""" - # 1. Add a variable value to server_test_1 - variable_value = self.VariableValue.create( - { - "variable_id": self.variable_os.id, - "value_char": "Ubuntu 20.04", - "server_id": self.server_test_1.id, - } - ) - - # 2. Add a file to server_test_1 - server_file = self.File.create( - { - "name": "test_file.txt", - "server_id": self.server_test_1.id, - "source": "tower", - "code": "Test file content", - } - ) - - # Store original references for comparison - original_server_reference = self.server_test_1.reference - original_variable_value_reference = variable_value.reference - original_file_reference = server_file.reference - - # 3. Change the reference for server_test_1 to "awesome_server" - self.server_test_1.write({"reference": "awesome_server"}) - - # 4. Verify that references are updated for dependent models - # Invalidate models to refresh all references - self.env["cx.tower.server"].invalidate_model(["reference"]) - self.env["cx.tower.variable.value"].invalidate_model(["reference"]) - self.env["cx.tower.file"].invalidate_model(["reference"]) - - # Check that server reference was updated - self.assertEqual(self.server_test_1.reference, "awesome_server") - self.assertNotEqual(self.server_test_1.reference, original_server_reference) - - # Check that variable value reference was updated - # to include the new server reference - self.assertIn("awesome_server", variable_value.reference) - self.assertNotEqual(variable_value.reference, original_variable_value_reference) - - # Check that file reference was updated to include the new server reference - self.assertIn("awesome_server", server_file.reference) - self.assertNotEqual(server_file.reference, original_file_reference) - - # Verify the reference pattern for variable value follows the expected format: - # ___ # noqa: E501 - expected_variable_pattern = ( - f"{self.variable_os.reference}_variable_value_server_" - f"{self.server_test_1.reference}" - ) - self.assertEqual(variable_value.reference, expected_variable_pattern) - - # Verify the reference pattern for file follows the expected format: - # __ - expected_file_pattern = f"{self.server_test_1.reference}_file_1" - self.assertEqual(server_file.reference, expected_file_pattern) diff --git a/addons/cetmix_tower_server/tests/test_server_jet_action_command.py b/addons/cetmix_tower_server/tests/test_server_jet_action_command.py deleted file mode 100644 index 2f20337..0000000 --- a/addons/cetmix_tower_server/tests/test_server_jet_action_command.py +++ /dev/null @@ -1,231 +0,0 @@ -# Copyright (C) 2024 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from unittest.mock import patch - -from odoo import _ -from odoo.exceptions import ValidationError - -from odoo.addons.cetmix_tower_server.models.constants import ( - GENERAL_ERROR, - JET_NOT_FOUND, - JET_TEMPLATE_NOT_FOUND, -) - -from .common_jets import TestTowerJetsCommon - - -class TestTowerServerJetActionCommand(TestTowerJetsCommon): # pylint: disable=protected-access - """Tests for cx.tower.server._command_runner_jet_action.""" - - def _create_jet_action_command(self, jet_template, jet_action): - """Create a command that triggers a jet action for the given template.""" - return self.Command.create( - { - "name": "Test jet action command", - "action": "jet_action", - "jet_template_id": jet_template.id, - "jet_action_id": jet_action.id, - } - ) - - def _create_jet_action_log(self, jet, command): - """Create a command log bound to a jet and command.""" - return self.CommandLog.create( - { - "server_id": jet.server_id.id, - "command_id": command.id, - "jet_id": jet.id, - } - ) - - def test_command_runner_jet_action_requires_log_record(self): - """Calling without a log record must raise ValidationError.""" - with self.assertRaises(ValidationError): - self.server_test_1._command_runner_jet_action(False) - - def test_command_runner_jet_action_missing_jet_action(self): - """Missing command jet_action_id finishes with GENERAL_ERROR.""" - command = self._create_jet_action_command( - self.jet_template_test, - self.action_stopped_to_running, - ) - command.write({"jet_action_id": False}) - log = self._create_jet_action_log(self.jet_test, command) - - result = self.server_test_1._command_runner_jet_action(log) - - self.assertEqual(result["status"], GENERAL_ERROR) - self.assertEqual(result["response"], None) - self.assertEqual(result["error"], _("Jet action is not found.")) - log.invalidate_recordset() - self.assertEqual(log.command_status, GENERAL_ERROR) - - def test_command_runner_jet_action_missing_jet(self): - """Missing jet on the log finishes with JET_NOT_FOUND.""" - command = self._create_jet_action_command( - self.jet_template_test, - self.action_stopped_to_running, - ) - log = self.CommandLog.create( - { - "server_id": self.server_test_1.id, - "command_id": command.id, - "jet_id": False, - } - ) - - result = self.server_test_1._command_runner_jet_action(log) - - self.assertEqual(result["status"], JET_NOT_FOUND) - self.assertIsNotNone(result["error"]) - - def test_command_runner_jet_action_missing_jet_template(self): - """ - Missing jet_template_id on the command finishes with - JET_TEMPLATE_NOT_FOUND. - """ - command = self._create_jet_action_command( - self.jet_template_test, - self.action_stopped_to_running, - ) - command.write({"jet_template_id": False}) - log = self._create_jet_action_log(self.jet_test, command) - - result = self.server_test_1._command_runner_jet_action(log) - - self.assertEqual(result["status"], JET_TEMPLATE_NOT_FOUND) - self.assertIsNotNone(result["error"]) - - @patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._trigger_action", - autospec=True, - ) - def test_command_runner_jet_action_success_aggregates_response(self, mock_trigger): - mock_trigger.return_value = {"status": 0, "error": None} - command = self._create_jet_action_command( - self.jet_template_test, - self.action_stopped_to_running, - ) - log = self._create_jet_action_log(self.jet_test, command) - - result = self.server_test_1._command_runner_jet_action(log) - - self.assertEqual(result["status"], 0) - self.assertIsNone(result["error"]) - self.assertTrue(result["response"]) - self.assertIn("Action triggered for", result["response"]) - self.assertIn(self.jet_test.reference, result["response"]) - mock_trigger.assert_called_once() - log.invalidate_recordset() - self.assertEqual(log.command_status, 0) - self.assertIn("Action triggered for", log.command_response) - self.assertFalse(log.command_error) - - @patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._trigger_action", - autospec=True, - ) - def test_command_runner_jet_action_failure_single_jet_error_message( - self, mock_trigger - ): - mock_trigger.return_value = {"status": 1, "error": "No action found"} - command = self._create_jet_action_command( - self.jet_template_test, - self.action_stopped_to_running, - ) - log = self._create_jet_action_log(self.jet_test, command) - - result = self.server_test_1._command_runner_jet_action(log) - - self.assertEqual(result["status"], GENERAL_ERROR) - self.assertIsNone(result["response"]) - self.assertTrue(result["error"]) - lines = result["error"].split("\n") - self.assertEqual(len(lines), 2) - self.assertIn("Action triggered for", lines[0]) - self.assertIn(self.jet_test.reference, lines[1]) - self.assertIn("No action found", lines[1]) - - @patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._trigger_action", - autospec=True, - ) - def test_command_runner_jet_action_failure_status_without_error_text( - self, mock_trigger - ): - mock_trigger.return_value = {"status": 99, "error": None} - command = self._create_jet_action_command( - self.jet_template_test, - self.action_stopped_to_running, - ) - log = self._create_jet_action_log(self.jet_test, command) - - result = self.server_test_1._command_runner_jet_action(log) - - self.assertEqual(result["status"], GENERAL_ERROR) - self.assertIn(self.jet_test.reference, result["error"]) - self.assertIn("99", result["error"]) - - @patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._trigger_action", - autospec=True, - ) - def test_command_runner_jet_action_failure_multiple_jets(self, mock_trigger): - jet_b = self._create_jet( - name="Second Jet", - reference="jet_second", - template=self.jet_template_test, - server=self.server_test_1, - ) - - def side_effect(jet_self, *_args, **_kwargs): - jet_self.ensure_one() - if jet_self.id == self.jet_test.id: - return {"status": 1, "error": "No action found"} - return {"status": 2, "error": "Jet is busy"} - - mock_trigger.side_effect = side_effect - - command = self._create_jet_action_command( - self.jet_template_test, - self.action_stopped_to_running, - ) - log = self._create_jet_action_log(self.jet_woocommerce, command) - - with patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._get_dependent_jets_by_template", - autospec=True, - return_value=self.jet_test | jet_b, - ): - result = self.server_test_1._command_runner_jet_action(log) - - self.assertEqual(result["status"], GENERAL_ERROR) - lines = result["error"].split("\n") - self.assertEqual(len(lines), 2) - self.assertIn("Action triggered for", lines[0]) - self.assertIn(self.jet_test.reference, lines[0]) - self.assertIn(jet_b.reference, lines[0]) - agg = lines[1] - self.assertIn(f"{self.jet_test.reference}: No action found", agg) - self.assertIn(f"{jet_b.reference}: Jet is busy", agg) - - @patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_jet.CxTowerJet._get_dependent_jets_by_template", - autospec=True, - ) - def test_command_runner_jet_action_no_dependent_jets(self, mock_deps): - mock_deps.return_value = self.Jet.browse() - command = self._create_jet_action_command( - self.jet_template_test, - self.action_stopped_to_running, - ) - log = self._create_jet_action_log(self.jet_woocommerce, command) - - result = self.server_test_1._command_runner_jet_action(log) - - self.assertEqual(result["status"], 0) - self.assertIsNone(result["error"]) - self.assertTrue(result["response"]) - self.assertIn(self.jet_woocommerce.name, result["response"]) - self.assertIn(self.jet_template_test.name, result["response"]) diff --git a/addons/cetmix_tower_server/tests/test_server_log.py b/addons/cetmix_tower_server/tests/test_server_log.py deleted file mode 100644 index 86cfa2b..0000000 --- a/addons/cetmix_tower_server/tests/test_server_log.py +++ /dev/null @@ -1,657 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo.exceptions import AccessError - -from .common_jets import TestTowerJetsCommon - - -class TestTowerServerLog(TestTowerJetsCommon): - """Test the cx.tower.server.log model access rights.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create test server logs - cls.server_log_1 = cls.ServerLog.create( - { - "name": "Test Log 1", - "server_id": cls.server_test_1.id, - "log_type": "file", - "access_level": "1", - } - ) - - cls.server_log_2 = cls.ServerLog.create( - { - "name": "Test Log 2", - "server_id": cls.server_test_1.id, - "log_type": "file", - "access_level": "1", - } - ) - - # Create additional server for testing - cls.server_2 = cls.Server.create( - { - "name": "Test Server 2", - "ip_v4_address": "localhost", - "ssh_username": "test2", - "ssh_password": "test2", - "ssh_port": 22, - "user_ids": [(6, 0, [])], - "manager_ids": [(6, 0, [])], - } - ) - - # Use pre-created jet_template_test and jet_test from TestTowerJetsCommon - # Ensure jet_template_test has server_test_1 in server_ids - cls.jet_template_test.write({"server_ids": [(4, cls.server_test_1.id)]}) - - # Create server logs linked to Jet - cls.server_log_jet_1 = cls.ServerLog.create( - { - "name": "Test Jet Log 1", - "server_id": cls.server_test_1.id, - "jet_id": cls.jet_test.id, - "log_type": "file", - "access_level": "1", - } - ) - - cls.server_log_jet_2 = cls.ServerLog.create( - { - "name": "Test Jet Log 2", - "server_id": cls.server_test_1.id, - "jet_id": cls.jet_test.id, - "log_type": "file", - "access_level": "2", - } - ) - - # Create server logs linked to Jet Template - cls.server_log_jet_template_1 = cls.ServerLog.create( - { - "name": "Test Jet Template Log 1", - "server_id": cls.server_test_1.id, - "jet_template_id": cls.jet_template_test.id, - "log_type": "file", - "access_level": "1", - } - ) - - cls.server_log_jet_template_2 = cls.ServerLog.create( - { - "name": "Test Jet Template Log 2", - "server_id": cls.server_test_1.id, - "jet_template_id": cls.jet_template_test.id, - "log_type": "file", - "access_level": "2", - } - ) - - def test_user_access(self): - """Test user access to server logs""" - # Add user to server's user_ids - self.server_test_1.write( - { - "user_ids": [(6, 0, [self.user.id])], - } - ) - - # Case 1: User should be able to read when: - # - access_level == "1" - # - user is in server's user_ids - recs = self.ServerLog.with_user(self.user).search( - [("id", "in", [self.server_log_1.id, self.server_log_2.id])] - ) - self.assertEqual( - len(recs), - 2, - "User should be able to read all logs with access_level '1'" - " when in user_ids", - ) - - # Case 2: User should not be able to read when not in server's user_ids - self.server_test_1.write( - { - "user_ids": [(5, 0, 0)], # Remove all users - } - ) - recs = self.ServerLog.with_user(self.user).search( - [("id", "=", self.server_log_1.id)] - ) - self.assertEqual( - len(recs), - 0, - "User should not be able to read when not in server's user_ids", - ) - - # Case 3: User should not be able to read when access_level > "1" - self.server_test_1.write( - { - "user_ids": [(6, 0, [self.user.id])], - } - ) - high_access_log = ( - self.ServerLog.with_user(self.user) - .sudo() - .create( - { - "name": "High Access Log", - "server_id": self.server_test_1.id, - "log_type": "file", - "access_level": "2", - } - ) - ) - - recs = self.ServerLog.with_user(self.user).search( - [("id", "=", high_access_log.id)] - ) - self.assertEqual( - len(recs), - 0, - "User should not be able to read logs with access_level > '1'", - ) - - def test_manager_access(self): - """Test manager access to server logs""" - # Add manager to server's manager_ids - self.server_test_1.write( - { - "manager_ids": [(6, 0, [self.manager.id])], - } - ) - - # Case 1: Manager should be able to read when: - # - access_level <= "2" - # - manager is in server's manager_ids - recs = self.ServerLog.with_user(self.manager).search( - [("id", "in", [self.server_log_1.id, self.server_log_2.id])] - ) - self.assertEqual( - len(recs), - 2, - "Manager should be able to read all logs when in manager_ids", - ) - - # Case 2: Manager should be able to create and write when: - # - access_level <= "2" - # - manager is in server's manager_ids - try: - new_log = self.ServerLog.with_user(self.manager).create( - { - "name": "Manager Test Log", - "server_id": self.server_test_1.id, - "log_type": "file", - "access_level": "2", - } - ) - except AccessError: - self.fail( - "Manager should be able to create logs when in server's manager_ids" - ) - - try: - new_log.write({"name": "Updated Name"}) - except AccessError: - self.fail( - "Manager should be able to write logs when in server's manager_ids" - ) - self.assertEqual(new_log.name, "Updated Name") - - # Case 3: Manager should be able to unlink when: - # - access_level <= "2" - # - created by manager - # - manager is in server's manager_ids - try: - new_log.unlink() - except AccessError: - self.fail( - "Manager should be able to unlink own logs when in server's manager_ids" - ) - - # Case 4: Manager should not be able to unlink logs created by others - with self.assertRaises(AccessError): - self.server_log_1.with_user(self.manager).unlink() - - # Case 5: Manager should not be able to access logs with access_level > "2" - high_access_log = ( - self.ServerLog.with_user(self.manager) - .sudo() - .create( - { - "name": "High Access Log", - "server_id": self.server_test_1.id, - "log_type": "file", - "access_level": "3", - } - ) - ) - - recs = self.ServerLog.with_user(self.manager).search( - [("id", "=", high_access_log.id)] - ) - self.assertEqual( - len(recs), - 0, - "Manager should not be able to read logs with access_level > '2'", - ) - - def test_root_access(self): - """Test root user unrestricted access""" - # Create test logs with various conditions - test_logs = self.ServerLog.with_user(self.root).create( - [ - { - "name": f"Root Test Log {level}", - "server_id": self.server_test_1.id, - "log_type": "file", - "access_level": level, - } - for level in ["1", "2", "3"] - ] - ) - - # Root should be able to read all logs regardless of conditions - recs = self.ServerLog.with_user(self.root).search([("id", "in", test_logs.ids)]) - self.assertEqual( - len(recs), - 3, - "Root should have unrestricted read access to all logs", - ) - - # Root should be able to write all logs - try: - for log in test_logs: - log.write({"name": "Updated by Root"}) - except AccessError: - self.fail("Root should be able to write any logs") - - # Root should be able to unlink all logs - try: - test_logs.unlink() - except AccessError: - self.fail("Root should be able to unlink any logs") - - def test_log_text_access_restrictions(self): - """Test log_text field access controls""" - test_log = self.ServerLog.create( - { - "name": "Access Test Log", - "server_id": self.server_test_1.id, - "log_type": "file", - "access_level": "1", - "log_text": "

Test content

", - } - ) - - # 1. Verify read access for all roles - for user in (self.root, self.manager, self.user): - content = test_log.with_user(user).log_text - self.assertEqual( - content, "

Test content

", f"{user.name} should read log_text" - ) - - # 2. Verify write prohibition for all roles - for user in (self.root, self.manager, self.user): - with self.assertRaises( - AccessError, msg=f"{user.name} shouldn't modify log_text" - ): - test_log.with_user(user).write({"log_text": "

Modified

"}) - - def test_log_text_refresh_mechanism(self): - """Test log_text can only be updated via refresh action""" - test_log = self.ServerLog.create( - { - "name": "Refresh Test Log", - "server_id": self.server_test_1.id, - "log_type": "file", - "access_level": "1", - "log_text": "

Initial

", - } - ) - - # 1. Direct write attempts should fail - with self.assertRaises(AccessError): - test_log.sudo().write({"log_text": "

Illegal Update

"}) - - # 2. Verify refresh action updates content - original_content = test_log.log_text - test_log.action_update_log() - - self.assertNotEqual( - test_log.log_text, - original_content, - "action_update_log() should update log_text", - ) - - def test_log_text_copy(self): - """Duplicating a log must NOT keep the log output""" - original = self.ServerLog.create( - { - "name": "Original Log", - "server_id": self.server_test_1.id, - "log_type": "file", - "access_level": "1", - "log_text": "

Original content

", - } - ) - - copied = original.copy() - - # log_text must be cleared because copy=False - self.assertFalse(copied.log_text, "Copied log must not keep log_text") - self.assertNotEqual(copied.id, original.id) - self.assertTrue(bool(copied.name)) - - def test_jet_user_access(self): - """Test user access to server logs via Jet""" - # Set user to jet's user_ids (replaces any existing users) - self.jet_test.write({"user_ids": [(6, 0, [self.user.id])]}) - - # Case 1: User should be able to read when: - # - access_level == "1" - # - user is in jet's user_ids - recs = self.ServerLog.with_user(self.user).search( - [("id", "in", [self.server_log_jet_1.id, self.server_log_jet_2.id])] - ) - self.assertEqual( - len(recs), - 1, - "User should be able to read logs with access_level '1'" - " when in jet's user_ids", - ) - self.assertEqual(recs.id, self.server_log_jet_1.id) - - # Case 2: User should not be able to read when not in jet's user_ids - self.jet_test.write({"user_ids": [(5, 0, 0)]}) # Remove all users - recs = self.ServerLog.with_user(self.user).search( - [("id", "=", self.server_log_jet_1.id)] - ) - self.assertEqual( - len(recs), - 0, - "User should not be able to read when not in jet's user_ids", - ) - - # Case 3: User should not be able to read when access_level > "1" - # Set user back to jet's user_ids - self.jet_test.write({"user_ids": [(6, 0, [self.user.id])]}) - recs = self.ServerLog.with_user(self.user).search( - [("id", "=", self.server_log_jet_2.id)] - ) - self.assertEqual( - len(recs), - 0, - "User should not be able to read logs with access_level > '1'", - ) - - def test_jet_manager_access(self): - """Test manager access to server logs via Jet""" - # Set manager to jet's manager_ids (replaces any existing managers) - self.jet_test.write({"manager_ids": [(6, 0, [self.manager.id])]}) - - # Case 1: Manager should be able to read when: - # - access_level <= "2" - # - manager is in jet's user_ids or manager_ids - recs = self.ServerLog.with_user(self.manager).search( - [("id", "in", [self.server_log_jet_1.id, self.server_log_jet_2.id])] - ) - self.assertEqual( - len(recs), - 2, - "Manager should be able to read all logs when in jet's manager_ids", - ) - - # Case 2: Manager should be able to create and write when: - # - access_level <= "2" - # - manager is in jet's manager_ids - try: - new_log = self.ServerLog.with_user(self.manager).create( - { - "name": "Manager Jet Test Log", - "server_id": self.server_test_1.id, - "jet_id": self.jet_test.id, - "log_type": "file", - "access_level": "2", - } - ) - except AccessError: - self.fail("Manager should be able to create logs when in jet's manager_ids") - - try: - new_log.write({"name": "Updated Jet Name"}) - except AccessError: - self.fail("Manager should be able to write logs when in jet's manager_ids") - self.assertEqual(new_log.name, "Updated Jet Name") - - # Case 3: Manager should be able to unlink when: - # - access_level <= "2" - # - created by manager - # - manager is in jet's manager_ids - try: - new_log.unlink() - except AccessError: - self.fail( - "Manager should be able to unlink own logs when in jet's manager_ids" - ) - - # Case 4: Manager should not be able to unlink logs created by others - with self.assertRaises(AccessError): - self.server_log_jet_1.with_user(self.manager).unlink() - - # Case 5: Manager should not be able to access logs with access_level > "2" - high_access_log = ( - self.ServerLog.with_user(self.manager) - .sudo() - .create( - { - "name": "High Access Jet Log", - "server_id": self.server_test_1.id, - "jet_id": self.jet_test.id, - "log_type": "file", - "access_level": "3", - } - ) - ) - - recs = self.ServerLog.with_user(self.manager).search( - [("id", "=", high_access_log.id)] - ) - self.assertEqual( - len(recs), - 0, - "Manager should not be able to read logs with access_level > '2'", - ) - - # Case 6: Manager should be able to read when in jet's user_ids - # Remove managers and add manager to jet's user_ids - self.jet_test.write( - { - "manager_ids": [(5, 0, 0)], # Remove managers - "user_ids": [(6, 0, [self.manager.id])], # Set to users - } - ) - recs = self.ServerLog.with_user(self.manager).search( - [("id", "in", [self.server_log_jet_1.id, self.server_log_jet_2.id])] - ) - self.assertEqual( - len(recs), - 2, - "Manager should be able to read when in jet's user_ids", - ) - - def test_jet_template_user_access(self): - """Test user access to server logs via Jet Template""" - # Set user to jet template's user_ids (replaces any existing users) - self.jet_template_test.write({"user_ids": [(6, 0, [self.user.id])]}) - - # Case 1: User should be able to read when: - # - access_level == "1" - # - user is in jet template's user_ids - recs = self.ServerLog.with_user(self.user).search( - [ - ( - "id", - "in", - [ - self.server_log_jet_template_1.id, - self.server_log_jet_template_2.id, - ], - ) - ] - ) - self.assertEqual( - len(recs), - 1, - "User should be able to read logs with access_level '1'" - " when in jet template's user_ids", - ) - self.assertEqual(recs.id, self.server_log_jet_template_1.id) - - # Case 2: User should not be able to read when not in jet template's user_ids - self.jet_template_test.write({"user_ids": [(5, 0, 0)]}) # Remove all users - recs = self.ServerLog.with_user(self.user).search( - [("id", "=", self.server_log_jet_template_1.id)] - ) - self.assertEqual( - len(recs), - 0, - "User should not be able to read when not in jet template's user_ids", - ) - - # Case 3: User should not be able to read when access_level > "1" - # Set user back to jet template's user_ids - self.jet_template_test.write({"user_ids": [(6, 0, [self.user.id])]}) - recs = self.ServerLog.with_user(self.user).search( - [("id", "=", self.server_log_jet_template_2.id)] - ) - self.assertEqual( - len(recs), - 0, - "User should not be able to read logs with access_level > '1'", - ) - - def test_jet_template_manager_access(self): - """Test manager access to server logs via Jet Template""" - # Set manager to jet template's manager_ids (replaces any existing managers) - self.jet_template_test.write({"manager_ids": [(6, 0, [self.manager.id])]}) - - # Case 1: Manager should be able to read when: - # - access_level <= "2" - # - manager is in jet template's user_ids or manager_ids - recs = self.ServerLog.with_user(self.manager).search( - [ - ( - "id", - "in", - [ - self.server_log_jet_template_1.id, - self.server_log_jet_template_2.id, - ], - ) - ] - ) - self.assertEqual( - len(recs), - 2, - "Manager should be able to read all logs when" - " in jet template's manager_ids", - ) - - # Case 2: Manager should be able to create and write when: - # - access_level <= "2" - # - manager is in jet template's manager_ids - try: - new_log = self.ServerLog.with_user(self.manager).create( - { - "name": "Manager Jet Template Test Log", - "server_id": self.server_test_1.id, - "jet_template_id": self.jet_template_test.id, - "log_type": "file", - "access_level": "2", - } - ) - except AccessError: - self.fail( - "Manager should be able to create logs when " - "in jet template's manager_ids" - ) - - try: - new_log.write({"name": "Updated Jet Template Name"}) - except AccessError: - self.fail( - "Manager should be able to write logs when " - "in jet template's manager_ids" - ) - self.assertEqual(new_log.name, "Updated Jet Template Name") - - # Case 3: Manager should be able to unlink when: - # - access_level <= "2" - # - created by manager - # - manager is in jet template's manager_ids - try: - new_log.unlink() - except AccessError: - self.fail( - "Manager should be able to unlink own logs" - " when in jet template's manager_ids" - ) - - # Case 4: Manager should not be able to unlink logs created by others - with self.assertRaises(AccessError): - self.server_log_jet_template_1.with_user(self.manager).unlink() - - # Case 5: Manager should not be able to access logs with access_level > "2" - high_access_log = ( - self.ServerLog.with_user(self.manager) - .sudo() - .create( - { - "name": "High Access Jet Template Log", - "server_id": self.server_test_1.id, - "jet_template_id": self.jet_template_test.id, - "log_type": "file", - "access_level": "3", - } - ) - ) - - recs = self.ServerLog.with_user(self.manager).search( - [("id", "=", high_access_log.id)] - ) - self.assertEqual( - len(recs), - 0, - "Manager should not be able to read logs with access_level > '2'", - ) - - # Case 6: Manager should be able to read when in jet template's user_ids - # Remove managers and add manager to jet template's user_ids - self.jet_template_test.write( - { - "manager_ids": [(5, 0, 0)], # Remove managers - "user_ids": [(6, 0, [self.manager.id])], # Set to users - } - ) - recs = self.ServerLog.with_user(self.manager).search( - [ - ( - "id", - "in", - [ - self.server_log_jet_template_1.id, - self.server_log_jet_template_2.id, - ], - ) - ] - ) - self.assertEqual( - len(recs), - 2, - "Manager should be able to read when in jet template's user_ids", - ) diff --git a/addons/cetmix_tower_server/tests/test_server_template.py b/addons/cetmix_tower_server/tests/test_server_template.py deleted file mode 100644 index 2b7f074..0000000 --- a/addons/cetmix_tower_server/tests/test_server_template.py +++ /dev/null @@ -1,1073 +0,0 @@ -from odoo.exceptions import AccessError, ValidationError -from odoo.tests.common import Form - -from .common import TestTowerCommon - - -class TestTowerServerTemplate(TestTowerCommon): - """ - Test the server template model - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create two "Manager" group users - cls.manager1 = cls.Users.create( - { - "name": "Manager 1", - "login": "manager1", - "email": "manager1@example.com", - "groups_id": [(6, 0, [cls.group_manager.id])], - } - ) - cls.manager2 = cls.Users.create( - { - "name": "Manager 2", - "login": "manager2", - "email": "manager2@example.com", - "groups_id": [(6, 0, [cls.group_manager.id])], - } - ) - - def test_create_server_from_template(self): - """ - Create new server from template - """ - self.assertFalse( - self.Server.search( - [("server_template_id", "=", self.server_template_sample.id)] - ), - "The servers shouldn't exist", - ) - # add variable values to server template - self.VariableValue.create( - { - "variable_id": self.variable_version.id, - "server_template_id": self.server_template_sample.id, - "value_char": "test", - } - ) - - # add delete flight plan - self.server_template_sample.plan_delete_id = self.plan_1.id - - # add server logs to template - command_for_log = self.Command.create( - {"name": "Get system info", "code": "uname -a"} - ) - - server_template_log = self.ServerLog.create( - { - "name": "Log from server template", - "server_template_id": self.server_template_sample.id, - "log_type": "command", - "command_id": command_for_log.id, - } - ) - - self.assertEqual( - len(self.variable_version.value_ids), - 1, - "The variable must have one value only", - ) - - server_log = self.ServerLog.search([("command_id", "=", command_for_log.id)]) - self.assertEqual(len(server_log), 1, "Server log must be one") - - # create new server from template - new_server = self.ServerTemplate.create_server_from_template( - self.server_template_sample.reference, - "server_from_template", - ipv4="0.0.0.0", - ) - - server = self.Server.search( - [("server_template_id", "=", self.server_template_sample.id)] - ) - self.assertEqual(new_server, server, "Servers must be the same") - self.assertEqual( - new_server.name, - "server_from_template", - "Server name must be server_from_template", - ) - self.assertEqual( - new_server.ip_v4_address, "0.0.0.0", "Server IP must be 0.0.0.0" - ) - self.assertEqual( - new_server.os_id, self.os_debian_10, "Server os must be Debian" - ) - self.assertEqual(new_server.ssh_port, 22, "Server SSH Port must be 22") - self.assertEqual( - new_server.ssh_username, "admin", "Server SSH Username must be 'admin'" - ) - self.assertEqual( - new_server._get_secret_value("ssh_password"), - "password", - "Server SSH Password must be 'password'", - ) - self.assertEqual( - new_server.ssh_auth_mode, "p", "Server SSH Auth Mode must be 'p'" - ) - self.assertEqual( - len(self.variable_version.value_ids), - 2, - "The variable must have two value only", - ) - self.assertEqual( - new_server.plan_delete_id, - self.plan_1, - "Server On Delete Plan must be 'Test plan 1'", - ) - - server_log = self.ServerLog.search([("command_id", "=", command_for_log.id)]) - self.assertEqual(len(server_log), 2, "Server log must be two") - - server_log = server_log.filtered(lambda rec: rec.server_id == new_server) - self.assertNotEqual(server_log, server_template_log) - - def test_create_server_from_template_wizard(self): - """ - Create new server from template from wizard - """ - action = self.server_template_sample.action_create_server() - wizard = ( - self.env["cx.tower.server.template.create.wizard"] # pylint: disable=context-overridden we need a new clean context - .with_context(action["context"]) - .new({}) - ) - self.assertEqual( - self.server_template_sample, - wizard.server_template_id, - "Server Templates must be the same", - ) - - self.assertFalse( - self.Server.search( - [("server_template_id", "=", self.server_template_sample.id)] - ), - "The servers shouldn't exist", - ) - - wizard.update( - { - "name": "test", - "ip_v4_address": "0.0.0.0", - "use_sudo": "n", - "partner_id": self.user_bob.partner_id.id, - "os_id": self.os_debian_10.id, - "tag_ids": [(4, self.tag_test_production.id)], - } - ) - action = wizard.action_confirm() - - server = self.Server.search( - [("server_template_id", "=", self.server_template_sample.id)] - ) - self.assertEqual(action["res_id"], server.id, "Server ids must be the same") - self.assertEqual( - server.partner_id, self.user_bob.partner_id, "Partner must be the same" - ) - self.assertEqual(server.os_id, self.os_debian_10, "OS must be the same") - self.assertEqual( - server.tag_ids, self.tag_test_production, "Tag must be the same" - ) - self.assertEqual(server.use_sudo, "n", "Use sudo must be the same") - self.assertEqual(server.ip_v4_address, "0.0.0.0", "IP must be the same") - self.assertEqual(server.name, "test", "Name must be the same") - - def test_create_server_from_template_action(self): - """ - Create new server from action - """ - name = "server from template" - self.assertFalse( - self.Server.search([("name", "=", name)]), - "Server should not exist", - ) - # add variable values to server template - self.VariableValue.create( - { - "variable_id": self.variable_version.id, - "server_template_id": self.server_template_sample.id, - "value_char": "test template version", - } - ) - self.VariableValue.create( - { - "variable_id": self.variable_url.id, - "server_template_id": self.server_template_sample.id, - "value_char": "test template url", - } - ) - # add variable option - variable_url_option = self.VariableOption.create( - { - "name": "localhost", - "value_char": "localhost", - "variable_id": self.variable_url.id, - } - ) - - # create new server with new variable - self.ServerTemplate.create_server_from_template( - self.server_template_sample.reference, - "server from template", - ipv4="localhost", - ssh_username="test", - ssh_password="test", - plan_delete_id=self.plan_1.id, - configuration_variables={ - self.variable_version.reference: "test server version", - "new_variable": "new_value", - }, - configuration_variable_options={ - self.variable_url.reference: variable_url_option.reference, - }, - ) - new_server = self.Server.search([("name", "=", name)]) - - self.assertTrue(new_server, "Server must exist!") - self.assertFalse(new_server.plan_delete_id, "On Delete Plan must be empty!") - - self.assertEqual( - len(new_server.variable_value_ids), 3, "Should be 3 variable values!" - ) - - # check variable values - var_version_value = new_server.variable_value_ids.filtered( - lambda rec: rec.variable_id == self.variable_version - ) - self.assertEqual( - var_version_value.value_char, - "test server version", - "Version variable values should be with new values for " - "server from template", - ) - - var_url_value = new_server.variable_value_ids.filtered( - lambda rec: rec.variable_id == self.variable_url - ) - self.assertEqual( - var_url_value.value_char, - variable_url_option.value_char, - "Url variable values should be same as option value", - ) - - var_new_value = new_server.variable_value_ids.filtered( - lambda rec: rec.variable_id.reference == "new_variable" - ) - self.assertTrue(var_new_value, "New variable should exist on the server") - self.assertEqual( - var_new_value.value_char, - "new_value", - "New variable values should be 'new_values'", - ) - - def test_server_template_copy(self): - """ - Test duplicating a Server Template with variable values and server logs - """ - - # A server template - server_template = self.server_template_sample - - # Add variable values to the server template - original_variable_value = self.VariableValue.create( - { - "variable_id": self.variable_version.id, - "server_template_id": server_template.id, - "value_char": "test", - } - ) - - # Create a command for the server log - command_for_log = self.Command.create( - { - "name": "Get system info", - "code": "uname -a", - } - ) - - # Add server logs to the template - original_log = self.ServerLog.create( - { - "name": "Log from server template", - "server_template_id": server_template.id, - "log_type": "command", - "command_id": command_for_log.id, - } - ) - - # Duplicate the server template - copied_template = server_template.copy() - - # Ensure the new server template was created with a new ID - self.assertNotEqual( - copied_template.id, - server_template.id, - "Copied server template should have a different ID from the original", - ) - - # Check that the copied template has the same number of variable values - self.assertEqual( - len(copied_template.variable_value_ids), - len(server_template.variable_value_ids), - ( - "Copied template should have the same " - "number of variable values as the original" - ), - ) - - # Ensure the variable itself was copied (check variable_id) - copied_variable_value = copied_template.variable_value_ids - self.assertEqual( - copied_variable_value.variable_id.id, - original_variable_value.variable_id.id, - "Variable ID should be the same in the copied template", - ) - self.assertEqual( - copied_variable_value.value_char, - original_variable_value.value_char, - "Variable value should be the same in the copied template", - ) - - # Check that the copied template has the same number of server logs - self.assertEqual( - len(copied_template.server_log_ids), - len(server_template.server_log_ids), - ( - "Copied template should have the same " - "number of server logs as the original" - ), - ) - - # Ensure the first server log in the copied template matches the original - copied_log = copied_template.server_log_ids - self.assertEqual( - copied_log.name, - original_log.name, - "Server log name should be the same in the copied template", - ) - self.assertEqual( - copied_log.command_id.id, - original_log.command_id.id, - "Command ID should be the same in the copied server log", - ) - self.assertEqual( - copied_log.command_id.code, - original_log.command_id.code, - "Command code should be the same in the copied server log", - ) - - def test_required_attribute_in_wizard_field(self): - """ - Test that the 'required' attribute - is correctly applied to the 'value_char' field - in the wizard when the variable is marked as required. - """ - # Create a required variable - self.VariableValue.create( - { - "variable_id": self.variable_version.id, - "server_template_id": self.server_template_sample.id, - "value_char": "Test Value", - "required": True, - } - ) - - # Open the wizard - wizard = self.env["cx.tower.server.template.create.wizard"].create( - { - "server_template_id": self.server_template_sample.id, - "name": "Test Server", - "ssh_username": "admin", - } - ) - - # Checking that the 'required' flag is passed to the form context - required_fields = [ - line.required - for line in wizard.line_ids - if line.variable_id == self.variable_version - ] - self.assertTrue( - all(required_fields), - "The 'required' attribute should be correctly " - "applied to the 'value_char' field for required variables.", - ) - - def test_successful_server_creation_with_required_variables(self): - """ - Test that a server is successfully created - when all required variables are filled in the wizard. - """ - # Add manager as user of template - self.server_template_sample.user_ids = self.manager - - # Adding a required variable - self.VariableValue.create( - { - "variable_id": self.variable_version.id, - "server_template_id": self.server_template_sample.id, - "value_char": "", - "required": True, - } - ) - - # Open the wizard and fill in the data as manager - wizard = ( - self.env["cx.tower.server.template.create.wizard"] - .with_user(self.manager) - .create( - { - "server_template_id": self.server_template_sample.id, - "name": "Test Server With Required Variables", - "ssh_username": "admin", - "line_ids": [ - ( - 0, - 0, - { - "variable_id": self.variable_version.id, - "required": True, - }, - ) - ], - } - ) - ) - - # Fill in the value for the required variable - with Form(wizard) as wizard_form: - with wizard_form.line_ids.edit(0) as line: - line.value_char = "Test Value" - wizard_form.save() - - # Checking the successful creation of the server - action = wizard.action_confirm() - self.assertTrue(action, "Server should be created successfully.") - - # Checking that the server has been created - server = self.Server.search( - [ - ("name", "=", "Test Server With Required Variables"), - ("server_template_id", "=", self.server_template_sample.id), - ] - ) - self.assertTrue(server, "Server should exist.") - self.assertEqual( - server.variable_value_ids.filtered( - lambda v: v.variable_id == self.variable_version - ).value_char, - "Test Value", - "The variable value should be saved correctly.", - ) - - def test_optional_variable_with_empty_value(self): - """ - Test that an optional variable - with an empty value is saved correctly - in the wizard and does not block server creation. - """ - # Adding an optional variable - self.VariableValue.create( - { - "variable_id": self.variable_url.id, - "server_template_id": self.server_template_sample.id, - "value_char": "", - "required": False, - } - ) - - # Open the wizard - wizard = self.env["cx.tower.server.template.create.wizard"].create( - { - "server_template_id": self.server_template_sample.id, - "name": "Server With Optional Variable", - "ssh_username": "admin", - "line_ids": [ - ( - 0, - 0, - { - "variable_id": self.variable_url.id, - "value_char": "", - "required": False, - }, - ) - ], - } - ) - - # Checking that the wizard is saved without errors - wizard.action_confirm() - - # Checking that the server has been created - server = self.Server.search( - [ - ("name", "=", "Server With Optional Variable"), - ("server_template_id", "=", self.server_template_sample.id), - ] - ) - self.assertTrue( - server, "Server should be created successfully with optional variables." - ) - - # Checking that an optional variable is saved with an empty value - variable = server.variable_value_ids.filtered( - lambda v: v.variable_id == self.variable_url - ) - self.assertTrue(variable, "Optional variable should be attached to the server.") - self.assertEqual( - variable.value_char, "", "Optional variable should have an empty value." - ) - - def test_wizard_without_variables(self): - """ - Test that the wizard does not display - any variables if the server template has none. - """ - # Removing all variables from the template - self.VariableValue.search( - [("server_template_id", "=", self.server_template_sample.id)] - ).unlink() - - # Open the wizard - wizard = self.env["cx.tower.server.template.create.wizard"].create( - { - "server_template_id": self.server_template_sample.id, - "name": "Server Without Variables", - "ssh_username": "admin", - } - ) - - # Checking that the wizard does not contain variables - self.assertFalse(wizard.line_ids, "Wizard should not display any variables.") - - def test_update_required_variable_value(self): - """ - Test that the value of a required variable - can be updated in the wizard and saved correctly. - """ - # Adding a required variable - self.VariableValue.create( - { - "variable_id": self.variable_version.id, - "server_template_id": self.server_template_sample.id, - "value_char": "Old Value", - "required": True, - } - ) - - # Open the wizard and update the variable value - wizard = self.env["cx.tower.server.template.create.wizard"].create( - { - "server_template_id": self.server_template_sample.id, - "name": "Server With Updated Variable", - "ssh_username": "admin", - "line_ids": [ - ( - 0, - 0, - { - "variable_id": self.variable_version.id, - "value_char": "New Value", - "required": True, - }, - ) - ], - } - ) - wizard.action_confirm() - - # Checking that the variable value has been updated - server = self.Server.search([("name", "=", "Server With Updated Variable")]) - variable = server.variable_value_ids.filtered( - lambda v: v.variable_id == self.variable_version - ) - self.assertEqual( - variable.value_char, - "New Value", - "The variable value should be updated correctly.", - ) - - def test_optional_variable_handling(self): - """ - Test that optional variables do not block server creation, - even if their values are empty or missing. - """ - # Adding an optional variable to the template - self.VariableValue.create( - { - "variable_id": self.variable_url.id, - "server_template_id": self.server_template_sample.id, - "value_char": "", - "required": False, - } - ) - - # Specify an optional variable with an empty value - values = self.server_template_sample._prepare_server_values( - configuration_variables={self.variable_url.reference: ""} - ) - - # Checking that the optional variable is processed correctly - variable_data = next( - ( - v - for v in values["variable_value_ids"] - if v[2]["variable_id"] == self.variable_url.id - ), - None, - ) - self.assertIsNotNone( - variable_data, - "The optional variable should be included " - "in the server values even if empty.", - ) - self.assertEqual( - variable_data[2]["value_char"], - "", - "Optional variable should have an empty value.", - ) - - def test_server_creation_with_all_required_variables_removed(self): - """ - Test that server creation fails if all required variables - are removed in the wizard. - - Steps: - 1. Create a server template with required variables. - 2. Open the server creation wizard. - 3. Remove all required variables from the wizard. - 4. Attempt to create the server. - - Expected Result: - - ValidationError is raised with a clear message listing missing variables. - """ - # Create a server template with mandatory variables - template = self.ServerTemplate.create( - { - "name": "Template with required variables", - "ssh_port": 22, - "ssh_username": "admin", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "variable_value_ids": [ - ( - 0, - 0, - { - "variable_id": self.variable_path.id, - "value_char": "/var/log", - "required": True, - }, - ), - ( - 0, - 0, - { - "variable_id": self.variable_dir.id, - "value_char": "logs", - "required": True, - }, - ), - ], - } - ) - - # Simulating the launch of a wizard with the removal of all variables - configuration_variables = {} # All variables removed - - # Checking that the server cannot be created - with self.assertRaises(ValidationError) as cm: - template._create_new_server( - name="Server with missing variables", - configuration_variables=configuration_variables, - ) - - # Checking that the error message contains all removed variables - error_message = str(cm.exception) - self.assertIn("Please resolve the following issues", error_message) - self.assertIn("Missing variables: test_path_, test_dir", error_message) - - def test_partial_required_variables_provided(self): - """ - Test that server creation fails if only some required variables - are provided, and the error message includes both missing and empty variables. - """ - # Create a template with mandatory variables - template = self.ServerTemplate.create( - { - "name": "Template with partial variables", - "variable_value_ids": [ - ( - 0, - 0, - { - "variable_id": self.variable_path.id, - "value_char": "/var/log", - "required": False, - }, - ), - ( - 0, - 0, - { - "variable_id": self.variable_dir.id, - "required": True, - }, - ), - ], - } - ) - - # Launch the wizard and specify only some of the required variables - configuration_variables = {"test_path_": "/var/log"} # test_dir skipped - - # Checking that the server is not being created - with self.assertRaises(ValidationError) as cm: - template._create_new_server( - name="Server with partial variables", - configuration_variables=configuration_variables, - ) - - # Checking the error message - error_message = str(cm.exception) - self.assertIn("Missing variables: test_dir", error_message) - self.assertNotIn("test_path_", error_message) # test_path_ provided - - def test_empty_values_for_required_variables(self): - """ - Test that server creation fails if required variables - have empty values, and the error message includes these variables. - """ - # Create a template with mandatory variables - template = self.ServerTemplate.create( - { - "name": "Template with empty values", - "variable_value_ids": [ - ( - 0, - 0, - { - "variable_id": self.variable_path.id, - "value_char": "", - "required": True, - }, - ), - ( - 0, - 0, - { - "variable_id": self.variable_dir.id, - "value_char": "", - "required": True, - }, - ), - ], - } - ) - - # Run the wizard with empty values for all variables - configuration_variables = {"test_path_": "", "test_dir": ""} - - # Checking that the server is not being created - with self.assertRaises(ValidationError) as cm: - template._create_new_server( - name="Server with empty variables", - configuration_variables=configuration_variables, - ) - - # Checking the error message - error_message = str(cm.exception) - self.assertIn("Empty values for variables: test_path_, test_dir", error_message) - - def test_with_partial_removed_variables_from_wizard(self): - """ - Test that server creation only with specified - variables from wizard and option - """ - # create new variable option - test_variable = self.Variable.create( - { - "name": "Test Variable", - "variable_type": "s", - } - ) - option = self.VariableOption.create( - { - "name": "test", - "value_char": "test", - "variable_id": test_variable.id, - } - ) - - # template with variables - self.server_template_sample.write( - { - "variable_value_ids": [ - ( - 0, - 0, - { - "variable_id": self.variable_path.id, - "value_char": "/var/log", - "required": False, - }, - ), - ( - 0, - 0, - { - "variable_id": test_variable.id, - "option_id": option.id, - "required": False, - }, - ), - ], - } - ) - - action = self.server_template_sample.action_create_server() - - # Open the wizard and fill in the data - wizard = ( - self.env["cx.tower.server.template.create.wizard"] # pylint: disable=context-overridden we new need a new clean context - .with_context(action["context"]) - .create( - { - "name": "Server from Template", - "ip_v4_address": "localhost", - "server_template_id": self.server_template_sample.id, - } - ) - ) - - with Form(wizard) as wizard_form: - wizard_form.line_ids.remove(0) - wizard_form.save() - - wizard.action_confirm() - - server = self.server_template_sample.server_ids - self.assertEqual( - len(server.variable_value_ids), 1, "Server variable must be 1!" - ) - self.assertEqual( - server.variable_value_ids.value_char, - option.value_char, - "The variable value must be equal to the value from the option", - ) - - def test_manager_access_rights(self): - """ - Test manager access rights for Server Template records: - - Read: user is in user_ids or manager_ids - - Write: user is in manager_ids - """ - record = self.ServerTemplate.create( - { - "name": "Manager Access Test", - "ssh_port": 22, - "ssh_username": "admin", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "user_ids": [(5, 0, 0)], - "manager_ids": [(5, 0, 0)], - } - ) - - # Case 1: No access rights - records = self.ServerTemplate.with_user(self.manager1).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 0, - "Manager should not see the record if not added to user_ids or manager_ids", - ) - - # Case 2: Read access through user_ids - record.write({"user_ids": [(4, self.manager1.id)]}) - records = self.ServerTemplate.with_user(self.manager1).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should see the record when added to user_ids", - ) - - # Write access should still be forbidden - with self.assertRaises(AccessError): - record.with_user(self.manager1).write({"name": "Updated Name"}) - - # Case 3: Full access through manager_ids - record.write( - { - "user_ids": [(5, 0, 0)], - "manager_ids": [(4, self.manager1.id)], - } - ) - - records = self.ServerTemplate.with_user(self.manager1).search( - [("id", "=", record.id)] - ) - self.assertEqual( - len(records), - 1, - "Manager should see the record when added to manager_ids", - ) - - # Write access should now work - try: - record.with_user(self.manager1).write({"name": "Updated Name"}) - except AccessError: - self.fail("Manager should be able to update the record when in manager_ids") - - def test_manager_create_access(self): - """ - Test that a manager can only create a Server Template record - if they add themselves to manager_ids. - """ - # Try to create without adding to manager_ids - with self.assertRaises(AccessError): - self.ServerTemplate.with_user(self.manager1).create( - { - "name": "Create Access Test - Should Fail", - "ssh_port": 22, - "ssh_username": "admin", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "manager_ids": [(5, 0, 0)], - } - ) - - # Create with manager_ids - should succeed - record = self.ServerTemplate.with_user(self.manager1).create( - { - "name": "Create Access Test - Should Succeed", - "ssh_port": 22, - "ssh_username": "admin", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "manager_ids": [(4, self.manager1.id)], - } - ) - self.assertEqual( - len(self.ServerTemplate.search([("id", "=", record.id)])), - 1, - "Manager should be able to create record when added to manager_ids", - ) - - def test_manager_delete_access(self): - """ - Test that a manager can only delete a Server Template record if: - - They are in manager_ids - - They created the record - """ - # Scenario 1: Manager1 creates and tries to delete their own record - record = self.ServerTemplate.with_user(self.manager1).create( - { - "name": "Delete Access Test - Own Record", - "ssh_port": 22, - "ssh_username": "admin", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "manager_ids": [(4, self.manager1.id)], - } - ) - - try: - record.with_user(self.manager1).unlink() - except AccessError: - self.fail( - "Manager should be able to delete their own record if in manager_ids" - ) - - # Scenario 2: Manager2 creates record, Manager1 tries to delete - record2 = self.ServerTemplate.with_user(self.manager2).create( - { - "name": "Delete Access Test - Other's Record", - "ssh_port": 22, - "ssh_username": "admin", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "manager_ids": [(6, 0, [self.manager1.id, self.manager2.id])], - } - ) - - # Manager1 should not be able to delete Manager2's record - with self.assertRaises(AccessError): - record2.with_user(self.manager1).unlink() - - # Remove Manager2 from manager_ids - record2.write({"manager_ids": [(5, 0, 0)]}) - - # Manager2 should not be able to delete their record now - with self.assertRaises(AccessError): - record2.with_user(self.manager2).unlink() - - # Scenario 3: Manager1 creates record but is later removed from manager_ids - record3 = self.ServerTemplate.with_user(self.manager1).create( - { - "name": "Delete Access Test - Removed Manager", - "ssh_port": 22, - "ssh_username": "admin", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "manager_ids": [(4, self.manager1.id)], - } - ) - - # Remove Manager1 from manager_ids - record3.write({"manager_ids": [(5, 0, 0)]}) - - # Manager1 should not be able to delete their record after being removed - with self.assertRaises(AccessError): - record3.with_user(self.manager1).unlink() - - def test_server_template_reference_update(self): - """Test server template reference update cascades to dependent models""" - # 1. Add a variable value to server_template_sample - variable_value = self.VariableValue.create( - { - "variable_id": self.variable_os.id, - "value_char": "Ubuntu 20.04", - "server_template_id": self.server_template_sample.id, - } - ) - - # Store original references for comparison - original_template_reference = self.server_template_sample.reference - original_variable_value_reference = variable_value.reference - - # 2. Change the reference for server_template_sample to "super_template" - self.server_template_sample.write({"reference": "super_template"}) - - # 3. Verify that references are updated for dependent models - # Invalidate models to refresh all references - self.env["cx.tower.server.template"].invalidate_model(["reference"]) - self.env["cx.tower.variable.value"].invalidate_model(["reference"]) - - # Check that server template reference was updated - self.assertEqual(self.server_template_sample.reference, "super_template") - self.assertNotEqual( - self.server_template_sample.reference, original_template_reference - ) - - # Check that variable value reference was updated - # to include the new template reference - self.assertIn("super_template", variable_value.reference) - self.assertNotEqual(variable_value.reference, original_variable_value_reference) - - # Verify the reference pattern for variable value follows the expected format: - # ___ # noqa: E501 - expected_variable_pattern = ( - f"{self.variable_os.reference}_variable_value_server_template_" - f"{self.server_template_sample.reference}" - ) - self.assertEqual(variable_value.reference, expected_variable_pattern) diff --git a/addons/cetmix_tower_server/tests/test_shortcut.py b/addons/cetmix_tower_server/tests/test_shortcut.py deleted file mode 100644 index 804edf3..0000000 --- a/addons/cetmix_tower_server/tests/test_shortcut.py +++ /dev/null @@ -1,244 +0,0 @@ -from .common import TestTowerCommon - - -class TestTowerShortcut(TestTowerCommon): - """Test Tower Shortcut""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Server - cls.server_test_1_pro = cls.Server.create( - { - "name": "Test 1 Pro", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "skip_host_key": True, - } - ) - - # Variable - cls.variable_path_pro = cls.Variable.create({"name": "test_path_pro"}) - - # Command - cls.command_list_dir_pro = cls.Command.create( - { - "name": "Test create directory", - "code": "ls -l {{ test_path_ }}", - } - ) - - # Flight plan - cls.plan_1_pro = cls.Plan.create( - { - "name": "Test plan 1 Pro", - "note": "List directory contents", - } - ) - cls.plan_line_1_pro = cls.plan_line.create( - { - "sequence": 5, - "plan_id": cls.plan_1_pro.id, - "command_id": cls.command_list_dir_pro.id, - } - ) - - # Shortcuts - cls.shortcut_for_command = cls.Shortcut.create( - { - "name": "Shortcut for Command", - "action": "command", - "command_id": cls.command_list_dir_pro.id, - "server_ids": [(4, cls.server_test_1_pro.id)], - } - ) - - cls.shortcut_for_flight_plan = cls.Shortcut.create( - { - "name": "Shortcut for Flight Plan", - "action": "plan", - "plan_id": cls.plan_1_pro.id, - "server_ids": [(4, cls.server_test_1_pro.id)], - } - ) - - def test_shortcut_user_access_rules(self): - """Test shortcut user access rules""" - # Create shortcuts with different access levels and server/template assignments - shortcut_level_1_server = self.Shortcut.create( - { - "name": "Level 1 Server Shortcut", - "action": "command", - "command_id": self.command_list_dir_pro.id, - "server_ids": [(4, self.server_test_1_pro.id)], - "access_level": "1", - } - ) - - shortcut_level_2_template = self.Shortcut.create( - { - "name": "Level 2 Template Shortcut", - "action": "command", - "command_id": self.command_list_dir_pro.id, - "server_template_ids": [(4, self.server_template_sample.id)], - "access_level": "2", - } - ) - - # Remove bob from all cxtower_server groups - self.remove_from_group( - self.user_bob, - [ - "cetmix_tower_server.group_user", - "cetmix_tower_server.group_manager", - "cetmix_tower_server.group_root", - ], - ) - - shortcut_server_as_bob = shortcut_level_1_server.with_user(self.user_bob) - shortcut_template_as_bob = shortcut_level_2_template.with_user(self.user_bob) - - # Test: User access - self.add_to_group(self.user_bob, "cetmix_tower_server.group_user") - self.server_test_1_pro.write({"user_ids": [(4, self.user_bob.id)]}) - - # User should see level 1 shortcuts for their servers - res = shortcut_server_as_bob.read(["name"]) - self.assertEqual(res[0]["name"], shortcut_level_1_server.name) - - # User should NOT see level 2 shortcuts - search_result = shortcut_template_as_bob.search( - [("id", "=", shortcut_level_2_template.id)] - ) - self.assertEqual(len(search_result), 0) - - # Test: Manager access through server assignment - self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") - self.server_test_1_pro.write({"manager_ids": [(4, self.user_bob.id)]}) - - # Manager should see shortcuts for servers they manage - res = shortcut_server_as_bob.read(["name"]) - self.assertEqual(res[0]["name"], shortcut_level_1_server.name) - - # Manager should NOT see template shortcuts without template access - search_result = shortcut_template_as_bob.search( - [("id", "=", shortcut_level_2_template.id)] - ) - self.assertEqual(len(search_result), 0) - - # Test: Manager access through template assignment - self.server_template_sample.write({"manager_ids": [(4, self.user_bob.id)]}) - - # Manager should now see template shortcuts - res = shortcut_template_as_bob.read(["name"]) - self.assertEqual(res[0]["name"], shortcut_level_2_template.name) - - # Test: Manager access as template user - self.server_template_sample.write( - { - "manager_ids": [(3, self.user_bob.id)], # Remove from managers - "user_ids": [(4, self.user_bob.id)], # Add as user - } - ) - - # Manager should still see template shortcuts when they're a template user - res = shortcut_template_as_bob.read(["name"]) - self.assertEqual(res[0]["name"], shortcut_level_2_template.name) - - # Test: Root access to all shortcuts - shortcut_level_3 = self.Shortcut.create( - { - "name": "Level 3 Mixed Shortcut", - "action": "command", - "command_id": self.command_list_dir_pro.id, - "server_ids": [(4, self.server_test_1_pro.id)], - "server_template_ids": [(4, self.server_template_sample.id)], - "access_level": "3", - } - ) - shortcut_level_3_as_bob = shortcut_level_3.with_user(self.user_bob) - - # Manager should NOT see level 3 shortcuts - search_result = shortcut_level_3_as_bob.search( - [("id", "=", shortcut_level_3.id)] - ) - self.assertEqual(len(search_result), 0) - - # Root should see all shortcuts - self.add_to_group(self.user_bob, "cetmix_tower_server.group_root") - search_result = shortcut_level_3_as_bob.search( - [ - ( - "id", - "in", - [ - shortcut_level_1_server.id, - shortcut_level_2_template.id, - shortcut_level_3.id, - ], - ) - ] - ) - self.assertEqual(len(search_result), 3) - - def test_shortcut_run_type_command(self): - """Test run shortcut of type 'command'""" - self.shortcut_for_command.run(self.server_test_1_pro) - - # Check command log - shortcut_result = self.CommandLog.search( - [("command_id", "=", self.shortcut_for_command.command_id.id)] - ) - self.assertEqual(len(shortcut_result), 1, "Must be single log record") - self.assertEqual( - shortcut_result.server_id, - self.server_test_1_pro, - "Server should match", - ) - - def test_shortcut_run_type_plan(self): - """Test run shortcut of type 'plan'""" - self.shortcut_for_flight_plan.run(self.server_test_1_pro) - - # Check shortcut log - shortcut_result = self.PlanLog.search( - [("plan_id", "=", self.shortcut_for_flight_plan.plan_id.id)] - ) - self.assertEqual(len(shortcut_result), 1, "Must be single log record") - self.assertEqual( - shortcut_result.server_id, - self.server_test_1_pro, - "Server should match", - ) - - def test_shortcut_run_from_context(self): - """Test running shortcut with server from context""" - # Create a test shortcut - shortcut = self.Shortcut.create( - { - "name": "Context Test Shortcut", - "action": "command", - "command_id": self.command_list_dir_pro.id, - "server_ids": [(4, self.server_test_1_pro.id)], - } - ) - - # Run with server_id in context - shortcut.with_context(server_id=self.server_test_1_pro.id).run() - - # Check command log was created - log_entries = self.CommandLog.search( - [ - ("command_id", "=", shortcut.command_id.id), - ("server_id", "=", self.server_test_1_pro.id), - ] - ) - self.assertEqual(len(log_entries), 1, "Should create a log entry") - self.assertEqual( - log_entries.server_id, - self.server_test_1_pro, - "Server should match", - ) diff --git a/addons/cetmix_tower_server/tests/test_tag.py b/addons/cetmix_tower_server/tests/test_tag.py deleted file mode 100644 index 41b61a4..0000000 --- a/addons/cetmix_tower_server/tests/test_tag.py +++ /dev/null @@ -1,91 +0,0 @@ -from odoo.exceptions import AccessError, ValidationError - -from .common import TestTowerCommon - - -class TestTowerTag(TestTowerCommon): - """Test for the 'cx.tower.tag' model""" - - def test_01_unlink_as_user_with_used_tag(self): - """Test that user cannot delete tag that is in use""" - # Create test tag - test_tag = self.Tag.create( - { - "name": "Test Tag User", - } - ) - # Link tag to server - self.server_test_1.write({"tag_ids": [(4, test_tag.id)]}) - - with self.assertRaises(ValidationError): - test_tag.with_user(self.user).unlink() - - def test_02_unlink_as_user_with_unused_tag(self): - """Test that user cannot delete tag even if it's not in use""" - # Create new unused tag - unused_tag = self.Tag.create( - { - "name": "Unused Tag", - } - ) - # Try to delete unused tag - with self.assertRaises(AccessError): - unused_tag.with_user(self.user).unlink() - - def test_03_unlink_as_manager_with_used_tag(self): - """Test that manager cannot delete tag that is in use""" - # Create test tag as manager - test_tag = self.Tag.with_user(self.manager).create( - { - "name": "Test Tag Manager", - } - ) - # Link tag to server - test_tag.write({"server_ids": [(4, self.server_test_1.id)]}) - - # Access error because user doesn't have access to server - with self.assertRaises(AccessError): - test_tag.with_user(self.user).unlink() - - # Add 'manager' to server - self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) - - # Validation error - with self.assertRaises(ValidationError): - test_tag.with_user(self.manager).unlink() - - def test_04_unlink_as_manager_with_own_tag(self): - """Test that manager can delete their own unused tag""" - # Create new unused tag as manager - unused_tag = self.Tag.with_user(self.manager).create( - { - "name": "Manager's Tag", - } - ) - # Manager should be able to delete their own unused tag - unused_tag.with_user(self.manager).unlink() - - def test_05_unlink_as_manager_with_other_tag(self): - """Test that manager cannot delete tag created by other user""" - # Create tag as root - other_tag = self.Tag.create( - { - "name": "Other's Tag", - } - ) - # Manager should not be able to delete tag created by other user - with self.assertRaises(AccessError): - other_tag.with_user(self.manager).unlink() - - def test_06_unlink_as_sudo(self): - """Test that sudo can delete tag that is in use""" - # Create test tag - test_tag = self.Tag.create( - { - "name": "Test Tag Sudo", - } - ) - # Link tag to server - self.server_test_1.write({"tag_ids": [(4, test_tag.id)]}) - - test_tag.with_user(self.user).sudo().unlink() diff --git a/addons/cetmix_tower_server/tests/test_tag_mixin.py b/addons/cetmix_tower_server/tests/test_tag_mixin.py deleted file mode 100644 index 8faa270..0000000 --- a/addons/cetmix_tower_server/tests/test_tag_mixin.py +++ /dev/null @@ -1,167 +0,0 @@ -from .common import TestTowerCommon - - -class TestTowerTagMixin(TestTowerCommon): - """Test class for tower tag mixin.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - # Create 3 tags to test tag mixin - cls.tag_test_1 = cls.Tag.create( - { - "name": "Test Tag 1", - } - ) - cls.tag_test_2 = cls.Tag.create( - { - "name": "Test Tag 2", - } - ) - cls.tag_test_3 = cls.Tag.create( - { - "name": "Test Tag 3", - } - ) - - # Create 3 commands to test tag mixin - cls.command_test_1 = cls.Command.create( - { - "name": "Test Command 1", - } - ) - cls.command_test_2 = cls.Command.create( - { - "name": "Test Command 2", - } - ) - cls.command_test_3 = cls.Command.create( - { - "name": "Test Command 3", - } - ) - - cls.all_commands = cls.command_test_1 | cls.command_test_2 | cls.command_test_3 - - # Add tags to commands - # - Command 1: Test Tag 1, Test Tag 2 - cls.command_test_1.add_tags(["Test Tag 1", "Test Tag 2", "Test Tag 3"]) - # - Command 2: Test Tag 2, Test Tag 3 - cls.command_test_2.add_tags(["Test Tag 2", "Test Tag 3"]) - # - Command 3: Test Tag 3 - cls.command_test_3.add_tags(["Test Tag 3"]) - - def test_01_add_tags(self): - """Test that tags are added to the record""" - self.assertEqual(len(self.command_test_1.tag_ids), 3) - self.assertEqual(len(self.command_test_2.tag_ids), 2) - self.assertEqual(len(self.command_test_3.tag_ids), 1) - self.assertIn(self.tag_test_1, self.command_test_1.tag_ids) - self.assertIn(self.tag_test_2, self.command_test_1.tag_ids) - self.assertIn(self.tag_test_3, self.command_test_1.tag_ids) - self.assertIn(self.tag_test_2, self.command_test_2.tag_ids) - self.assertIn(self.tag_test_3, self.command_test_2.tag_ids) - self.assertIn(self.tag_test_3, self.command_test_3.tag_ids) - - # Test adding duplicate tags (should be idempotent) - self.command_test_1.add_tags(["Test Tag 1"]) - self.assertEqual(len(self.command_test_1.tag_ids), 3) - - # Test adding single tag name - self.command_test_1.add_tags("Test Tag 1") - self.assertEqual(len(self.command_test_1.tag_ids), 3) - self.assertIn(self.tag_test_1, self.command_test_1.tag_ids) - self.assertIn(self.tag_test_2, self.command_test_1.tag_ids) - self.assertIn(self.tag_test_3, self.command_test_1.tag_ids) - - # Test adding invalid type (should return True) - self.assertTrue(self.command_test_1.add_tags(123)) - self.assertTrue(self.command_test_1.add_tags([])) - # Test adding invalid type (should return True) - # Empty list is a no-op - before = len(self.command_test_1.tag_ids) - self.assertTrue(self.command_test_1.add_tags([])) - self.assertEqual(len(self.command_test_1.tag_ids), before) - - # Test adding non-existent tags (should be ignored) - initial_count = len(self.command_test_1.tag_ids) - self.command_test_1.add_tags(["Non Existent Tag"]) - self.assertEqual(len(self.command_test_1.tag_ids), initial_count) - - def test_02_remove_tags(self): - """Test that tags are removed from the record""" - self.command_test_1.remove_tags(["Test Tag 1", "Test Tag 2"]) - self.assertEqual(len(self.command_test_1.tag_ids), 1) - - # Test removing single tag name - self.command_test_2.remove_tags("Test Tag 2") - self.assertEqual(len(self.command_test_2.tag_ids), 1) - self.assertIn(self.tag_test_3, self.command_test_2.tag_ids) - - # Test removing invalid type (should return True) - self.assertTrue(self.command_test_1.remove_tags(123)) - # Test removing no tags (should return True) - self.assertTrue(self.command_test_1.remove_tags([])) - - def test_03_has_tags(self): - """Test that the record has any of the given tags""" - - # Search selected records - commands_with_any_tags = self.all_commands.has_tags( - ["Test Tag 1", "Test Tag 2"] - ) - self.assertEqual(len(commands_with_any_tags), 2) - self.assertIn(self.command_test_1, commands_with_any_tags) - self.assertIn(self.command_test_2, commands_with_any_tags) - - # Search all records in the model - commands_with_any_tags = self.Command.has_tags( - ["Test Tag 1", "Test Tag 2"], search_all=True - ) - self.assertEqual(len(commands_with_any_tags), 2) - self.assertIn(self.command_test_1, commands_with_any_tags) - self.assertIn(self.command_test_2, commands_with_any_tags) - - # Search with single tag name - commands_with_any_tags = self.all_commands.has_tags("Test Tag 2") - self.assertEqual(len(commands_with_any_tags), 2) - self.assertIn(self.command_test_1, commands_with_any_tags) - self.assertIn(self.command_test_2, commands_with_any_tags) - - commands_with_any_tags = self.Command.has_tags("Test Tag 2", search_all=True) - self.assertEqual(len(commands_with_any_tags), 2) - self.assertIn(self.command_test_1, commands_with_any_tags) - self.assertIn(self.command_test_2, commands_with_any_tags) - - # Search with invalid type (should return empty recordset) - commands_with_any_tags = self.Command.has_tags(123) - self.assertEqual(len(commands_with_any_tags), 0) - - # Search with no tags (should return empty recordset) - commands_with_any_tags = self.Command.has_tags([]) - self.assertEqual(len(commands_with_any_tags), 0) - - def test_04_has_all_tags(self): - """Test that the record has all of the given tags""" - - # Search selected records - commands_with_all_tags = self.all_commands.has_all_tags( - ["Test Tag 1", "Test Tag 2"] - ) - self.assertEqual(len(commands_with_all_tags), 1) - self.assertIn(self.command_test_1, commands_with_all_tags) - - # Search all records in the model - commands_with_all_tags = self.Command.has_all_tags( - ["Test Tag 1", "Test Tag 2"], search_all=True - ) - self.assertEqual(len(commands_with_all_tags), 1) - self.assertIn(self.command_test_1, commands_with_all_tags) - - # Search with invalid type (should return empty recordset) - commands_with_all_tags = self.Command.has_all_tags(123) - self.assertEqual(len(commands_with_all_tags), 0) - - # Search with no tags (should return empty recordset) - commands_with_all_tags = self.Command.has_all_tags([]) - self.assertEqual(len(commands_with_all_tags), 0) diff --git a/addons/cetmix_tower_server/tests/test_tools.py b/addons/cetmix_tower_server/tests/test_tools.py deleted file mode 100644 index 98cd545..0000000 --- a/addons/cetmix_tower_server/tests/test_tools.py +++ /dev/null @@ -1,38 +0,0 @@ -from odoo.tests import common - -from ..models.tools import CHARS, generate_random_id - - -class TestTools(common.TransactionCase): - """Test class for tools module.""" - - def test_generate_random_id(self): - """Test random id generation""" - # Test single section - result = generate_random_id() - self.assertEqual(len(result), 4) # Default length is 4 - self.assertTrue(all(c in CHARS for c in result)) # All chars from CHARS - - # Test multiple sections - result = generate_random_id(sections=2) - sections = result.split("-") - self.assertEqual(len(sections), 2) - self.assertTrue(all(len(s) == 4 for s in sections)) - self.assertTrue(all(c in CHARS for s in sections for c in s)) - - # Test custom population - result = generate_random_id(population=6) - self.assertEqual(len(result), 6) - - # Test custom separator - result = generate_random_id(sections=2, separator="_") - self.assertIn("_", result) - self.assertEqual(len(result.split("_")), 2) - - # Test invalid inputs - self.assertIsNone(generate_random_id(sections=0)) - self.assertIsNone(generate_random_id(population=-1)) - - # Test empty separator - result = generate_random_id(sections=3, separator="") - self.assertEqual(len(result), 12) # 3 sections of 4 chars with no separator diff --git a/addons/cetmix_tower_server/tests/test_update_related_variable_names.py b/addons/cetmix_tower_server/tests/test_update_related_variable_names.py deleted file mode 100644 index aba26cd..0000000 --- a/addons/cetmix_tower_server/tests/test_update_related_variable_names.py +++ /dev/null @@ -1,204 +0,0 @@ -from .common import TestTowerCommon - - -class TestUpdateRelatedVariableNames(TestTowerCommon): - """Test Update Related Variable Names""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create test variables - cls.var1 = cls.Variable.create({"name": "var1", "reference": "var1"}) - cls.var2 = cls.Variable.create({"name": "var2", "reference": "var2"}) - cls.var3 = cls.Variable.create({"name": "var3", "reference": "var3"}) - - cls.test_command = cls.Command.create( - { - "name": "Test Command", - "code": "{{ var1 }} and {{ var2 }}", - "path": "{{ var3 }}", - } - ) - - cls.server = cls.Server.create( - { - "name": "Test Server", - "color": 2, - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "k", - "ssh_key_id": cls.key_1.id, - } - ) - cls.test_file = cls.File.create( - { - "server_id": cls.server.id, - "code": "{{ var1 }} is used", - "server_dir": "path/to/{{ var2 }}", - "name": "{{ var3 }}.txt", - } - ) - - cls.test_plan_line = cls.plan_line.create( - { - "command_id": cls.test_command.id, - "condition": "Condition with {{ var1 }} and {{ var2 }}", - } - ) - - cls.test_variable_value = cls.VariableValue.create( - { - "variable_id": cls.variable_os.id, - "value_char": "{{ var1 }} is here and {{ var2 }} too", - } - ) - - cls.test_file_template = cls.FileTemplate.create( - { - "name": "Test File Template", - "code": "{{ var1 }} in code", - "server_dir": "This path has {{ var2 }}", - "file_name": "file_name_with_{{ var1 }}", - } - ) - - def test_variables_command_computation(self): - """ - Test that the variable_ids field is correctly computed based on the 'code' - and 'path' fields of the command. - """ - # Verify that the correct variables are assigned to variable_ids - self.assertEqual( - set(self.test_command.variable_ids.ids), - {self.var1.id, self.var2.id, self.var3.id}, - "The variable_ids should contain var1, var2, and var3.", - ) - - def test_variables_command_clearing(self): - """ - Test that the variable_ids field is cleared when - no variables are found in the code or path. - """ - # Update code and path to remove references - self.test_command.write( - {"code": "No variables here", "path": "No variables here either"} - ) - # Verify that variable_ids is empty - self.assertFalse( - self.test_command.variable_ids, - "The variable_ids should be empty when no variables are found.", - ) - - def test_variables_file_computation(self): - """ - Test that the variable_ids field is correctly computed based on the 'code', - 'server_dir', and 'name' fields of the file. - """ - # Verify that the correct variables are assigned to variable_ids - self.assertEqual( - set(self.test_file.variable_ids.ids), - {self.var1.id, self.var2.id, self.var3.id}, - "The variable_ids should contain var1, var2, and var3.", - ) - - def test_variables_file_clearing(self): - """ - Test that the variable_ids field is cleared when - no variables are found in the code, server_dir, or name fields. - """ - # Update the file to remove references - self.test_file.write( - { - "code": "No variables here", - "server_dir": "No variables here either", - "name": "no_var.txt", - } - ) - # Verify that variable_ids is empty - self.assertFalse( - self.test_file.variable_ids, - "The variable_ids should be empty when no variables are found.", - ) - - def test_variables_plan_line_computation(self): - """ - Test that the variable_ids field is correctly - computed based on the 'condition' field of the plan line. - """ - # Verify that the correct variables are assigned to variable_ids - self.assertEqual( - set(self.test_plan_line.variable_ids.ids), - {self.var1.id, self.var2.id}, - "The variable_ids should contain var1 and var2.", - ) - - def test_variables_plan_line_clearing(self): - """ - Test that the variable_ids field is cleared when - no variables are found in the condition field. - """ - # Update the plan line to remove references - self.test_plan_line.write({"condition": "No variables in this condition"}) - # Verify that variable_ids is empty - self.assertFalse( - self.test_plan_line.variable_ids, - "The variable_ids should be empty when no variables are found.", - ) - - def test_variables_variable_value_computation(self): - """ - Test that the variable_ids field is correctly - computed based on the 'value_char' field. - """ - # Verify that the correct variables are assigned to variable_ids - self.assertEqual( - set(self.test_variable_value.variable_ids.ids), - {self.var1.id, self.var2.id}, - "The variable_ids should contain var1 and var2.", - ) - - def test_variables_variable_value_clearing(self): - """ - Test that the variable_ids field is cleared when - no variables are found in the value_char field. - """ - # Update the variable value to remove references - self.test_variable_value.write({"value_char": "No variables in this text"}) - # Verify that variable_ids is empty - self.assertFalse( - self.test_variable_value.variable_ids, - "The variable_ids should be empty when no variables are found.", - ) - - def test_variables_file_template_computation(self): - """ - Test that the variable_ids field is correctly computed - based on 'code', 'server_dir', and 'file_name' fields. - """ - # Verify that the correct variables are assigned to variable_ids - self.assertEqual( - set(self.test_file_template.variable_ids.ids), - {self.var1.id, self.var2.id}, - "The variable_ids should contain var1 and var2.", - ) - - def test_variable_file_template_clearing(self): - """ - Test that the variable_ids field is cleared when - no variables are found in code, server_dir, or file_name. - """ - # Update the file template to remove references - self.test_file_template.write( - { - "code": "No variables here", - "server_dir": "No variables here either", - "file_name": "no_var_in_file", - } - ) - # Verify that variable_ids is empty - self.assertFalse( - self.test_file_template.variable_ids, - "The variable_ids should be empty when no variables are found.", - ) diff --git a/addons/cetmix_tower_server/tests/test_variable.py b/addons/cetmix_tower_server/tests/test_variable.py deleted file mode 100644 index 6f8b0fb..0000000 --- a/addons/cetmix_tower_server/tests/test_variable.py +++ /dev/null @@ -1,1189 +0,0 @@ -# Copyright (C) 2022 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from unittest.mock import patch -from urllib.parse import urlparse - -from odoo import _, fields -from odoo.exceptions import AccessError, ValidationError -from odoo.tests.common import Form - -from .common import TestTowerCommon -from .common_jets import TestTowerJetsCommon - - -class TestTowerVariable(TestTowerCommon): - """Testing variables and variable values.""" - - def check_variable_values(self, vals, server_ids=None): - """Check if variable values are correctly stored in db - - Args: - vals (List of tuples): format ("variable_id", "value") - server_id (cx.tower.server()): Servers those variables belong to. - """ - if server_ids: - variable_records = server_ids.variable_value_ids - else: - variable_records = self.VariableValue.search([("is_global", "=", True)]) - len_vals = len(vals) - - # Ensure correct number of records - self.assertEqual( - len(variable_records), len_vals, msg="Must be %s records" % str(len_vals) - ) - - # Check variable values - for val in vals: - variable_line = variable_records.filtered( - lambda v, val=val: v.variable_id.id == val[0] - ) - self.assertEqual( - len(variable_line), 1, msg="Must be a single variable line" - ) - expected_value = val[1] or False - self.assertEqual( - variable_line.value_char, - expected_value, - msg="Variable value does not match provided one", - ) - - def test_variable_values(self): - """Test common variable operations""" - - # -- 1 -- - # Server specific variables - - # Add two variables - with Form(self.server_test_1) as f: - with f.variable_value_ids.new() as line: - line.variable_id = self.variable_dir - line.value_char = "/opt/odoo" - with f.variable_value_ids.new() as line: - line.variable_id = self.variable_url - line.value_char = "example.com" - f.save() - - vals = [ - (self.variable_url.id, "example.com"), - (self.variable_dir.id, "/opt/odoo"), - ] - self.check_variable_values(vals=vals, server_ids=self.server_test_1) - - # Add another variable and edit the existing one - with Form(self.server_test_1) as f: - with f.variable_value_ids.edit(1) as line: - line.value_char = "meme.example.com" - with f.variable_value_ids.new() as line: - line.variable_id = self.variable_version - line.value_char = "10.0" - f.save() - - vals = [ - (self.variable_url.id, "meme.example.com"), - (self.variable_dir.id, "/opt/odoo"), - (self.variable_version.id, "10.0"), - ] - self.check_variable_values(vals=vals, server_ids=self.server_test_1) - - # Delete two variables, add a new one - with Form(self.server_test_1) as f: - f.variable_value_ids.remove(index=0) - f.variable_value_ids.remove(index=0) - with f.variable_value_ids.new() as line: - line.variable_id = self.variable_os - line.value_char = "Debian" - - # Add an empty variable value - with f.variable_value_ids.new() as line: - line.variable_id = self.variable_url - f.save() - - vals = [ - (self.variable_os.id, "Debian"), - (self.variable_version.id, "10.0"), - (self.variable_url.id, False), - ] - self.check_variable_values(vals=vals, server_ids=self.server_test_1) - - # Test 'get_variable_values' function - res_vars = self.Variable._get_variable_values_by_references( - ["test_dir", "test_os", "test_url", "test_version"], - server=self.server_test_1, - ) - self.assertEqual(len(res_vars), 5, "Must be a 5 values in the result") - - var_dir = res_vars["test_dir"] - var_os = res_vars["test_os"] - var_url = res_vars["test_url"] - var_version = res_vars["test_version"] - - self.assertIsNone(var_dir, msg="Variable 'dir' must be None") - self.assertFalse(var_url, msg="Variable 'url' must be False") - self.assertEqual(var_os, "Debian", msg="Variable 'os' must be 'Debian'") - self.assertEqual(var_version, "10.0", msg="Variable 'version' must be '10.0'") - - # -- 2 -- - # Test global variable values - - # Create a global value for the 'dir' variable - self.VariableValue.create( - {"variable_id": self.variable_dir.id, "value_char": "/global/dir"} - ) - res_vars = self.Variable._get_variable_values_by_references( - ["test_dir", "test_os", "test_url", "test_version"], - server=self.server_test_1, - ) - self.assertEqual(len(res_vars), 5, "Must be a 5 values in the result") - - var_dir = res_vars["test_dir"] - var_os = res_vars["test_os"] - var_url = res_vars["test_url"] - var_version = res_vars["test_version"] - - self.assertEqual( - var_dir, "/global/dir", msg="Variable 'dir' must be equal to '/global/dir'" - ) - self.assertFalse(var_url, msg="Variable 'url' must be False") - self.assertEqual(var_os, "Debian", msg="Variable 'os' must be 'Debian'") - self.assertEqual(var_version, "10.0", msg="Variable 'version' must be '10.0'") - - # Now save a local value for the variable - with Form(self.server_test_1) as f: - with f.variable_value_ids.new() as line: - line.variable_id = self.variable_dir - line.value_char = "/opt/odoo" - f.save() - - # Check - res_vars = self.Variable._get_variable_values_by_references( - ["test_dir", "test_os", "test_url", "test_version"], - server=self.server_test_1, - ) - self.assertEqual(len(res_vars), 5, "Must be a 5 values in the result") - - var_dir = res_vars["test_dir"] - var_os = res_vars["test_os"] - var_url = res_vars["test_url"] - var_version = res_vars["test_version"] - - self.assertEqual( - var_dir, "/opt/odoo", msg="Variable 'dir' must be equal to '/opt/odoo'" - ) - self.assertFalse(var_url, msg="Variable 'url' must be False") - self.assertEqual(var_os, "Debian", msg="Variable 'os' must be 'Debian'") - self.assertEqual(var_version, "10.0", msg="Variable 'version' must be '10.0'") - - def test_variables_in_variable_values(self): - """Test variables in variable values - eg - home: /home - user: bob - home_dir: {{ home }}/{{ user }} --> /home/bob - """ - - # Add local variables - with Form(self.server_test_1) as f: - with f.variable_value_ids.new() as line: - line.variable_id = self.variable_dir - line.value_char = "/web" - with f.variable_value_ids.new() as line: - line.variable_id = self.variable_path - line.value_char = "{{ test_dir }}/{{ test_version }}" - with f.variable_value_ids.new() as line: - line.variable_id = self.variable_url - line.value_char = "{{ test_path_ }}/example.com" - f.save() - - # Create a global value for the 'Version' variable - self.VariableValue.create( - {"variable_id": self.variable_version.id, "value_char": "10.0"} - ) - - # Check values - res_vars = self.Variable._get_variable_values_by_references( - ["test_dir", "test_url", "test_version"], - server=self.server_test_1, - ) - # Including system variable - self.assertEqual(len(res_vars), 4, "Must be a 4 values in the result") - - var_dir = res_vars["test_dir"] - var_url = res_vars["test_url"] - var_version = res_vars["test_version"] - - self.assertEqual(var_dir, "/web", msg="Variable 'dir' must be '/web'") - self.assertEqual( - var_url, - "/web/10.0/example.com", - msg="Variable 'url' must be '/web/10.0/example.com'", - ) - self.assertEqual(var_version, "10.0", msg="Variable 'version' must be '10.0'") - - def test_variable_values_unlink(self): - """Ensure variable values are deleted properly - - Create a new server - - Add 2 variable values - - Delete server - - Ensure variable values are deleted - """ - - def get_value_count(variable): - """helper function to count variable value records - Arg: (cx.tower.variable) variable rec - Returns: (int) record count - """ - return self.VariableValue.search_count([("variable_id", "=", variable.id)]) - - # Get variable values count before adding variables to server - count_dir_before = get_value_count(self.variable_dir) - count_url_before = get_value_count(self.variable_url) - - # Create new server - server_test_var = self.Server.create( - { - "name": "Test Var", - "os_id": self.os_debian_10.id, - "ip_v4_address": "localhost", - "ssh_username": "bob", - "ssh_password": "pass", - } - ) - - # Add two variables to server - with Form(server_test_var) as f: - with f.variable_value_ids.new() as line: - line.variable_id = self.variable_dir - line.value_char = "/opt/odoo" - with f.variable_value_ids.new() as line: - line.variable_id = self.variable_url - line.value_char = "example.com" - f.save() - - # Number of values should be incremented - self.assertEqual( - get_value_count(self.variable_dir), - count_dir_before + 1, - msg="Value count must be incremented!", - ) - self.assertEqual( - get_value_count(self.variable_url), - count_url_before + 1, - msg="Value count must be incremented!", - ) - - # Delete the server - server_test_var.unlink() - self.assertEqual( - get_value_count(self.variable_dir), - count_dir_before, - msg="Value count must be same as before server creation!", - ) - self.assertEqual( - get_value_count(self.variable_url), - count_url_before, - msg="Value count must be same as before server creation!", - ) - - def test_variable_value_toggle_global(self): - """Test what happens when variable value 'global' setting is togged""" - - variable_meme = self.Variable.create({"name": "meme"}) - variable_value_pepe = self.VariableValue.create( - {"variable_id": variable_meme.id, "value_char": "Pepe"} - ) - - self.assertEqual( - variable_value_pepe.is_global, True, msg="Value 'Pepe' must be global" - ) - - # Test `_check_is_global` function - self.assertEqual( - variable_value_pepe._check_is_global(), - True, - msg="Value 'Pepe' must be global", - ) - - # Try to create another global value for the same variable - with self.assertRaises(ValidationError) as err: - self.VariableValue.create( - {"variable_id": variable_meme.id, "value_char": "Doge"} - ) - - # We check the message in order to ensure that - # exception was raised by the correct event. - self.assertEqual( - err.exception.args[0], - _("Only one global value can be defined for variable 'meme'"), - msg="Error message doesn't match. Check if you have modified it in code:" - "models/cx_tower_server.py", - ) - - # Try to disable 'global' for a global variable explicitly - with self.assertRaises(ValidationError) as err: - variable_value_pepe.is_global = False - - # We check the message in order to ensure that - # exception was raised by the correct event. - self.assertEqual( - err.exception.args[0], - _( - "Cannot change 'global' status for " - "'meme' with value 'Pepe'." - "\nTry to assigns it to a record instead." - ), - msg="Error message doesn't match. Check if you have modified it in code:" - "models/cx_tower_server.py", - ) - - def test_system_variable_server_type_values(self): - """Test system variables of `server` type""" - - # Modify server record for testing - self.server_test_1.ip_v6_address = "suchmuchipv6" - self.server_test_1.url = "meme.example.com" - self.server_test_1.partner_id = ( - self.env["res.partner"].create({"name": "Pepe Frog"}).id - ) - - # Create new command with system variables - command = self.Command.create( - { - "name": "Super System Command", - "code": "echo {{ tower.server.name }} " - "{{ tower.server.username}} " - "{{ tower.server.partner_name }} " - "{{ tower.server.ipv4 }} " - "{{ tower.server.ipv6 }} " - "{{ tower.server.url }} ", - } - ) - - # Get variables - variables = command.get_variables().get(str(command.id)) - # Get variable values - variable_values = self.Variable._get_variable_values_by_references( - variables, - server=self.server_test_1, - ) - - # Check values - self.assertEqual( - variable_values["tower"]["server"]["name"], - self.server_test_1.name, - "System variable doesn't match server property", - ) - self.assertEqual( - variable_values["tower"]["server"]["reference"], - self.server_test_1.reference, - "System variable doesn't match server property", - ) - self.assertEqual( - variable_values["tower"]["server"]["username"], - self.server_test_1.ssh_username, - "System variable doesn't match server property", - ) - self.assertEqual( - variable_values["tower"]["server"]["username"], - self.server_test_1.ssh_username, - "System variable doesn't match server property", - ) - self.assertEqual( - variable_values["tower"]["server"]["partner_name"], - self.server_test_1.partner_id.name, - "System variable doesn't match server property", - ) - self.assertEqual( - variable_values["tower"]["server"]["ipv4"], - self.server_test_1.ip_v4_address, - "System variable doesn't match server property", - ) - self.assertEqual( - variable_values["tower"]["server"]["ipv6"], - self.server_test_1.ip_v6_address, - "System variable doesn't match server property", - ) - self.assertEqual( - variable_values["tower"]["server"]["url"], - self.server_test_1.url, - "System variable doesn't match server property", - ) - self.assertEqual( - variable_values["tower"]["server"]["hostname"], - urlparse(self.server_test_1.url).hostname, - "System variable doesn't match server property", - ) - self.assertEqual( - variable_values["tower"]["server"]["netloc"], - urlparse(self.server_test_1.url).netloc, - "System variable doesn't match server property", - ) - self.assertEqual( - variable_values["tower"]["server"]["port"], - urlparse(self.server_test_1.url).port, - "System variable doesn't match server property", - ) - - @patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_variable.fields.Datetime.now", - return_value=fields.Datetime.now(), - ) - @patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_variable.fields.Date.today", - return_value=fields.Date.today(), - ) - @patch( - "odoo.addons.cetmix_tower_server.models.cx_tower_variable.uuid.uuid4", - return_value="suchmuchuuid4", - ) - def test_system_variable_tools_type_values(self, mock_uuid4, mock_today, mock_now): - """Test system variables of `tools` type""" - - # Create new command with system variables - command = self.Command.create( - {"name": "Super System Command", "code": "echo {{ tower.tools.uuid}}"} - ) - - # Get variables - variables = command.get_variables().get(str(command.id)) - # Get variable values - variable_values = self.Variable._get_variable_values_by_references( - variables, - server=self.server_test_1, - ) - - # Check values - self.assertEqual( - variable_values["tower"]["tools"]["uuid"], - mock_uuid4.return_value, - "System variable doesn't match result provided by tools", - ) - self.assertEqual( - variable_values["tower"]["tools"]["today"], - str(mock_today.return_value), - "System variable doesn't match result provided by tools", - ) - self.assertEqual( - variable_values["tower"]["tools"]["now"], - str(mock_now.return_value), - "System variable doesn't match result provided by tools", - ) - self.assertEqual( - variable_values["tower"]["tools"]["today_underscore"], - str(mock_today.return_value) - .replace("-", "_") - .replace(" ", "_") - .replace(":", "_") - .replace(".", "_") - .replace("/", "_"), - "System variable doesn't match result provided by tools", - ) - self.assertEqual( - variable_values["tower"]["tools"]["now_underscore"], - str(mock_now.return_value) - .replace("-", "_") - .replace(":", "_") - .replace(" ", "_") - .replace(".", "_") - .replace("/", "_"), - "System variable doesn't match result provided by tools", - ) - - def test_make_value_pythonic(self): - """Test making variable values 'pythonic`""" - - # Number - value = 12.34 - expected_value = '"12.34"' - result_value = self.Command._make_value_pythonic(value) - - self.assertEqual( - expected_value, result_value, "Result value doesn't match expected" - ) - - # Text - value = "Doge much like" - expected_value = '"Doge much like"' - result_value = self.Command._make_value_pythonic(value) - - self.assertEqual( - expected_value, result_value, "Result value doesn't match expected" - ) - - # Boolean - value = True - expected_value = True - result_value = self.Command._make_value_pythonic(value) - - self.assertEqual( - expected_value, result_value, "Result value doesn't match expected" - ) - - # None - value = None - expected_value = None - result_value = self.Command._make_value_pythonic(value) - - self.assertEqual( - expected_value, result_value, "Result value doesn't match expected" - ) - - # Dict - value = {"doge": {"likes": "memes", "much": 200}} - expected_value = {"doge": {"likes": '"memes"', "much": '"200"'}} - result_value = self.Command._make_value_pythonic(value) - - self.assertEqual( - expected_value, result_value, "Result value doesn't match expected" - ) - - def test_single_assignment(self): - """Test that a variable can only be assigned to one model at a time.""" - # Create a variable value assigned to the server - variable_value = self.env["cx.tower.variable.value"].create( - { - "variable_id": self.variable_os.id, - "value_char": "Branch = Main", - "server_id": self.server_test_1.id, - } - ) - - # Try to assign the same variable value to - # server template and expect a ValidationError - with self.assertRaises(ValidationError): - variable_value.write({"server_template_id": self.server_template_sample.id}) - - # Try to assign the same variable value to - # plan line action and expect a ValidationError - with self.assertRaises(ValidationError): - variable_value.write({"plan_line_action_id": self.plan_line_1_action_1.id}) - - def test_unique_assignment(self): - """Test that the same variable value cannot be - assigned multiple times to the same record. - """ - - # Create a variable - variable = self.env["cx.tower.variable"].create( - {"name": "Environment Type", "note": "The environment type for the server."} - ) - - # Create a server - server = self.env["cx.tower.server"].create( - { - "name": "Test Server", - "ip_v4_address": "127.0.0.1", - "ssh_username": "testuser", - "ssh_password": "testpassword", - "ssh_auth_mode": "p", - } - ) - - # Create a variable value for the server - self.env["cx.tower.variable.value"].create( - { - "variable_id": variable.id, - "value_char": "Production", - "server_id": server.id, - } - ) - - # Try to create a second variable value with the same variable and server - with self.assertRaises( - ValidationError, - msg="A variable value cannot be assigned multiple times to the same server", - ): - self.env["cx.tower.variable.value"].create( - { - "variable_id": variable.id, - "value_char": "Production", - "server_id": server.id, - } - ) - - def test_value_access_level_consistency(self): - """Test that variable value access level cannot be lower - than variable access level.""" - - # Create test servers - server_2 = self.Server.create( - { - "name": "Test Server 2", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": self.os_debian_10.id, - } - ) - - server_3 = self.Server.create( - { - "name": "Test Server 3", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": self.os_debian_10.id, - } - ) - - # Create a variable with access level "2" - variable_restricted = self.Variable.create( - { - "name": "restricted_variable", - "access_level": "2", - } - ) - - # Should succeed: value with same access level as variable - try: - self.VariableValue.create( - { - "variable_id": variable_restricted.id, - "value_char": "test_value1", - "access_level": "2", - "is_global": True, - } - ) - except ValidationError: - self.fail("Should allow creating value with same access level as variable") - - # Should succeed: value with higher access level than variable - try: - self.VariableValue.create( - { - "variable_id": variable_restricted.id, - "value_char": "test_value2", - "access_level": "3", - "server_id": server_2.id, - } - ) - except ValidationError: - self.fail( - "Should allow creating value with higher access level than variable" - ) - - # Should fail: value with lower access level than variable - with self.assertRaises( - ValidationError, - msg="Should not allow creating value with lower access level than variable", - ): - self.VariableValue.create( - { - "variable_id": variable_restricted.id, - "value_char": "test_value3", - "access_level": "1", - "server_id": server_3.id, - } - ) - - # Test updating existing value's access level - value = self.VariableValue.create( - { - "variable_id": self.variable_dir.id, # Using a different variable - "value_char": "test_value4", - "access_level": "2", - "server_id": server_3.id, - } - ) - - # Should fail: updating to lower access level than variable - with self.assertRaises( - ValidationError, - msg="Should not allow updating value to lower access level than variable", - ): - value.write({"access_level": "1"}) - - # Should succeed: updating to higher access level than variable - try: - value.write({"access_level": "3"}) - except ValidationError: - self.fail( - "Should allow updating value to higher access level than variable" - ) - - def test_variable_access_rights(self): - """Test access rights for variables based on access levels and user roles.""" - - # Create variables with different access levels - variable_level_1 = self.Variable.create( - { - "name": "Level 1 Variable", - "access_level": "1", - } - ) - - variable_level_2 = self.Variable.create( - { - "name": "Level 2 Variable", - "access_level": "2", - } - ) - - variable_level_3 = self.Variable.create( - { - "name": "Level 3 Variable", - "access_level": "3", - } - ) - manager2 = self.Users.create( - { - "name": "Manager 2", - "login": "manager2@example.com", - "groups_id": [(4, self.group_manager.id)], - } - ) - - # Test User Access - # --------------- - # Should see level 1 variables - records = self.Variable.with_user(self.user).search( - [ - ( - "id", - "in", - [variable_level_1.id, variable_level_2.id, variable_level_3.id], - ) - ] - ) - self.assertEqual(len(records), 1, "User should only see level 1 variables") - self.assertEqual( - records.id, variable_level_1.id, "User should only see level 1 variables" - ) - - # Test Manager Access - # ----------------- - # Should see level 1 and 2 variables - records = self.Variable.with_user(self.manager).search( - [ - ( - "id", - "in", - [variable_level_1.id, variable_level_2.id, variable_level_3.id], - ) - ] - ) - self.assertEqual(len(records), 2, "Manager should see level 1 and 2 variables") - self.assertIn( - variable_level_1.id, records.ids, "Manager should see level 1 variables" - ) - self.assertIn( - variable_level_2.id, records.ids, "Manager should see level 2 variables" - ) - - # Test Manager Write Access - # ----------------------- - # Create a variable as manager - manager_variable = self.Variable.with_user(self.manager).create( - { - "name": "Manager Created Variable", - "access_level": "2", - } - ) - - # Manager should be able to modify their own variable - try: - manager_variable.with_user(self.manager).write({"name": "Updated Name"}) - except AccessError: - self.fail("Manager should be able to modify their own variables") - - # Manager should not be able to modify another manager's variable - manager2_variable = self.Variable.with_user(manager2).create( - { - "name": "Other Manager Variable", - "access_level": "2", - } - ) - - with self.assertRaises(AccessError): - manager2_variable.with_user(self.manager).write({"name": "Try Update"}) - - # Manager should not be able to create level 3 variable - with self.assertRaises(AccessError): - self.Variable.with_user(self.manager).create( - { - "name": "Try Level 3", - "access_level": "3", - } - ) - - # Test Root Access - # -------------- - # Root should see all variables - records = self.Variable.with_user(self.root).search( - [ - ( - "id", - "in", - [variable_level_1.id, variable_level_2.id, variable_level_3.id], - ) - ] - ) - self.assertEqual(len(records), 3, "Root should see all variables") - - # Root should be able to create any level variable - try: - self.Variable.with_user(self.root).create( - { - "name": "Root Level 3", - "access_level": "3", - } - ) - except AccessError: - self.fail("Root should be able to create any level variable") - - # Root should be able to modify any variable - try: - variable_level_3.with_user(self.root).write({"name": "Updated by Root"}) - except AccessError: - self.fail("Root should be able to modify any variable") - - def test_validate_value(self): - """Test variable value validation""" - # Create variable with validation pattern - variable_with_pattern = self.Variable.create( - { - "name": "Test Pattern", - "validation_pattern": "^[a-z0-9]+$", - "validation_message": "Only lowercase letters and numbers allowed", - } - ) - - # Test valid values - valid_value = "abc123" - is_valid, message = variable_with_pattern._validate_value(valid_value) - self.assertTrue(is_valid, "Value should be valid") - self.assertIsNone(message, "No message should be returned for valid value") - - # Test invalid values - invalid_value = "ABC123!" - is_valid, message = variable_with_pattern._validate_value(invalid_value) - self.assertFalse(is_valid, "Value should be invalid") - self.assertEqual( - message, - f"Variable: {variable_with_pattern.name}, Value: {invalid_value}\n" - "Only lowercase letters and numbers allowed", - "Invalid value message doesn't match", - ) - - # Test empty value - is_valid, message = variable_with_pattern._validate_value(None) - self.assertTrue(is_valid, "Empty value should be valid") - self.assertIsNone(message, "No message should be returned for empty value") - - # Test variable without pattern - variable_no_pattern = self.Variable.create( - { - "name": "No Pattern", - } - ) - test_value = "Any Value!" - is_valid, message = variable_no_pattern._validate_value(test_value) - self.assertTrue(is_valid, "Value should be valid when no pattern is set") - self.assertIsNone( - message, "No message should be returned when no pattern is set" - ) - - # Test default validation message - variable_default_message = self.Variable.create( - { - "name": "Default Message", - "validation_pattern": "^[a-z]+$", - } - ) - invalid_value = "123" - is_valid, message = variable_default_message._validate_value(invalid_value) - self.assertFalse(is_valid, "Value should be invalid") - self.assertEqual( - message, - f"Variable: {variable_default_message.name}, Value: {invalid_value}\n" - f"{variable_default_message.DEFAULT_VALIDATION_MESSAGE}", - "Default validation message doesn't match", - ) - - -class TestVariableReferenceRename(TestTowerCommon): - """Ensure variable rename updates all Jinja references using shared fixtures.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.ref_old = cls.variable_version.reference - cls.ref_new = "software_version" - - cls.command = cls.Command.create( - { - "name": "Show version (test)", - "code": f"echo {{ {{ {cls.ref_old} }} }}", - "variable_ids": [(6, 0, [cls.variable_version.id])], - } - ) - - cls.file = cls.File.create( - { - "name": "test_version.txt", - "server_dir": "/tmp", - "code": f"{{ {{ {cls.ref_old} }} }}", - "variable_ids": [(6, 0, [cls.variable_version.id])], - } - ) - - def _rename(self): - """Rename variable and invalidate caches for records under test.""" - self.variable_version.write({"reference": self.ref_new}) - self.command.invalidate_recordset() - self.file.invalidate_recordset() - - def test_false_references_are_ignored(self): - """Ignore malformed or non-Jinja references.""" - cmd_plain = self.Command.create( - { - "name": "Plain", - "code": "print(test_version)", - "variable_ids": [(6, 0, [self.variable_version.id])], - } - ) - cmd_bad = self.Command.create( - { - "name": "BadBrackets", - "code": "{test_version}", - "variable_ids": [(6, 0, [self.variable_version.id])], - } - ) - - self._rename() - cmd_plain.invalidate_recordset() - cmd_bad.invalidate_recordset() - - self.assertEqual(cmd_plain.code, "print(test_version)") - self.assertEqual(cmd_bad.code, "{test_version}") - - def test_multiple_occurrences_replace_all(self): - """Replace all valid Jinja references in one field.""" - code = "A: {{ test_version }}, B: {{ test_version }}, C-end" - cmd_multi = self.Command.create( - { - "name": "Multi", - "code": code, - "variable_ids": [(6, 0, [self.variable_version.id])], - } - ) - - self._rename() - cmd_multi.invalidate_recordset() - actual_ref = self.variable_version.reference - expected = f"A: {{{{ {actual_ref} }}}}, " f"B: {{{{ {actual_ref} }}}}, C-end" - self.assertEqual(cmd_multi.code, expected) - - def test_template_files_updated(self): - """Propagate rename in template and generated file.""" - tpl = self.env["cx.tower.file.template"].create( - { - "name": "TmpTpl", - "file_name": "tpl.txt", - "server_dir": "/tmp", - "code": "{{ test_version }}", - "variable_ids": [(6, 0, [self.variable_version.id])], - } - ) - tpl_file = self.File.create( - { - "name": "from_tpl.txt", - "server_dir": "/tmp", - "template_id": tpl.id, - "code": "{{ test_version }}", - } - ) - - self._rename() - tpl.invalidate_recordset() - tpl_file.invalidate_recordset() - - actual_ref = self.variable_version.reference - expected = f"{{{{ {actual_ref} }}}}" - self.assertEqual(tpl.code, expected) - self.assertEqual(tpl_file.code, expected) - - def test_value_and_plan_line_update(self): - """Update value_char and plan line condition.""" - - def patched_mapping(_): - 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"], - } - - with patch.object( - type(self.variable_version), - "_get_propagation_field_mapping", - patched_mapping, - ): - val = self.env["cx.tower.variable.value"].create( - { - "variable_id": self.variable_version.id, - "value_char": "hello {{ test_version }} world", - } - ) - - pl = self.plan_line_1 - pl.write( - { - "variable_ids": [(6, 0, [self.variable_version.id])], - "condition": "if {{ test_version }} then", - } - ) - - self.assertIn(self.variable_version.id, pl.variable_ids.ids) - - self._rename() - val.invalidate_recordset() - pl.invalidate_recordset() - - actual_ref = self.variable_version.reference - expected_val = f"hello {{{{ {actual_ref} }}}} world" - self.assertEqual(val.value_char, expected_val) - expected_cond = f"if {{{{ {actual_ref} }}}} then" - self.assertEqual(pl.condition, expected_cond) - - def test_variable_reference_update(self): - """Test variable reference update cascades to dependent models""" - # 1. Add a variable value to variable_os - variable_value = self.VariableValue.create( - { - "variable_id": self.variable_os.id, - "value_char": "Ubuntu 20.04", - "server_id": self.server_test_1.id, - } - ) - - # Store original references for comparison - original_variable_reference = self.variable_os.reference - original_variable_value_reference = variable_value.reference - - # 2. Change the reference for variable_os to "awesome_variable" - self.variable_os.write({"reference": "awesome_variable"}) - - # 3. Verify that references are updated for dependent models - # Invalidate models to refresh all references - self.env["cx.tower.variable"].invalidate_model(["reference"]) - self.env["cx.tower.variable.value"].invalidate_model(["reference"]) - - # Check that variable reference was updated - self.assertEqual(self.variable_os.reference, "awesome_variable") - self.assertNotEqual(self.variable_os.reference, original_variable_reference) - - # Check that variable value reference was updated - # to include the new variable reference - self.assertIn("awesome_variable", variable_value.reference) - self.assertNotEqual(variable_value.reference, original_variable_value_reference) - - # Verify the reference pattern for variable value follows the expected format: - # ___ # noqa: E501 - expected_variable_pattern = ( - f"{self.variable_os.reference}_variable_value_server_" - f"{self.server_test_1.reference}" - ) - self.assertEqual(variable_value.reference, expected_variable_pattern) - - -class TestTowerVariableJet(TestTowerJetsCommon): - """Testing jet system variables with waypoint data.""" - - def test_system_variable_jet_type_values_with_waypoint(self): - """Test system variables of `jet` type with waypoint data""" - # Set waypoint as current waypoint for the jet - self.jet_test.waypoint_id = self.waypoint.id - - # Set waypoint metadata - self.waypoint.metadata = {"key1": "value1", "key2": "value2"} - - # Get system variable values - variable_values = self.Variable._get_system_variable_values(jet=self.jet_test) - - # Check waypoint data is included - self.assertIn( - "waypoint", variable_values["jet"], "Waypoint data should be included" - ) - waypoint_data = variable_values["jet"]["waypoint"] - - # Check waypoint reference and type - self.assertEqual( - waypoint_data["reference"], - self.waypoint.reference, - "Waypoint reference should match", - ) - self.assertEqual( - waypoint_data["type"], - self.waypoint_template.reference, - "Waypoint type should match template reference", - ) - - # Check metadata is included - self.assertEqual( - waypoint_data["key1"], - "value1", - "Waypoint metadata key1 should match", - ) - self.assertEqual( - waypoint_data["key2"], - "value2", - "Waypoint metadata key2 should match", - ) - - def test_system_variable_jet_type_values_without_waypoint(self): - """Test system variables of `jet` type without waypoint""" - # Ensure jet has no waypoint - self.jet_test.waypoint_id = False - - # Get system variable values - variable_values = self.Variable._get_system_variable_values(jet=self.jet_test) - - # Check waypoint data is included but with False values - self.assertIn( - "waypoint", - variable_values["jet"], - "Waypoint data should be included even when jet has no waypoint", - ) - waypoint_data = variable_values["jet"]["waypoint"] - - # Check waypoint reference and type are False - self.assertFalse( - waypoint_data["reference"], - "Waypoint reference should be False when jet has no waypoint", - ) - self.assertFalse( - waypoint_data["type"], - "Waypoint type should be False when jet has no waypoint", - ) - - def test_system_variable_jet_type_values_with_waypoint_empty_metadata(self): - """Test system variables of `jet` type with waypoint but empty metadata""" - # Set waypoint as current waypoint for the jet - self.jet_test.waypoint_id = self.waypoint.id - - # Set waypoint metadata to empty dict - self.waypoint.metadata = {} - - # Get system variable values - variable_values = self.Variable._get_system_variable_values(jet=self.jet_test) - - # Check waypoint data is included - self.assertIn( - "waypoint", variable_values["jet"], "Waypoint data should be included" - ) - waypoint_data = variable_values["jet"]["waypoint"] - - # Check that only reference and type are present (no metadata keys) - self.assertEqual( - len(waypoint_data), - 2, - "Waypoint data should only contain reference" - " and type when metadata is empty", - ) - self.assertIn( - "reference", waypoint_data, "Waypoint reference should be present" - ) - self.assertIn("type", waypoint_data, "Waypoint type should be present") diff --git a/addons/cetmix_tower_server/tests/test_variable_option.py b/addons/cetmix_tower_server/tests/test_variable_option.py deleted file mode 100644 index a833af6..0000000 --- a/addons/cetmix_tower_server/tests/test_variable_option.py +++ /dev/null @@ -1,285 +0,0 @@ -from odoo.exceptions import AccessError, ValidationError - -from .common import TestTowerCommon - - -class TestTowerVariableOption(TestTowerCommon): - """Test case class to validate the behavior of - 'cx.tower.variable.option' model. - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.variable_odoo_versions = cls.Variable.create( - { - "name": "odoo_versions", - "variable_type": "o", - } - ) - - cls.variable_option_17_0 = cls.VariableOption.create( - { - "name": "17.0", - "value_char": "17.0", - "variable_id": cls.variable_odoo_versions.id, - } - ) - - cls.variable_option_18_0 = cls.VariableOption.create( - { - "name": "18.0", - "value_char": "18.0", - "variable_id": cls.variable_odoo_versions.id, - } - ) - - # Create additional test users - cls.manager2 = cls.Users.create( - { - "name": "Manager 2", - "login": "manager2@example.com", - "groups_id": [(4, cls.group_manager.id)], - } - ) - - # Create variables with different access levels - cls.variable_level_1 = cls.Variable.create( - { - "name": "Level 1 Variable", - "access_level": "1", - } - ) - - cls.variable_level_2 = cls.Variable.create( - { - "name": "Level 2 Variable", - "access_level": "2", - } - ) - - # Create options with different access levels (inherited from variables) - cls.option_level_1 = cls.VariableOption.create( - { - "name": "Option Level 1", - "value_char": "value1", - "variable_id": cls.variable_level_1.id, - } - ) - - cls.option_level_2 = cls.VariableOption.create( - { - "name": "Option Level 2", - "value_char": "value2", - "variable_id": cls.variable_level_2.id, - } - ) - - def test_variable_value_set_from_option(self): - """Test that a variable value can be set from an option.""" - - variable_value = self.VariableValue.create( - { - "server_id": self.server_test_1.id, - "variable_id": self.variable_odoo_versions.id, - } - ) - - # -- 1 -- - # Set value_char to an existing option - variable_value.value_char = "17.0" - self.assertEqual( - variable_value.option_id, - self.variable_option_17_0, - ) - - # -- 2 -- - # Set value_char to a non-existing option - variable_meme_level = self.Variable.create( - { - "name": "meme_level", - "variable_type": "o", - } - ) - option_meme_level_high = self.VariableOption.create( - { - "name": "high", - "value_char": "high", - "variable_id": variable_meme_level.id, - } - ) - with self.assertRaises(ValidationError): - variable_value.option_id = option_meme_level_high - - # -- 3 -- - # Set value_char to a non-existing option - variable_value.value_char = "29.0" - self.assertFalse(variable_value.option_id) - - def test_access_level_consistency(self): - """Test that variable option access level cannot be lower - than variable access level.""" - - # Create a variable with access level "2" - variable_restricted = self.Variable.create( - { - "name": "restricted_variable", - "variable_type": "o", - "access_level": "2", - } - ) - - # Should succeed: option with same access level as variable - try: - self.VariableOption.create( - { - "name": "Option 1", - "value_char": "value1", - "variable_id": variable_restricted.id, - "access_level": "2", - } - ) - except ValidationError: - self.fail("Should allow creating option with same access level as variable") - - # Should succeed: option with higher access level than variable - try: - self.VariableOption.create( - { - "name": "Option 2", - "value_char": "value2", - "variable_id": variable_restricted.id, - "access_level": "3", - } - ) - except ValidationError: - self.fail( - "Should allow creating option with higher access level than variable" - ) - - # Should fail: option with lower access level than variable - with self.assertRaises( - ValidationError, - msg="Should not allow creating option " - "with lower access level than variable", - ): - self.VariableOption.create( - { - "name": "Option 3", - "value_char": "value3", - "variable_id": variable_restricted.id, - "access_level": "1", - } - ) - - # Test updating existing option's access level - option = self.VariableOption.create( - { - "name": "Option 4", - "value_char": "value4", - "variable_id": variable_restricted.id, - "access_level": "2", - } - ) - - # Should fail: updating to lower access level than variable - with self.assertRaises( - ValidationError, - msg="Should not allow updating option to lower access level than variable", - ): - option.write({"access_level": "1"}) - - # Should succeed: updating to higher access level than variable - try: - option.write({"access_level": "3"}) - except ValidationError: - self.fail( - "Should allow updating option to higher access level than variable" - ) - - def test_variable_option_access_rights(self): - """ - Test access rights for variable options - based on access levels and user roles. - """ - - # Test User Access - # --------------- - # Should see level 1 options only - records = self.VariableOption.with_user(self.user).search( - [("id", "in", [self.option_level_1.id, self.option_level_2.id])] - ) - self.assertEqual(len(records), 1, "User should only see level 1 options") - self.assertEqual( - records.id, self.option_level_1.id, "User should only see level 1 options" - ) - - # Test Manager Access - # ----------------- - # Should see level 1 and 2 options - records = self.VariableOption.with_user(self.manager).search( - [("id", "in", [self.option_level_1.id, self.option_level_2.id])] - ) - self.assertEqual(len(records), 2, "Manager should see level 1 and 2 options") - self.assertIn( - self.option_level_1.id, records.ids, "Manager should see level 1 options" - ) - self.assertIn( - self.option_level_2.id, records.ids, "Manager should see level 2 options" - ) - - # Test Manager Write Access - # ----------------------- - # Create an option as manager - manager_option = self.VariableOption.with_user(self.manager).create( - { - "name": "Manager Created Option", - "value_char": "manager_value", - "variable_id": self.variable_level_2.id, - } - ) - - # Manager should be able to modify their own option - try: - manager_option.with_user(self.manager).write({"name": "Updated Name"}) - except AccessError: - self.fail("Manager should be able to modify their own options") - - # Manager should not be able to modify another manager's option - manager2_option = self.VariableOption.with_user(self.manager2).create( - { - "name": "Other Manager Option", - "value_char": "other_value", - "variable_id": self.variable_level_2.id, - } - ) - - with self.assertRaises(AccessError): - manager2_option.with_user(self.manager).write({"name": "Try Update"}) - - # Test Root Access - # -------------- - # Root should see all options - records = self.VariableOption.with_user(self.root).search( - [("id", "in", [self.option_level_1.id, self.option_level_2.id])] - ) - self.assertEqual(len(records), 2, "Root should see all options") - - # Root should be able to create any option - try: - self.VariableOption.with_user(self.root).create( - { - "name": "Root Created Option", - "value_char": "root_value", - "variable_id": self.variable_level_2.id, - } - ) - except AccessError: - self.fail("Root should be able to create any option") - - # Root should be able to modify any option - try: - self.option_level_2.with_user(self.root).write({"name": "Updated by Root"}) - except AccessError: - self.fail("Root should be able to modify any option") diff --git a/addons/cetmix_tower_server/tests/test_variable_value.py b/addons/cetmix_tower_server/tests/test_variable_value.py deleted file mode 100644 index b1d85e9..0000000 --- a/addons/cetmix_tower_server/tests/test_variable_value.py +++ /dev/null @@ -1,952 +0,0 @@ -from odoo.exceptions import AccessError - -from . import common - - -class TestTowerVariableValue(common.TestTowerCommon): - """Testing variable values.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Create additional test users - cls.user2 = cls.Users.create( - { - "name": "Test User 2", - "login": "test_user2", - "email": "test_user2@example.com", - "groups_id": [(6, 0, [cls.group_user.id])], - } - ) - - cls.manager2 = cls.Users.create( - { - "name": "Test Manager 2", - "login": "test_manager2", - "email": "test_manager2@example.com", - "groups_id": [(6, 0, [cls.group_manager.id])], - } - ) - - # Create variables with different access levels - cls.variable_level_1 = cls.Variable.create( - { - "name": "Level 1 Variable", - "access_level": "1", - } - ) - - cls.variable_level_2 = cls.Variable.create( - { - "name": "Level 2 Variable", - "access_level": "2", - } - ) - - # Create servers - cls.server_1 = cls.Server.create( - { - "name": "Test Server 1", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": cls.os_debian_10.id, - "user_ids": [(4, cls.user.id)], - "manager_ids": [(4, cls.manager.id)], - } - ) - - cls.server_2 = cls.Server.create( - { - "name": "Test Server 2", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": cls.os_debian_10.id, - "user_ids": [(4, cls.user2.id)], - "manager_ids": [(4, cls.manager2.id)], - } - ) - - # Create test command - cls.test_command = cls.Command.create( - { - "name": "Test Command", - "code": "echo 'test'", - } - ) - - # Create flight plan and its components - cls.test_plan = cls.Plan.create( - { - "name": "Test Plan", - "user_ids": [(4, cls.user.id)], - "manager_ids": [(4, cls.manager.id)], - } - ) - - cls.test_plan_line = cls.plan_line.create( - { - "name": "Test Line", - "plan_id": cls.test_plan.id, - "command_id": cls.test_command.id, - } - ) - - cls.test_plan_line_action = cls.plan_line_action.create( - { - "name": "Test Action", - "line_id": cls.test_plan_line.id, - "condition": "==", - "value_char": "0", - "action": "n", - } - ) - - # Create variable values - cls.global_value_1 = cls.VariableValue.create( - { - "variable_id": cls.variable_level_1.id, - "value_char": "global_value_1", - } - ) - - cls.global_value_2 = cls.VariableValue.create( - { - "variable_id": cls.variable_level_2.id, - "value_char": "global_value_2", - } - ) - - cls.server_value_1 = cls.VariableValue.create( - { - "variable_id": cls.variable_level_1.id, - "value_char": "server_value_1", - "server_id": cls.server_1.id, - } - ) - - cls.server_value_2 = cls.VariableValue.with_user(cls.manager).create( - { - "variable_id": cls.variable_level_2.id, - "value_char": "server_value_2", - "server_id": cls.server_1.id, - } - ) - - cls.plan_value_1 = cls.VariableValue.create( - { - "variable_id": cls.variable_level_1.id, - "value_char": "plan_value_1", - "plan_line_action_id": cls.test_plan_line_action.id, - } - ) - - cls.plan_value_2 = cls.VariableValue.create( - { - "variable_id": cls.variable_level_2.id, - "value_char": "plan_value_2", - "plan_line_action_id": cls.test_plan_line_action.id, - } - ) - - # Add server template setup - cls.server_template = cls.ServerTemplate.create( - { - "name": "Test Template", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": cls.os_debian_10.id, - "manager_ids": [ - (4, cls.manager.id) - ], # Only managers should have access - } - ) - - # Add template variable values - cls.template_value_1 = cls.VariableValue.create( - { - "variable_id": cls.variable_level_1.id, - "value_char": "template_value_1", - "server_template_id": cls.server_template.id, - } - ) - - cls.template_value_2 = cls.VariableValue.with_user(cls.manager).create( - { - "variable_id": cls.variable_level_2.id, - "value_char": "template_value_2", - "server_template_id": cls.server_template.id, - } - ) - - # Add server to plan - cls.test_plan.write({"server_ids": [(4, cls.server_1.id)]}) - - # Create Jet Template - cls.jet_template = cls.JetTemplate.create( - { - "name": "Test Jet Template", - "server_ids": [(4, cls.server_1.id)], - "user_ids": [(4, cls.user.id)], - "manager_ids": [(4, cls.manager.id)], - } - ) - - # Create Jet Template variable values - cls.jet_template_value_1 = cls.VariableValue.create( - { - "variable_id": cls.variable_level_1.id, - "value_char": "jet_template_value_1", - "jet_template_id": cls.jet_template.id, - } - ) - - cls.jet_template_value_2 = cls.VariableValue.with_user(cls.manager).create( - { - "variable_id": cls.variable_level_2.id, - "value_char": "jet_template_value_2", - "jet_template_id": cls.jet_template.id, - } - ) - - # Create Jet - cls.jet = cls.Jet.create( - { - "name": "Test Jet", - "jet_template_id": cls.jet_template.id, - "server_id": cls.server_1.id, - "user_ids": [(4, cls.user.id)], - "manager_ids": [(4, cls.manager.id)], - } - ) - - # Create Jet variable values - cls.jet_value_1 = cls.VariableValue.create( - { - "variable_id": cls.variable_level_1.id, - "value_char": "jet_value_1", - "jet_id": cls.jet.id, - } - ) - - cls.jet_value_2 = cls.VariableValue.with_user(cls.manager).create( - { - "variable_id": cls.variable_level_2.id, - "value_char": "jet_value_2", - "jet_id": cls.jet.id, - } - ) - - def test_variable_value_access_rights(self): - """ - Test access rights for variable values - based on access levels and user roles. - """ - - # Test User Access - # --------------- - user_values = self.VariableValue.with_user(self.user).search( - [ - ( - "id", - "in", - [ - self.global_value_1.id, - self.global_value_2.id, - self.server_value_1.id, - self.server_value_2.id, - self.plan_value_1.id, - self.plan_value_2.id, - ], - ) - ] - ) - - # User should see level 1 global values and level 1 values - # from their server/plan - self.assertEqual(len(user_values), 3) - self.assertIn(self.global_value_1.id, user_values.ids) - self.assertIn(self.server_value_1.id, user_values.ids) - self.assertIn(self.plan_value_1.id, user_values.ids) - - # User should not be able to create/write/unlink values - with self.assertRaises(AccessError): - self.VariableValue.with_user(self.user).create( - { - "variable_id": self.variable_level_1.id, - "value_char": "test", - "server_id": self.server_1.id, - } - ) - - with self.assertRaises(AccessError): - self.server_value_1.with_user(self.user).write({"value_char": "new_value"}) - - with self.assertRaises(AccessError): - self.server_value_1.with_user(self.user).unlink() - - # Test Manager Access - # ------------------ - manager_values = self.VariableValue.with_user(self.manager).search( - [ - ( - "id", - "in", - [ - self.global_value_1.id, - self.global_value_2.id, - self.server_value_1.id, - self.server_value_2.id, - self.plan_value_1.id, - self.plan_value_2.id, - ], - ) - ] - ) - - # Manager should see all level 1 and 2 values from their server/plan - self.assertEqual(len(manager_values), 6) - - # Manager should be able to create values for their server/plan - test_variable = self.Variable.create( - { - "name": "Test Variable", - "access_level": "2", - } - ) - try: - new_value = self.VariableValue.with_user(self.manager).create( - { - "variable_id": test_variable.id, - "value_char": "manager_value", - "server_id": self.server_1.id, - } - ) - except AccessError: - self.fail("Manager should be able to create values for their server") - - # Manager should be able to modify values for their server/plan - try: - self.server_value_2.with_user(self.manager).write( - {"value_char": "updated_value"} - ) - except AccessError: - self.fail("Manager should be able to modify values for their server") - - # Manager should be able to delete their own values - try: - new_value.with_user(self.manager).unlink() - except AccessError: - self.fail("Manager should be able to delete their own values") - - # Manager should not be able to modify other manager's values - with self.assertRaises(AccessError): - self.VariableValue.with_user(self.manager).create( - { - "variable_id": self.variable_level_1.id, - "value_char": "test", - "server_id": self.server_2.id, - } - ) - - # Test Root Access - # --------------- - root_values = self.VariableValue.with_user(self.root).search( - [ - ( - "id", - "in", - [ - self.global_value_1.id, - self.global_value_2.id, - self.server_value_1.id, - self.server_value_2.id, - self.plan_value_1.id, - self.plan_value_2.id, - ], - ) - ] - ) - - # Root should see all values - self.assertEqual(len(root_values), 6) - - # Root should be able to create any value - try: - root_value = self.VariableValue.with_user(self.root).create( - { - "variable_id": self.variable_level_2.id, - "value_char": "root_value", - "server_id": self.server_2.id, - "access_level": "2", - } - ) - except AccessError: - self.fail("Root should be able to create any value") - - # Root should be able to modify any value - try: - self.server_value_2.with_user(self.root).write( - {"value_char": "root_updated"} - ) - except AccessError: - self.fail("Root should be able to modify any value") - - # Root should be able to delete any value - try: - root_value.with_user(self.root).unlink() - except AccessError: - self.fail("Root should be able to delete any value") - - def test_server_template_access(self): - """Test access rights for server template variable values""" - - # Test user access to template values - # (should see none since they don't have template access) - user_template_values = self.VariableValue.with_user(self.user).search( - [("server_template_id", "=", self.server_template.id)] - ) - self.assertEqual( - len(user_template_values), 0 - ) # Users can't see template values - - # Test manager access to template values - manager_template_values = self.VariableValue.with_user(self.manager).search( - [("server_template_id", "=", self.server_template.id)] - ) - self.assertEqual(len(manager_template_values), 2) - - # Create a new variable for testing manager create rights - test_variable = self.Variable.create( - { - "name": "Test Template Manager Variable", - "access_level": "2", - } - ) - - # Test manager create rights - new_template_value = self.VariableValue.with_user(self.manager).create( - { - "variable_id": test_variable.id, # Use the new variable - "value_char": "new_template_value", - "server_template_id": self.server_template.id, - } - ) - self.assertTrue(new_template_value.exists()) - - # Test manager write rights - self.template_value_2.with_user(self.manager).write( - {"value_char": "updated_template_value"} - ) - self.assertEqual(self.template_value_2.value_char, "updated_template_value") - - # Test manager unlink rights (only own records) - new_template_value.with_user(self.manager).unlink() - self.assertFalse(new_template_value.exists()) - - def test_server_template_manager_in_users_access(self): - """Test access rights for server template when manager is in user_ids only""" - - # Create new template with manager in user_ids only (not in manager_ids) - template_with_manager_user = self.ServerTemplate.create( - { - "name": "Template With Manager User", - "ssh_username": "admin", - "ssh_password": "password", - "os_id": self.os_debian_10.id, - "user_ids": [(4, self.manager.id)], # Add manager to user_ids only - } - ) - - # Create test values as root to set up the test - template_value_1 = self.VariableValue.create( - { - "variable_id": self.variable_level_1.id, - "value_char": "manager_user_value_1", - "server_template_id": template_with_manager_user.id, - } - ) - - template_value_2 = self.VariableValue.create( - { - "variable_id": self.variable_level_2.id, - "value_char": "manager_user_value_2", - "server_template_id": template_with_manager_user.id, - } - ) - - # Test manager can read both level 1 and level 2 values - # (Manager Read rule allows access_level <= '2' when manager is in user_ids) - manager_values = self.VariableValue.with_user(self.manager).search( - [("server_template_id", "=", template_with_manager_user.id)] - ) - self.assertEqual(len(manager_values), 2) - self.assertIn(template_value_1.id, manager_values.ids) - self.assertIn(template_value_2.id, manager_values.ids) - - # Create a new variable for testing create access - test_variable = self.Variable.create( - { - "name": "Test Template User Variable", - "access_level": "1", - } - ) - - # Test manager cannot create values - with self.assertRaises(AccessError): - self.VariableValue.with_user(self.manager).create( - { - "variable_id": test_variable.id, # Use the new variable - "value_char": "new_manager_user_value", - "server_template_id": template_with_manager_user.id, - } - ) - - # Test manager cannot write values - with self.assertRaises(AccessError): - template_value_1.with_user(self.manager).write( - {"value_char": "updated_manager_user_value"} - ) - - # Test manager cannot delete values - with self.assertRaises(AccessError): - template_value_1.with_user(self.manager).unlink() - - def test_plan_server_access(self): - """Test access rights for plan server variable values""" - - # Create a new variable for testing - test_variable = self.Variable.create( - { - "name": "Test Plan Server Variable", - "access_level": "2", - } - ) - - # Create variable value for plan server (only assign to server) - plan_server_value = self.VariableValue.with_user(self.manager).create( - { - "variable_id": test_variable.id, - "value_char": "plan_server_value", - "server_id": self.server_1.id, - } - ) - - # Test user read access - user_plan_server_values = self.VariableValue.with_user(self.user).search( - [("server_id", "=", self.server_1.id), ("access_level", "=", "1")] - ) - self.assertTrue(user_plan_server_values) - - # Test manager read/write access - manager_plan_server_values = self.VariableValue.with_user(self.manager).search( - [("server_id", "=", self.server_1.id)] - ) - self.assertTrue(manager_plan_server_values) - - # Test manager write rights - plan_server_value.with_user(self.manager).write( - {"value_char": "updated_plan_server_value"} - ) - self.assertEqual(plan_server_value.value_char, "updated_plan_server_value") - - # Create another new variable for testing create rights - test_variable_2 = self.Variable.create( - { - "name": "Test Plan Server Variable 2", - "access_level": "2", - } - ) - - # Test manager create rights (only assign to server) - new_plan_server_value = self.VariableValue.with_user(self.manager).create( - { - "variable_id": test_variable_2.id, - "value_char": "new_plan_server_value", - "server_id": self.server_1.id, - } - ) - self.assertTrue(new_plan_server_value.exists()) - - # Test manager unlink rights (only own records) - new_plan_server_value.with_user(self.manager).unlink() - self.assertFalse(new_plan_server_value.exists()) - - # Test plan-specific variable values - test_variable_3 = self.Variable.create( - { - "name": "Test Plan Action Variable", - "access_level": "2", - } - ) - - # Create variable value for plan action - plan_action_value = self.VariableValue.with_user(self.manager).create( - { - "variable_id": test_variable_3.id, - "value_char": "plan_action_value", - "plan_line_action_id": self.test_plan_line_action.id, - } - ) - self.assertTrue(plan_action_value.exists()) - - # Test manager access to plan action values - manager_plan_values = self.VariableValue.with_user(self.manager).search( - [("plan_line_action_id", "=", self.test_plan_line_action.id)] - ) - self.assertIn(plan_action_value.id, manager_plan_values.ids) - - def test_jet_access(self): - """Test access rights for Jet variable values""" - - # Test user access to jet values - # User should see level 1 values from jets they're added to - user_jet_values = self.VariableValue.with_user(self.user).search( - [("jet_id", "=", self.jet.id)] - ) - self.assertEqual(len(user_jet_values), 1) - self.assertIn(self.jet_value_1.id, user_jet_values.ids) - - # User should not be able to create/write/unlink values - with self.assertRaises(AccessError): - self.VariableValue.with_user(self.user).create( - { - "variable_id": self.variable_level_1.id, - "value_char": "test", - "jet_id": self.jet.id, - } - ) - - with self.assertRaises(AccessError): - self.jet_value_1.with_user(self.user).write({"value_char": "new_value"}) - - with self.assertRaises(AccessError): - self.jet_value_1.with_user(self.user).unlink() - - # Test manager access to jet values - # Manager should see all level 1 and 2 values from jets they're added to - manager_jet_values = self.VariableValue.with_user(self.manager).search( - [("jet_id", "=", self.jet.id)] - ) - self.assertEqual(len(manager_jet_values), 2) - self.assertIn(self.jet_value_1.id, manager_jet_values.ids) - self.assertIn(self.jet_value_2.id, manager_jet_values.ids) - - # Create a new variable for testing manager create rights - test_variable = self.Variable.create( - { - "name": "Test Jet Manager Variable", - "access_level": "2", - } - ) - - # Test manager create rights (only when manager in jet manager_ids) - new_jet_value = self.VariableValue.with_user(self.manager).create( - { - "variable_id": test_variable.id, - "value_char": "new_jet_value", - "jet_id": self.jet.id, - } - ) - self.assertTrue(new_jet_value.exists()) - - # Test manager write rights - self.jet_value_2.with_user(self.manager).write( - {"value_char": "updated_jet_value"} - ) - self.assertEqual(self.jet_value_2.value_char, "updated_jet_value") - - # Test manager unlink rights (only own records) - new_jet_value.with_user(self.manager).unlink() - self.assertFalse(new_jet_value.exists()) - - # Test manager cannot create values for jets they're not managers of - jet_without_manager = self.Jet.create( - { - "name": "Jet Without Manager", - "jet_template_id": self.jet_template.id, - "server_id": self.server_1.id, - "user_ids": [(4, self.user2.id)], - "manager_ids": [(4, self.manager2.id)], - } - ) - - with self.assertRaises(AccessError): - self.VariableValue.with_user(self.manager).create( - { - "variable_id": self.variable_level_1.id, - "value_char": "test", - "jet_id": jet_without_manager.id, - } - ) - - def test_jet_manager_in_users_access(self): - """Test access rights for Jet when manager is in user_ids only""" - - # Create new jet with manager in user_ids only (not in manager_ids) - jet_with_manager_user = self.Jet.create( - { - "name": "Jet With Manager User", - "jet_template_id": self.jet_template.id, - "server_id": self.server_1.id, - "user_ids": [(4, self.manager.id)], # Add manager to user_ids only - "manager_ids": [(5, 0, 0)], - } - ) - - # Create test values as root to set up the test - jet_value_1 = self.VariableValue.create( - { - "variable_id": self.variable_level_1.id, - "value_char": "manager_user_value_1", - "jet_id": jet_with_manager_user.id, - } - ) - - jet_value_2 = self.VariableValue.create( - { - "variable_id": self.variable_level_2.id, - "value_char": "manager_user_value_2", - "jet_id": jet_with_manager_user.id, - } - ) - - # Test manager can read both level 1 and level 2 values - # (Manager Read rule allows access_level <= '2' when manager is in user_ids) - manager_values = self.VariableValue.with_user(self.manager).search( - [("jet_id", "=", jet_with_manager_user.id)] - ) - self.assertEqual(len(manager_values), 2) - self.assertIn(jet_value_1.id, manager_values.ids) - self.assertIn(jet_value_2.id, manager_values.ids) - - # Create a new variable for testing create access - test_variable = self.Variable.create( - { - "name": "Test Jet User Variable", - "access_level": "1", - } - ) - - # Test manager cannot create values - with self.assertRaises(AccessError): - self.VariableValue.with_user(self.manager).create( - { - "variable_id": test_variable.id, - "value_char": "new_manager_user_value", - "jet_id": jet_with_manager_user.id, - } - ) - - # Test manager cannot write values - with self.assertRaises(AccessError): - jet_value_1.with_user(self.manager).write( - {"value_char": "updated_manager_user_value"} - ) - - # Test manager cannot delete values - with self.assertRaises(AccessError): - jet_value_1.with_user(self.manager).unlink() - - def test_jet_template_access(self): - """Test access rights for Jet Template variable values""" - - # Test user access to template values - # User should see level 1 values from jet templates they're added to - user_jet_template_values = self.VariableValue.with_user(self.user).search( - [("jet_template_id", "=", self.jet_template.id)] - ) - self.assertEqual(len(user_jet_template_values), 1) - self.assertIn(self.jet_template_value_1.id, user_jet_template_values.ids) - - # User should not be able to create/write/unlink values - with self.assertRaises(AccessError): - self.VariableValue.with_user(self.user).create( - { - "variable_id": self.variable_level_1.id, - "value_char": "test", - "jet_template_id": self.jet_template.id, - } - ) - - with self.assertRaises(AccessError): - self.jet_template_value_1.with_user(self.user).write( - {"value_char": "new_value"} - ) - - with self.assertRaises(AccessError): - self.jet_template_value_1.with_user(self.user).unlink() - - # Test manager access to template values - # Manager should see all level 1 and 2 values from jet templates - # they're added to - manager_jet_template_values = self.VariableValue.with_user(self.manager).search( - [("jet_template_id", "=", self.jet_template.id)] - ) - self.assertEqual(len(manager_jet_template_values), 2) - self.assertIn(self.jet_template_value_1.id, manager_jet_template_values.ids) - self.assertIn(self.jet_template_value_2.id, manager_jet_template_values.ids) - - # Create a new variable for testing manager create rights - test_variable = self.Variable.create( - { - "name": "Test Jet Template Manager Variable", - "access_level": "2", - } - ) - - # Test manager create rights (only when manager in template manager_ids) - new_jet_template_value = self.VariableValue.with_user(self.manager).create( - { - "variable_id": test_variable.id, - "value_char": "new_jet_template_value", - "jet_template_id": self.jet_template.id, - } - ) - self.assertTrue(new_jet_template_value.exists()) - - # Test manager write rights - self.jet_template_value_2.with_user(self.manager).write( - {"value_char": "updated_jet_template_value"} - ) - self.assertEqual( - self.jet_template_value_2.value_char, "updated_jet_template_value" - ) - - # Test manager unlink rights (only own records) - new_jet_template_value.with_user(self.manager).unlink() - self.assertFalse(new_jet_template_value.exists()) - - # Test manager cannot create values for templates they're not managers of - jet_template_without_manager = self.JetTemplate.create( - { - "name": "Template Without Manager", - "server_ids": [(4, self.server_1.id)], - "user_ids": [(4, self.user2.id)], - "manager_ids": [(4, self.manager2.id)], - } - ) - - with self.assertRaises(AccessError): - self.VariableValue.with_user(self.manager).create( - { - "variable_id": self.variable_level_1.id, - "value_char": "test", - "jet_template_id": jet_template_without_manager.id, - } - ) - - def test_jet_template_manager_in_users_access(self): - """Test access rights for Jet Template when manager is in user_ids only""" - - # Create new template with manager in user_ids only (not in manager_ids) - template_with_manager_user = self.JetTemplate.create( - { - "name": "Template With Manager User", - "server_ids": [(4, self.server_1.id)], - "user_ids": [(4, self.manager.id)], # Add manager to user_ids only - "manager_ids": [(5, 0, 0)], - } - ) - - # Create test values as root to set up the test - template_value_1 = self.VariableValue.create( - { - "variable_id": self.variable_level_1.id, - "value_char": "manager_user_value_1", - "jet_template_id": template_with_manager_user.id, - } - ) - - template_value_2 = self.VariableValue.create( - { - "variable_id": self.variable_level_2.id, - "value_char": "manager_user_value_2", - "jet_template_id": template_with_manager_user.id, - } - ) - - # Test manager can read both level 1 and level 2 values - # (Manager Read rule allows access_level <= '2' when manager is in user_ids) - manager_values = self.VariableValue.with_user(self.manager).search( - [("jet_template_id", "=", template_with_manager_user.id)] - ) - self.assertEqual(len(manager_values), 2) - self.assertIn(template_value_1.id, manager_values.ids) - self.assertIn(template_value_2.id, manager_values.ids) - - # Create a new variable for testing create access - test_variable = self.Variable.create( - { - "name": "Test Template User Variable", - "access_level": "1", - } - ) - - # Test manager cannot create values - with self.assertRaises(AccessError): - self.VariableValue.with_user(self.manager).create( - { - "variable_id": test_variable.id, - "value_char": "new_manager_user_value", - "jet_template_id": template_with_manager_user.id, - } - ) - - # Test manager cannot write values - with self.assertRaises(AccessError): - template_value_1.with_user(self.manager).write( - {"value_char": "updated_manager_user_value"} - ) - - # Test manager cannot delete values - with self.assertRaises(AccessError): - template_value_1.with_user(self.manager).unlink() - - def test_reference_pattern_global_server_template_action(self): - """Ensure model-scoped references follow the required pattern.""" - # Global - model_ref = self.VariableValue._get_model_generic_reference() - self.assertTrue(self.global_value_1.reference.endswith(f"_{model_ref}_global")) - - # Server - srv_model_ref = self.Server._get_model_generic_reference() - self.assertTrue( - self.server_value_1.reference.startswith( - f"{self.variable_level_1.reference}_{model_ref}_{srv_model_ref}_" - ) - ) - - # Server Template - tmpl_model_ref = self.ServerTemplate._get_model_generic_reference() - self.assertTrue( - self.template_value_1.reference.startswith( - f"{self.variable_level_1.reference}_{model_ref}_{tmpl_model_ref}_" - ) - ) - - # Plan Line Action - action_model_ref = self.plan_line_action._get_model_generic_reference() - self.assertTrue( - self.plan_value_1.reference.startswith( - f"{self.variable_level_1.reference}_{model_ref}_{action_model_ref}_" - ) - ) - - # Jet Template - jet_tmpl_model_ref = self.JetTemplate._get_model_generic_reference() - self.assertTrue( - self.jet_template_value_1.reference.startswith( - f"{self.variable_level_1.reference}_{model_ref}_{jet_tmpl_model_ref}_" - ) - ) - - # Jet - jet_model_ref = self.Jet._get_model_generic_reference() - self.assertTrue( - self.jet_value_1.reference.startswith( - f"{self.variable_level_1.reference}_{model_ref}_{jet_model_ref}_" - ) - ) diff --git a/addons/cetmix_tower_server/tests/test_vault_mixin.py b/addons/cetmix_tower_server/tests/test_vault_mixin.py deleted file mode 100644 index d387342..0000000 --- a/addons/cetmix_tower_server/tests/test_vault_mixin.py +++ /dev/null @@ -1,534 +0,0 @@ -# Copyright (C) 2022 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - - -from .common import TestTowerCommon - - -class TestVaultMixin(TestTowerCommon): - """Test vault mixin functionality.""" - - def test_vault_mixin_secret_fields(self): - """Test vault mixin functionality for secret fields - (host_key and ssh_password)""" - # Create a server with initial secret values - initial_password = "initial_password" - initial_host_key = "initial_host_key" - - server = self.Server.create( - { - "name": "Vault Test Server", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": initial_password, - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "host_key": initial_host_key, - "skip_host_key": False, - } - ) - - # Test 1: Verify initial values are stored in vault and accessible - # Read values using common way - should return placeholder - self.assertEqual( - server.ssh_password, - self.Server.SECRET_VALUE_PLACEHOLDER, - "ssh_password should return placeholder value when read normally", - ) - self.assertEqual( - server.host_key, - self.Server.SECRET_VALUE_PLACEHOLDER, - "host_key should return placeholder value when read normally", - ) - - # Read using _get_secret_values() - should return actual initial values - secret_values = server._get_secret_values() - self.assertIsNotNone(secret_values, "secret_values should not be None") - self.assertIn(server.id, secret_values, "Server ID should be in secret values") - - server_secrets = secret_values[server.id] - self.assertIn( - "ssh_password", server_secrets, "ssh_password should be in secret values" - ) - self.assertIn("host_key", server_secrets, "host_key should be in secret values") - - self.assertEqual( - server_secrets["ssh_password"], - initial_password, - "ssh_password should return initial value from vault", - ) - self.assertEqual( - server_secrets["host_key"], - initial_host_key, - "host_key should return initial value from vault", - ) - - # Read individual fields using _get_secret_value() - # should return initial values - retrieved_password = server._get_secret_value("ssh_password") - retrieved_host_key = server._get_secret_value("host_key") - - self.assertEqual( - retrieved_password, - initial_password, - "_get_secret_value should return correct initial ssh_password", - ) - self.assertEqual( - retrieved_host_key, - initial_host_key, - "_get_secret_value should return correct initial host_key", - ) - - # Test 2: Save new values to secret fields - new_password = "new_secure_password_123" - new_host_key = "new_host_key_456" - - server.write( - { - "ssh_password": new_password, - "host_key": new_host_key, - } - ) - - # Test 3: Read values using common way after update - should return placeholder - # Note: In Odoo, we need to re-read the record to see updated values - server = self.Server.browse(server.id) - self.assertEqual( - server.ssh_password, - self.Server.SECRET_VALUE_PLACEHOLDER, - "ssh_password should return placeholder value when read normally " - "after update", - ) - self.assertEqual( - server.host_key, - self.Server.SECRET_VALUE_PLACEHOLDER, - "host_key should return placeholder value when read normally " - "after update", - ) - - # Test 4: Read using _get_secret_values() after update - # should return new values - secret_values = server._get_secret_values() - self.assertIsNotNone( - secret_values, "secret_values should not be None after update" - ) - self.assertIn( - server.id, - secret_values, - "Server ID should be in secret values after update", - ) - - server_secrets = secret_values[server.id] - self.assertIn( - "ssh_password", - server_secrets, - "ssh_password should be in secret values after update", - ) - self.assertIn( - "host_key", - server_secrets, - "host_key should be in secret values after update", - ) - - self.assertEqual( - server_secrets["ssh_password"], - new_password, - "ssh_password should return new value from vault after update", - ) - self.assertEqual( - server_secrets["host_key"], - new_host_key, - "host_key should return new value from vault after update", - ) - - # Test 5: Read individual fields using _get_secret_value() after update - # Get both values in one call using _get_secret_values() - secret_values = server._get_secret_values() - self.assertIsNotNone( - secret_values, "secret_values should not be None for individual field test" - ) - self.assertIn( - server.id, - secret_values, - "Server ID should be in secret values for individual field test", - ) - - server_secrets = secret_values[server.id] - retrieved_password = server_secrets["ssh_password"] - retrieved_host_key = server_secrets["host_key"] - - self.assertEqual( - retrieved_password, - new_password, - "_get_secret_values should return correct new ssh_password after update", - ) - self.assertEqual( - retrieved_host_key, - new_host_key, - "_get_secret_values should return correct new host_key after update", - ) - - # Test 6: Verify that non-secret fields are not affected - self.assertEqual( - server.name, - "Vault Test Server", - "Non-secret field should not be affected by vault mixin", - ) - self.assertEqual( - server.ssh_username, - "admin", - "Non-secret field should not be affected by vault mixin", - ) - - def test_vault_mixin_create_with_secret_fields(self): - """Test vault mixin functionality when creating records with secret fields""" - # Create a server with secret fields - server = self.Server.create( - { - "name": "Create Test Server", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "create_password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "host_key": "create_host_key", - "skip_host_key": False, - } - ) - - # Verify secret fields are stored in vault and not in main table - self.assertEqual( - server.ssh_password, - self.Server.SECRET_VALUE_PLACEHOLDER, - "ssh_password should return placeholder after creation", - ) - self.assertEqual( - server.host_key, - self.Server.SECRET_VALUE_PLACEHOLDER, - "host_key should return placeholder after creation", - ) - - # Verify actual values are accessible via vault methods - secret_values = server._get_secret_values() - self.assertIn( - server.id, - secret_values, - "Server ID should be in secret values after creation", - ) - - server_secrets = secret_values[server.id] - self.assertEqual( - server_secrets["ssh_password"], - "create_password", - "ssh_password should be stored in vault after creation", - ) - self.assertEqual( - server_secrets["host_key"], - "create_host_key", - "host_key should be stored in vault after creation", - ) - - def test_vault_mixin_delete_secret_fields(self): - """Test vault mixin functionality when deleting secret field values""" - # Create a server with secret fields - server = self.Server.create( - { - "name": "Delete Test Server", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "delete_password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "host_key": "delete_host_key", - "skip_host_key": False, - } - ) - - # Verify initial values exist - secret_values = server._get_secret_values() - self.assertIn( - "ssh_password", - secret_values[server.id], - "ssh_password should exist initially", - ) - self.assertIn( - "host_key", secret_values[server.id], "host_key should exist initially" - ) - - # Delete secret field values - server.write( - { - "ssh_password": False, - "host_key": False, - } - ) - - # Verify values are removed from vault - secret_values = server._get_secret_values() - server_secrets = secret_values.get(server.id, {}) - - self.assertNotIn( - "ssh_password", server_secrets, "ssh_password should be removed from vault" - ) - self.assertNotIn( - "host_key", server_secrets, "host_key should be removed from vault" - ) - - # Verify normal field access still returns placeholders - server = self.Server.browse(server.id) - self.assertEqual( - server.ssh_password, - self.Server.SECRET_VALUE_PLACEHOLDER, - "ssh_password should return placeholder after deletion", - ) - self.assertEqual( - server.host_key, - self.Server.SECRET_VALUE_PLACEHOLDER, - "host_key should return placeholder after deletion", - ) - - def test_vault_mixin_bulk_create_with_secret_fields(self): - """Test vault mixin functionality when creating multiple servers with different - secret field configurations""" - placeholder = self.Server.SECRET_VALUE_PLACEHOLDER - # Create 3 servers with different secret field configurations - servers_data = [ - { - "name": "Server 1 - Both Fields", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password1", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "host_key": "host_key1", - "skip_host_key": False, - }, - { - "name": "Server 2 - Host Key Only", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_auth_mode": "k", - "os_id": self.os_debian_10.id, - "host_key": "host_key2", - "skip_host_key": False, - "ssh_key_id": self.key_1.id, - }, - { - "name": "Server 3 - SSH Password Only", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password3", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "skip_host_key": True, - }, - ] - - # Create all servers in one call - servers = self.Server.create(servers_data) - - # Verify we have 3 servers - self.assertEqual(len(servers), 3, "Should have created 3 servers") - - # Test 1: Get values for all 3 servers regular way - should return placeholders - for server in servers: - self.assertEqual( - server.ssh_password, - placeholder, - f"Server {server.name} ssh_password should return placeholder " - f"when read normally", - ) - - self.assertEqual( - server.host_key, - placeholder, - f"Server {server.name} host_key should return placeholder " - f"when read normally", - ) - - # Test 2: Get values for all 3 servers at once using _get_secret_values() - all_secret_values = servers._get_secret_values() - self.assertIsNotNone(all_secret_values, "all_secret_values should not be None") - - # Verify Server 1 (both fields) - server1 = servers[0] - self.assertIn( - server1.id, all_secret_values, "Server 1 should be in secret values" - ) - server1_secrets = all_secret_values[server1.id] - - self.assertEqual( - server1_secrets.get("ssh_password"), - "password1", - "Server 1 ssh_password should be preserved correctly in vault", - ) - self.assertEqual( - server1_secrets.get("host_key"), - "host_key1", - "Server 1 host_key should be preserved correctly in vault", - ) - - # Verify Server 2 (host key only) - server2 = servers[1] - self.assertIn( - server2.id, all_secret_values, "Server 2 should be in secret values" - ) - server2_secrets = all_secret_values[server2.id] - - self.assertIsNone( - server2_secrets.get("ssh_password"), - "Server 2 should not have ssh_password in vault", - ) - self.assertEqual( - server2_secrets.get("host_key"), - "host_key2", - "Server 2 host_key should be preserved correctly in vault", - ) - - # Verify Server 3 (ssh password only) - server3 = servers[2] - self.assertIn( - server3.id, all_secret_values, "Server 3 should be in secret values" - ) - server3_secrets = all_secret_values[server3.id] - - self.assertEqual( - server3_secrets.get("ssh_password"), - "password3", - "Server 3 ssh_password should be preserved correctly in vault", - ) - self.assertIsNone( - server3_secrets.get("host_key"), - "Server 3 should not have host_key in vault", - ) - - # Test 3: Verify that non-secret fields are not affected - for server in servers: - self.assertIsNotNone( - server.name, - f"Server {server.id} name should not be affected by vault mixin", - ) - self.assertIsNotNone( - server.ssh_username, - f"Server {server.id} ssh_username should not be affected " - f"by vault mixin", - ) - self.assertIsNotNone( - server.ip_v4_address, - f"Server {server.id} ip_v4_address should not be affected " - f"by vault mixin", - ) - - # Test 4: Modify secret fields and verify changes are handled correctly - # Change the ssh password and remove the host key from Server 1 - server1 = servers.filtered(lambda s: s.name == "Server 1 - Both Fields") - server1.write( - { - "ssh_password": "updated_password1", - "host_key": False, - } - ) - - # Remove host key and add an ssh password in Server 2 - server2 = servers.filtered(lambda s: s.name == "Server 2 - Host Key Only") - server2.write( - { - "host_key": False, - "ssh_password": "new_password2", - } - ) - - # Remove ssh password from Server 3 - server3 = servers.filtered(lambda s: s.name == "Server 3 - SSH Password Only") - server3.write( - { - "ssh_password": False, - } - ) - - # Test 5: Get values for all 3 servers regular way after modifications - # Ensure that all values are replaced with placeholders - for server in servers: - self.assertEqual( - server.ssh_password, - placeholder, - f"Server {server.id} ssh_password should return placeholder " - f"after modifications", - ) - self.assertEqual( - server.host_key, - placeholder, - f"Server {server.id} host_key should return placeholder " - f"after modifications", - ) - - # Test 6: Get values for all 3 servers at once using _get_secret_values() - # Ensure that all values are preserved correctly after modifications - all_secret_values = servers._get_secret_values() - self.assertIsNotNone( - all_secret_values, - "all_secret_values should not be None after modifications", - ) - - # Verify Server 1 (updated password, no host key) - server1 = servers[0] - server1_secrets = all_secret_values[server1.id] - - self.assertEqual( - server1_secrets.get("ssh_password"), - "updated_password1", - "Server 1 ssh_password should be updated correctly in vault", - ) - self.assertIsNone( - server1_secrets.get("host_key"), - "Server 1 host_key should be removed from vault", - ) - - # Verify Server 2 (new password, no host key) - server2_secrets = all_secret_values[server2.id] - - self.assertEqual( - server2_secrets.get("ssh_password"), - "new_password2", - "Server 2 ssh_password should be added correctly in vault", - ) - self.assertIsNone( - server2_secrets.get("host_key"), - "Server 2 host_key should be removed from vault", - ) - - # Verify Server 3 (no ssh password, no host key) - # Server 3 should not be in the result since it has no secret values - self.assertNotIn( - server3.id, - all_secret_values, - "Server 3 should not be in secret values since it has no secret fields", - ) - - def test_is_secret_value_set(self): - """Test _is_secret_value_set returns True/False for host_key correctly.""" - server = self.Server.create( - { - "name": "Is Secret Set Test Server", - "ip_v4_address": "localhost", - "ssh_username": "admin", - "ssh_password": "password", - "ssh_auth_mode": "p", - "os_id": self.os_debian_10.id, - "host_key": "test_host_key_value", - "skip_host_key": False, - } - ) - - self.assertTrue( - server._is_secret_value_set("host_key"), - "host_key should be considered set when value exists in vault", - ) - - server.write({"host_key": False}) - server = self.Server.browse(server.id) - - self.assertFalse( - server._is_secret_value_set("host_key"), - "host_key should be considered not set when cleared", - ) diff --git a/addons/cetmix_tower_server/views/cx_tower_command_log_view.xml b/addons/cetmix_tower_server/views/cx_tower_command_log_view.xml deleted file mode 100644 index 97fe1a8..0000000 --- a/addons/cetmix_tower_server/views/cx_tower_command_log_view.xml +++ /dev/null @@ -1,232 +0,0 @@ - - - - cx.tower.command.log.view.form - cx.tower.command.log - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - cx.tower.command.log.view.tree - cx.tower.command.log - - - - - - - - - - - - - - - - - - cx.tower.command.log.view.search - cx.tower.command.log - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Command Log - ir.actions.act_window - cx.tower.command.log - tree,form - {} - - -
diff --git a/addons/cetmix_tower_server/views/cx_tower_command_view.xml b/addons/cetmix_tower_server/views/cx_tower_command_view.xml deleted file mode 100644 index a7d770e..0000000 --- a/addons/cetmix_tower_server/views/cx_tower_command_view.xml +++ /dev/null @@ -1,346 +0,0 @@ - - - - - cx.tower.command.view.form - cx.tower.command - -
- - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - - cx.tower.command.view.tree - cx.tower.command - - - - - - - - - - - - - - - - - - cx.tower.command.view.search - cx.tower.command - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Command - ir.actions.act_window - cx.tower.command - tree,form - - -
diff --git a/addons/cetmix_tower_server/views/cx_tower_file_template_view.xml b/addons/cetmix_tower_server/views/cx_tower_file_template_view.xml deleted file mode 100644 index 9c69b82..0000000 --- a/addons/cetmix_tower_server/views/cx_tower_file_template_view.xml +++ /dev/null @@ -1,167 +0,0 @@ - - - - - cx.tower.file.template.view.form - cx.tower.file.template - -
- -
- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - - cx.tower.file.template.view.tree - cx.tower.file.template - - - - - - - - - - - - - - - cx.tower.file.template.view.search - cx.tower.file.template - - - - - - - - - - - - - - - - - - - Templates - cx.tower.file.template - tree,form - - -

- Add a new file template -

-
-
- -
diff --git a/addons/cetmix_tower_server/views/cx_tower_file_view.xml b/addons/cetmix_tower_server/views/cx_tower_file_view.xml deleted file mode 100644 index 07c6380..0000000 --- a/addons/cetmix_tower_server/views/cx_tower_file_view.xml +++ /dev/null @@ -1,318 +0,0 @@ - - - - - cx.tower.file.view.form - cx.tower.file - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
-
-
-
- - - cx.tower.file.view.tree - cx.tower.file - - - - - - - - - - - - - - - - - - - - cx.tower.file.view.search - cx.tower.file - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Files - cx.tower.file - tree,form - - [] - {} - -

- Add a new file -

-
-
- - - Upload - - - code - action = records.action_push_to_server() - - - - Download - - - code - action = records.action_pull_from_server() - - - - Delete from server - - - code - action = records.action_delete_from_server() - - - -
diff --git a/addons/cetmix_tower_server_queue/README.rst b/addons/cetmix_tower_server_queue/README.rst deleted file mode 100644 index a14f1fd..0000000 --- a/addons/cetmix_tower_server_queue/README.rst +++ /dev/null @@ -1,122 +0,0 @@ -========================= -Cetmix Tower Server Queue -========================= - -.. - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! This file is generated by oca-gen-addon-readme !! - !! changes will be overwritten. !! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:bcdbf27340bb59ec9a0cf443b108e2d6b27cf7c64466b47585fbd02410ef071b - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png - :target: https://odoo-community.org/page/development-status - :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png - :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html - :alt: License: AGPL-3 -.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github - :target: https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server_queue - :alt: cetmix/cetmix-tower - -|badge1| |badge2| |badge3| - -This module implements asynchronous task execution for `Cetmix -Tower `__. - -It requires the `queue_job `__ -module to be installed and configured in the Odoo instance. - -Please refer to the `official -documentation `__ for detailed information. - -**Table of contents** - -.. contents:: - :local: - -Configuration -============= - -Please refer to the `official -documentation `__ for detailed configuration -instructions. - -Usage -===== - -Please refer to the `official -documentation `__ for detailed usage -instructions. - -Changelog -========= - -16.0.2.0.0 (2026-03-23) ------------------------ - -- Features: Jets! (4700) - -16.0.1.2.0 (2025-11-12) ------------------------ - -- Features: Use the 'web_notify' module to send user notifications. - (5074) - -16.0.1.1.4 (2025-11-05) ------------------------ - -- Bugfixes: Finish multiple commands at once. (5062) - -16.0.1.1.3 (2025-10-13) ------------------------ - -- Features: Terminate running flight plan manually (3410) - -16.0.1.1.0 (2025-07-16) ------------------------ - -- Features: cetmix_tower_server_queue: Add async file upload/download - via job queue (3720) -- Features: Terminate command with error if job has failed (4718) - -16.0.1.0.2 (2025-05-16) ------------------------ - -- Features: 'sudo' parameter is not passed to command. (4678) - -16.0.1.0.1 (2025-05-09) ------------------------ - -- Bugfixes: Non-critical issues and performance improvements. (4611) - -16.0.1.0.0 ----------- - -Release for Odoo 16.0 - -Bug Tracker -=========== - -Bugs are tracked on `GitHub Issues `_. -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 `_. - -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 `_ project on GitHub. - -You are welcome to contribute. diff --git a/addons/cetmix_tower_server_queue/__init__.py b/addons/cetmix_tower_server_queue/__init__.py deleted file mode 100644 index 0650744..0000000 --- a/addons/cetmix_tower_server_queue/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import models diff --git a/addons/cetmix_tower_server_queue/__manifest__.py b/addons/cetmix_tower_server_queue/__manifest__.py deleted file mode 100644 index 2cceadc..0000000 --- a/addons/cetmix_tower_server_queue/__manifest__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (C) 2022 Cetmix OÜ -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -{ - "name": "Cetmix Tower Server Queue", - "summary": "Cetmix Tower asynchronous task execution using 'queue_job'", - "version": "16.0.2.0.0", - "development_status": "Beta", - "category": "Productivity", - "website": "https://tower.cetmix.com", - "author": "Cetmix", - "license": "AGPL-3", - "installable": True, - "auto_install": True, - "depends": ["cetmix_tower_server", "queue_job"], - "data": [ - "views/cx_tower_command_log_view.xml", - "views/cx_tower_file_view.xml", - ], -} diff --git a/addons/cetmix_tower_server_queue/i18n/cetmix_tower_server_queue.pot b/addons/cetmix_tower_server_queue/i18n/cetmix_tower_server_queue.pot deleted file mode 100644 index bda7834..0000000 --- a/addons/cetmix_tower_server_queue/i18n/cetmix_tower_server_queue.pot +++ /dev/null @@ -1,150 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * cetmix_tower_server_queue -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"Last-Translator: \n" -"Language-Team: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Plural-Forms: \n" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_command_log__command_status -msgid "" -"0 if command finished successfully.\n" -"-100 general error,\n" -"-101 not found,\n" -"-201 another instance of this command is running,\n" -"-202 no runner found for the command action,\n" -"-203 Python code execution failed\n" -"-205 plan line condition check failed\n" -"503 if SSH connection error occurred\n" -"601 if queue job failed" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_command_log -msgid "Cetmix Tower Command Log" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_file -msgid "Cetmix Tower File" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_server -msgid "Cetmix Tower Server" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -msgid "Error" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__command_status -msgid "Exit Code" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Failure" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File downloaded!" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed -msgid "File is currently being processed" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File uploaded!" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File(s) %(name)s download failed: %(error)s" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File(s) %(name)s upload failed: %(error)s" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Files downloaded!" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Files uploaded!" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed -msgid "Is Being Processed" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -msgid "Processing" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_queue_job -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__queue_job_id -msgid "Queue Job" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -#, python-format -msgid "Success" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "The following files are already being processed: %(name)s" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "" -"Unable to upload file '%(f)s'.\n" -"Upload operation is not supported for 'server' type files." -msgstr "" diff --git a/addons/cetmix_tower_server_queue/i18n/it.po b/addons/cetmix_tower_server_queue/i18n/it.po deleted file mode 100644 index 72ce79a..0000000 --- a/addons/cetmix_tower_server_queue/i18n/it.po +++ /dev/null @@ -1,148 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * cetmix_tower_server_queue -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: \n" -"PO-Revision-Date: \n" -"Last-Translator: \n" -"Language-Team: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Language: it\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 2.3\n" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_command_log__command_status -msgid "" -"0 if command finished successfully.\n" -"-100 general error,\n" -"-101 not found,\n" -"-201 another instance of this command is running,\n" -"-202 no runner found for the command action,\n" -"-203 Python code execution failed\n" -"-205 plan line condition check failed\n" -"503 if SSH connection error occurred\n" -"601 if queue job failed" -msgstr "0 se il comando è stato completato correttamente.-100 errore generale,-101 non trovato,-201 un'altra istanza di questo comando è in esecuzione,-202 nessun runner trovato per l'azione del comando,-203 esecuzione del codice Python non riuscita,-205 controllo delle condizioni della riga del piano non riuscito,503 se si è verificato un errore di connessione SSH,601 se il processo in coda non è riuscito." - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_command_log -msgid "Cetmix Tower Command Log" -msgstr "Registro comando Cetmix Tower" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_file -msgid "Cetmix Tower File" -msgstr "File Cetmix Tower" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_server -msgid "Cetmix Tower Server" -msgstr "Server Cetmix Tower" - -#. module: cetmix_tower_server_queue -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -msgid "Error" -msgstr "Errore" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__command_status -msgid "Exit Code" -msgstr "Codice uscita" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Failure" -msgstr "Fallimento" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File downloaded!" -msgstr "File scaricato!" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed -msgid "File is currently being processed" -msgstr "Il file è in lavorazione" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File uploaded!" -msgstr "File caricato!" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Files downloaded!" -msgstr "File scaricati!" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Files uploaded!" -msgstr "File caricati!" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed -msgid "Is Being Processed" -msgstr "In lavorazione" - -#. module: cetmix_tower_server_queue -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -msgid "Processing" -msgstr "Lavorazione" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_queue_job -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__queue_job_id -msgid "Queue Job" -msgstr "Accoda lavoro" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -#, python-format -msgid "Success" -msgstr "Successo" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "The following files are already being processed: %(name)s" -msgstr "I seguenti file sono già in fase di elaborazione: %(name)s" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "" -"Unable to upload file '%(f)s'.\n" -"Upload operation is not supported for 'server' type files." -msgstr "" -"Impossibile caricare il file '%(f)s'.\n" -"L'operazione di caricamento non è supportata per i file di tipo 'server'." - -#~ msgid "Display Name" -#~ msgstr "Nome visualizzato" - -#~ msgid "ID" -#~ msgstr "ID" - -#~ msgid "Last Modified on" -#~ msgstr "Ultima modifica il" diff --git a/addons/cetmix_tower_server_queue/models/__init__.py b/addons/cetmix_tower_server_queue/models/__init__.py deleted file mode 100644 index 4e40e4b..0000000 --- a/addons/cetmix_tower_server_queue/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from . import cx_tower_command_log -from . import cx_tower_server -from . import queue_job -from . import cx_tower_file diff --git a/addons/cetmix_tower_server_queue/models/cx_tower_command_log.py b/addons/cetmix_tower_server_queue/models/cx_tower_command_log.py deleted file mode 100644 index 9e6c450..0000000 --- a/addons/cetmix_tower_server_queue/models/cx_tower_command_log.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -import logging - -from odoo import fields, models, tools - -from odoo.addons.cetmix_tower_server.models.constants import ( - COMMAND_STOPPED, - COMMAND_TIMED_OUT, -) -from odoo.addons.queue_job.job import CANCELLED - -_logger = logging.getLogger(__name__) - - -class CxTowerCommandLog(models.Model): - _inherit = "cx.tower.command.log" - - queue_job_id = fields.Many2one( - "queue.job", - readonly=True, - groups="queue_job.group_queue_job_manager", - ) - - command_status = fields.Integer( - help="0 if command finished successfully.\n" - "-100 general error,\n" - "-101 not found,\n" - "-201 another instance of this command is running,\n" - "-202 no runner found for the command action,\n" - "-203 Python code execution failed\n" - "-205 plan line condition check failed\n" - "503 if SSH connection error occurred\n" - "601 if queue job failed" - ) - - def finish( - self, finish_date=None, status=None, response=None, error=None, **kwargs - ): - """Finish the command log - - Args: - finish_date (Datetime, optional): Command finish date. Defaults to None. - status (Integer, optional): Command status. Defaults to None. - response (Text, optional): Command response. Defaults to None. - error (Text, optional): Command error. Defaults to None. - """ - - # Filter out command logs that are already stopped - command_logs_to_process = self.filtered( - lambda log: log.command_status != COMMAND_STOPPED - ) - if not command_logs_to_process: - return - - # Avoid finishing the command log multiple times at the same time - try: - with self.env.cr.savepoint(), tools.mute_logger("odoo.sql_db"): - self.env.cr.execute( - f"SELECT command_status FROM {self._table} WHERE id IN %s FOR UPDATE NOWAIT", # noqa: E501 - (tuple(command_logs_to_process.ids),), - ) - except Exception as e: - _logger.error( - "Could not acquire lock on command logs %s, skipping finish: %s", - command_logs_to_process.ids, - e, - ) - return - - # Update the related queue job state if the command timed out - if status == COMMAND_TIMED_OUT: - for command_log in command_logs_to_process: - if command_log.queue_job_id: - command_log.queue_job_id.sudo()._change_job_state( - CANCELLED, result=error - ) - - return super(CxTowerCommandLog, command_logs_to_process).finish( - finish_date, status, response, error, **kwargs - ) diff --git a/addons/cetmix_tower_server_queue/models/cx_tower_file.py b/addons/cetmix_tower_server_queue/models/cx_tower_file.py deleted file mode 100644 index 327ffa1..0000000 --- a/addons/cetmix_tower_server_queue/models/cx_tower_file.py +++ /dev/null @@ -1,184 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -import logging - -from odoo import _, fields, models -from odoo.exceptions import UserError - -_logger = logging.getLogger(__name__) - - -class CxTowerFile(models.Model): - _inherit = "cx.tower.file" - - is_being_processed = fields.Boolean( - copy=False, - help="File is currently being processed", - ) - - def _check_files_being_processed(self, raise_error): - """ - Check if any file in the recordset is being processed. - True if at least one file is already processing and raise_error is False. - False if no files are currently being processed. - The caller uses the boolean to decide whether to continue or abort. - """ - processing_files = self.filtered(lambda rec: rec.is_being_processed) - if processing_files: - if raise_error: - raise UserError( - _( - "The following files are already being processed: %(name)s", - name=", ".join(processing_files.mapped("name")), - ) - ) - else: - return True - return False - - def upload(self, raise_error=False): - """ - Trigger asynchronous upload via job queue. - """ - # Check if the file is already being processed - if self._check_files_being_processed(raise_error): - return - - self.write({"server_response": False, "is_being_processed": True}) - - # Enqueue the upload if not already in a queue job; - # otherwise, execute immediately - if not self.env.context.get("job_uuid"): - self.with_delay()._do_upload(raise_error=raise_error) - else: - self._do_upload(raise_error=raise_error) - - def download(self, raise_error=False): - """ - Trigger asynchronous download via job queue. - """ - - # Check if the file is already being processed - if self._check_files_being_processed(raise_error): - return - - self.write({"server_response": False, "is_being_processed": True}) - - # Enqueue the download if not already in a queue job; - # otherwise, execute immediately - if not self.env.context.get("job_uuid"): - self.with_delay()._do_download(raise_error=raise_error) - else: - self._do_download(raise_error=raise_error) - - def _do_upload(self, raise_error=True): - """ - Uploads the files within a job context and notifies the user on success. - Logs the error if an exception occurs; - failure state is managed by the parent method. - """ - try: - with self.env.cr.savepoint(): - result = super().upload(raise_error=raise_error) - single_msg = _("File uploaded!") - plural_msg = _("Files uploaded!") - self.env.user.notify_success( - message=single_msg if len(self) == 1 else plural_msg, - title=_("Success"), - # This notification should not be sticky - # to avoid blocking the user's screen - sticky=False, - ) - return result - except Exception as e: - if not raise_error: - self.env.user.notify_danger( - message=_( - "File(s) %(name)s upload failed: %(error)s", - name=", ".join(self.mapped("name")), - error=str(e), - ), - title=_("Failure"), - sticky=self.env["ir.config_parameter"] - .sudo() - .get_param("cetmix_tower_server.notification_type_error", "sticky") - == "sticky", - ) - _logger.error("File %s upload failed: %s", str(self), str(e)) - else: - raise - finally: - self.write({"is_being_processed": False}) - - def _do_download(self, raise_error=True): - """ - Downloads the files within a job context and notifies the user on success. - Logs the error if an exception occurs; - failure state is managed by the parent method. - """ - try: - with self.env.cr.savepoint(): - result = super().download(raise_error=raise_error) - single_msg = _("File downloaded!") - plural_msg = _("Files downloaded!") - self.env.user.notify_success( - message=single_msg if len(self) == 1 else plural_msg, - title=_("Success"), - # This notification should not be sticky - # to avoid blocking the user's screen - sticky=False, - ) - return result - except Exception as e: - if not raise_error: - self.env.user.notify_danger( - message=_( - "File(s) %(name)s download failed: %(error)s", - name=", ".join(self.mapped("name")), - error=str(e), - ), - title=_("Failure"), - sticky=self.env["ir.config_parameter"] - .sudo() - .get_param("cetmix_tower_server.notification_type_error", "sticky") - == "sticky", - ) - _logger.error("File %s download failed: %s", str(self), str(e)) - else: - raise - finally: - self.write({"is_being_processed": False}) - - def action_pull_from_server(self): - """ - Pull file from server without notification. - """ - tower_files = self.filtered(lambda file_: file_.source == "tower") - server_files = self - tower_files - - tower_files.action_get_current_server_code() - - server_files.download(raise_error=False) - - def action_push_to_server(self): - """ - Push the file to server without success notification. - """ - server_files = self.filtered(lambda file_: file_.source == "server") - if server_files: - return { - "type": "ir.actions.client", - "tag": "display_notification", - "params": { - "title": _("Failure"), - "message": _( - "Unable to upload file '%(f)s'.\n" - "Upload operation is not supported for 'server' type files.", - f=", ".join(server_files.mapped("rendered_name")), - ), - "sticky": False, - }, - } - - self.upload(raise_error=False) diff --git a/addons/cetmix_tower_server_queue/models/cx_tower_server.py b/addons/cetmix_tower_server_queue/models/cx_tower_server.py deleted file mode 100644 index ff4cbb1..0000000 --- a/addons/cetmix_tower_server_queue/models/cx_tower_server.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright (C) 2022 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import models - - -class CxTowerServer(models.Model): - _inherit = "cx.tower.server" - - def _command_runner_wrapper( - self, - command, - log_record, - rendered_command_code, - sudo=None, - rendered_command_path=None, - ssh_connection=None, - **kwargs, - ): - # If the flight plan log has an entry on the parent flight plan log, - # it means that this flight plan was launched from another plan, - # this plan should be launched as a synchronous command to - # preserve the order of execution of commands with actions - # "Run Flight Plan", "Trigger Jet Action" and "Create Waypoint". - # Use runner only if command log record is provided. - if ( - log_record - and not log_record.plan_log_id.parent_flight_plan_log_id - and command.action - not in [ - "jet_action", - "create_waypoint", - ] - ): - job = self.with_delay()._queue_command_runner_wrapper( - command=command, - log_record=log_record, - rendered_command_code=rendered_command_code, - sudo=sudo, - rendered_command_path=rendered_command_path, - ssh_connection=ssh_connection, - **kwargs, - ) - log_record.sudo().queue_job_id = job.db_record().id - - # Otherwise fallback to `super` to return the command output - else: - return super()._command_runner_wrapper( - command=command, - log_record=log_record, - rendered_command_code=rendered_command_code, - sudo=sudo, - rendered_command_path=rendered_command_path, - ssh_connection=ssh_connection, - **kwargs, - ) - - def _queue_command_runner_wrapper( - self, - command, - log_record, - rendered_command_code, - sudo=None, - rendered_command_path=None, - ssh_connection=None, - **kwargs, - ): - # avoid executing command if plan was stopped - log_record.invalidate_recordset(["plan_log_id"]) - plan_log_id = log_record.plan_log_id - if plan_log_id: - plan_log_id.invalidate_recordset(["is_stopped"]) - - # If plan was stopped, stop the command - if plan_log_id.is_stopped: - log_record.stop() - return - - return self._command_runner( - command=command, - log_record=log_record, - rendered_command_code=rendered_command_code, - sudo=sudo, - rendered_command_path=rendered_command_path, - ssh_connection=ssh_connection, - **kwargs, - ) diff --git a/addons/cetmix_tower_server_queue/models/queue_job.py b/addons/cetmix_tower_server_queue/models/queue_job.py deleted file mode 100644 index 7b66eea..0000000 --- a/addons/cetmix_tower_server_queue/models/queue_job.py +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright 2013-2020 Camptocamp SA -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) -from odoo import models - - -class QueueJob(models.Model): - _inherit = "queue.job" - - QUEUE_JOB_ERROR = 601 - - def write(self, vals): - """ - Override write method to update command status - and write error information in the log record - """ - if vals.get("state") == "failed": - log_record = self.kwargs.get("log_record") - if log_record: - log_record.finish( - status=self.QUEUE_JOB_ERROR, - error=vals.get("exc_info"), - ) - return super().write(vals) diff --git a/addons/cetmix_tower_server_queue/pyproject.toml b/addons/cetmix_tower_server_queue/pyproject.toml deleted file mode 100644 index 4231d0c..0000000 --- a/addons/cetmix_tower_server_queue/pyproject.toml +++ /dev/null @@ -1,3 +0,0 @@ -[build-system] -requires = ["whool"] -build-backend = "whool.buildapi" diff --git a/addons/cetmix_tower_server_queue/readme/CONFIGURE.md b/addons/cetmix_tower_server_queue/readme/CONFIGURE.md deleted file mode 100644 index 8c717e5..0000000 --- a/addons/cetmix_tower_server_queue/readme/CONFIGURE.md +++ /dev/null @@ -1 +0,0 @@ -Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions. diff --git a/addons/cetmix_tower_server_queue/readme/DESCRIPTION.md b/addons/cetmix_tower_server_queue/readme/DESCRIPTION.md deleted file mode 100644 index 54e6fc3..0000000 --- a/addons/cetmix_tower_server_queue/readme/DESCRIPTION.md +++ /dev/null @@ -1,5 +0,0 @@ -This module implements asynchronous task execution for [Cetmix Tower](https://cetmix.com/tower). - -It requires the [queue_job](https://github.com/OCA/queue/queue_job) module to be installed and configured in the Odoo instance. - -Please refer to the [official documentation](https://cetmix.com/tower) for detailed information. diff --git a/addons/cetmix_tower_server_queue/readme/HISTORY.md b/addons/cetmix_tower_server_queue/readme/HISTORY.md deleted file mode 100644 index 1d802a2..0000000 --- a/addons/cetmix_tower_server_queue/readme/HISTORY.md +++ /dev/null @@ -1,39 +0,0 @@ -## 16.0.2.0.0 (2026-03-23) - -- Features: Jets! (4700) - - -## 16.0.1.2.0 (2025-11-12) - -- Features: Use the 'web_notify' module to send user notifications. (5074) - - -## 16.0.1.1.4 (2025-11-05) - -- Bugfixes: Finish multiple commands at once. (5062) - - -## 16.0.1.1.3 (2025-10-13) - -- Features: Terminate running flight plan manually (3410) - - -## 16.0.1.1.0 (2025-07-16) - -- Features: cetmix_tower_server_queue: Add async file upload/download via job queue (3720) -- Features: Terminate command with error if job has failed (4718) - - -## 16.0.1.0.2 (2025-05-16) - -- Features: 'sudo' parameter is not passed to command. (4678) - - -## 16.0.1.0.1 (2025-05-09) - -- Bugfixes: Non-critical issues and performance improvements. (4611) - - -## 16.0.1.0.0 - -Release for Odoo 16.0 diff --git a/addons/cetmix_tower_server_queue/readme/USAGE.md b/addons/cetmix_tower_server_queue/readme/USAGE.md deleted file mode 100644 index 901f5a6..0000000 --- a/addons/cetmix_tower_server_queue/readme/USAGE.md +++ /dev/null @@ -1 +0,0 @@ -Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions. diff --git a/addons/cetmix_tower_server_queue/readme/newsfragments/.gitkeep b/addons/cetmix_tower_server_queue/readme/newsfragments/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/addons/cetmix_tower_server_queue/static/description/icon.png b/addons/cetmix_tower_server_queue/static/description/icon.png deleted file mode 100644 index 2507f55..0000000 Binary files a/addons/cetmix_tower_server_queue/static/description/icon.png and /dev/null differ diff --git a/addons/cetmix_tower_server_queue/static/description/index.html b/addons/cetmix_tower_server_queue/static/description/index.html deleted file mode 100644 index 3f70631..0000000 --- a/addons/cetmix_tower_server_queue/static/description/index.html +++ /dev/null @@ -1,491 +0,0 @@ - - - - - -Cetmix Tower Server Queue - - - -
-

Cetmix Tower Server Queue

- - -

Beta License: AGPL-3 cetmix/cetmix-tower

-

This module implements asynchronous task execution for Cetmix -Tower.

-

It requires the queue_job -module to be installed and configured in the Odoo instance.

-

Please refer to the official -documentation for detailed information.

-

Table of contents

- -
-

Configuration

-

Please refer to the official -documentation for detailed configuration -instructions.

-
-
-

Usage

-

Please refer to the official -documentation for detailed usage -instructions.

-
-
-

Changelog

-
-

16.0.2.0.0 (2026-03-23)

-
    -
  • Features: Jets! (4700)
  • -
-
-
-

16.0.1.2.0 (2025-11-12)

-
    -
  • Features: Use the ‘web_notify’ module to send user notifications. -(5074)
  • -
-
-
-

16.0.1.1.4 (2025-11-05)

-
    -
  • Bugfixes: Finish multiple commands at once. (5062)
  • -
-
-
-

16.0.1.1.3 (2025-10-13)

-
    -
  • Features: Terminate running flight plan manually (3410)
  • -
-
-
-

16.0.1.1.0 (2025-07-16)

-
    -
  • Features: cetmix_tower_server_queue: Add async file upload/download -via job queue (3720)
  • -
  • Features: Terminate command with error if job has failed (4718)
  • -
-
-
-

16.0.1.0.2 (2025-05-16)

-
    -
  • Features: ‘sudo’ parameter is not passed to command. (4678)
  • -
-
-
-

16.0.1.0.1 (2025-05-09)

-
    -
  • Bugfixes: Non-critical issues and performance improvements. (4611)
  • -
-
-
-

16.0.1.0.0

-

Release for Odoo 16.0

-
-
-
-

Bug Tracker

-

Bugs are tracked on GitHub Issues. -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.

-

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 project on GitHub.

-

You are welcome to contribute.

-
-
-
- - diff --git a/addons/cetmix_tower_server_queue/tests/__init__.py b/addons/cetmix_tower_server_queue/tests/__init__.py deleted file mode 100644 index 306c04b..0000000 --- a/addons/cetmix_tower_server_queue/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import test_command -from . import test_command_log -from . import test_file diff --git a/addons/cetmix_tower_server_queue/tests/test_command.py b/addons/cetmix_tower_server_queue/tests/test_command.py deleted file mode 100644 index 2f043d9..0000000 --- a/addons/cetmix_tower_server_queue/tests/test_command.py +++ /dev/null @@ -1,145 +0,0 @@ -from datetime import timedelta -from unittest.mock import patch - -from odoo.fields import Datetime -from odoo.tools import mute_logger - -from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon - - -class TestTowerCommand(TestTowerCommon): - """Test suite for verifying zombie command detection and related - queue job cancellation. - - Tests in this class verify that commands which have been running - longer than the timeout are properly detected as zombies, and their - associated queue jobs are cancelled. - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - # Set command timeout to 10 seconds - cls.env["ir.config_parameter"].sudo().set_param( - "cetmix_tower_server.command_timeout", "10" - ) - # Set old time to 20 seconds ago (older than timeout) - # to simulate running command in past - now = Datetime.now() - cls.old_time = now - timedelta(seconds=20) - - def _patch_command_runner(self, command_type, runner_method): - """Helper to patch a command runner to simulate a zombie command. - - Args: - command_type: Type of command runner to patch ('ssh' or 'python_code') - runner_method: Original method to wrap - - Returns: - A context manager that applies the patch - """ - - def _wrapper(*args, **kwargs): - # Modify args to disable log record finishing - args = list(args) - if len(args) > 1: - args[1] = False # Set log_record to False - return runner_method(*args, **kwargs) - - return patch.object( - self.registry["cx.tower.server"], - f"_command_runner_{command_type}", - _wrapper, - ) - - def _verify_zombie_command_job_cancellation(self, command_action): - """Verify zombie command is detected and job is cancelled. - - Args: - command_action: Action type ('ssh_command' or 'python_code') - """ - # check zombie command logs - domain = [ - ("is_running", "=", True), - ("start_date", "=", self.old_time), - ("command_action", "=", command_action), - ] - zombie_command_logs = self.env["cx.tower.command.log"].search(domain) - - self.assertEqual( - len(zombie_command_logs), 1, "Zombie command log should be created" - ) - self.assertTrue( - zombie_command_logs.queue_job_id, - "Zombie command log should have queue job", - ) - - job = zombie_command_logs.queue_job_id - self.assertTrue(job.exists(), "Zombie command job should exist") - - self.assertEqual(job.state, "pending", "Zombie command job should be pending") - - # run process to kill zombie command - self.server_test_1._check_zombie_commands() - - # check that command log is cancelled - self.assertEqual( - job.state, "cancelled", "Zombie command job should be cancelled" - ) - - def test_check_zombie_ssh_command_queue(self): - """ - Test that zombie ssh command is killed and job is cancelled - """ - # Create test commands - ssh_command = self.Command.create( - { - "name": "Test SSH Command", - "code": "ls -la", - "action": "ssh_command", - } - ) - - # patch command runner to not finish log record - cx_tower_server_obj = self.registry["cx.tower.server"] - _command_runner_ssh_super = cx_tower_server_obj._command_runner_ssh - - with self._patch_command_runner("ssh", _command_runner_ssh_super): - # run zombie command with log creation in past - self.server_test_1.run_command( - ssh_command, log={"start_date": self.old_time} - ) - - # check zombie command logs - self._verify_zombie_command_job_cancellation("ssh_command") - - @mute_logger("py.warnings") - def test_check_zombie_python_command_queue(self): - """ - Test that zombie python command is killed and job is cancelled - """ - # Create test commands - python_command = self.Command.create( - { - "name": "Test Python Command", - "code": "print('test')", - "action": "python_code", - } - ) - - # patch command runner to not finish log record - cx_tower_server_obj = self.registry["cx.tower.server"] - _command_runner_python_code_super = ( - cx_tower_server_obj._command_runner_python_code - ) - - with self._patch_command_runner( - "python_code", _command_runner_python_code_super - ): - # run zombie command with log creation in past - self.server_test_1.run_command( - python_command, log={"start_date": self.old_time} - ) - - # check zombie command logs - self._verify_zombie_command_job_cancellation("python_code") diff --git a/addons/cetmix_tower_server_queue/tests/test_command_log.py b/addons/cetmix_tower_server_queue/tests/test_command_log.py deleted file mode 100644 index 5bef9eb..0000000 --- a/addons/cetmix_tower_server_queue/tests/test_command_log.py +++ /dev/null @@ -1,37 +0,0 @@ -from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon -from odoo.addons.queue_job.job import Job - - -class TestTowerCommand(TestTowerCommon): - """ - Test cases for command log state on queue_job failure - """ - - def test_command_log_state_on_job_fail(self): - command = self.env["cx.tower.command"].create( - { - "name": "Test Command", - "action": "ssh_command", - "code": "echo 'Hello World'", - } - ) - self.assertTrue(command.id, "Command should be created successfully") - - self.server_test_1.run_command(command=command) - command_log = self.env["cx.tower.command.log"].search( - [("command_id", "=", command.id)], order="id desc", limit=1 - ) - self.assertTrue(command_log, "Command log should be created") - - job = command_log.queue_job_id - self.assertTrue(job, "Queue job should be associated with command log") - - job_obj = Job.load(self.env, job.uuid) - job_obj.set_failed() - job_obj.store() - self.assertEqual(job.state, "failed", "Job should be in failed state") - self.assertEqual( - command_log.command_status, - self.env["queue.job"].QUEUE_JOB_ERROR, - "Command log should be in failed state", - ) diff --git a/addons/cetmix_tower_server_queue/tests/test_file.py b/addons/cetmix_tower_server_queue/tests/test_file.py deleted file mode 100644 index c04a229..0000000 --- a/addons/cetmix_tower_server_queue/tests/test_file.py +++ /dev/null @@ -1,201 +0,0 @@ -from odoo import exceptions - -from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon -from odoo.addons.queue_job.tests.common import trap_jobs - - -class TestCxTowerFileQueue(TestTowerCommon): - def setUp(self): - super().setUp() - self.file_template = self.FileTemplate.create( - { - "name": "Test", - "file_name": "test.txt", - "server_dir": "/var/tmp", - "code": "Hello, world!", - } - ) - - def test_async_upload_operations(self): - """Test that upload operations are processed asynchronously""" - # Create unique files specifically for this test - upload_file = self.File.create( - { - "source": "tower", - "template_id": self.file_template.id, - "server_id": self.server_test_1.id, - "name": "upload_test_1", - "auto_sync": False, - } - ) - - upload_file_2 = self.File.create( - { - "name": "upload_test_2", - "source": "server", - "server_id": self.server_test_1.id, - "server_dir": "/var/tmp", - "auto_sync": False, - } - ) - - with trap_jobs() as trap: - upload_file.upload() - upload_file_2.upload() - - self.assertEqual(len(trap.enqueued_jobs), 2) - - upload_file.write({"server_response": "ok", "is_being_processed": False}) - upload_file_2.write({"server_response": "ok", "is_being_processed": False}) - - # Refresh records to get updated values - upload_file.invalidate_recordset() - upload_file_2.invalidate_recordset() - - # Verify the expected state - self.assertEqual(upload_file.server_response, "ok") - self.assertFalse(upload_file.is_being_processed) - - self.assertEqual(upload_file_2.server_response, "ok") - self.assertFalse(upload_file_2.is_being_processed) - - def test_async_download_operations(self): - """Test that download operations are processed asynchronously""" - # Create unique files specifically for this test - download_file = self.File.create( - { - "source": "tower", - "template_id": self.file_template.id, - "server_id": self.server_test_1.id, - "name": "download_test_1", - "auto_sync": False, - } - ) - - download_file_2 = self.File.create( - { - "name": "download_test_2", - "source": "server", - "server_id": self.server_test_1.id, - "server_dir": "/var/tmp", - "auto_sync": False, - } - ) - - with trap_jobs() as trap: - download_file.download() - download_file_2.download() - - # Verify jobs were created - self.assertEqual(len(trap.enqueued_jobs), 2) - - download_file.write({"server_response": "ok", "is_being_processed": False}) - download_file_2.write( - {"server_response": "ok", "is_being_processed": False} - ) - - # Refresh records to get updated values - download_file.invalidate_recordset() - download_file_2.invalidate_recordset() - - # Verify the expected state - self.assertEqual(download_file.server_response, "ok") - self.assertFalse(download_file.is_being_processed) - - self.assertEqual(download_file_2.server_response, "ok") - self.assertFalse(download_file_2.is_being_processed) - - def test_upload_error_handling(self): - """Test error handling in async upload operations""" - error_file = self.File.create( - { - "source": "tower", - "template_id": self.file_template.id, - "server_id": self.server_test_1.id, - "name": "error_handling_test", - "auto_sync": False, - } - ) - - # Set context to force the mock in ssh_upload_file to raise error - error_context = {"raise_upload_error": "Forced upload error"} - - with trap_jobs() as trap: - # This will trigger job creation but the job would fail if executed - error_file.with_context(**error_context).upload(raise_error=True) - - # Verify job was created - self.assertEqual(len(trap.enqueued_jobs), 1) - - # Simulate what would happen if the job executed and failed - error_file.write({"server_response": "error", "is_being_processed": False}) - error_file.invalidate_recordset() - - self.assertEqual(error_file.server_response, "error") - self.assertFalse(error_file.is_being_processed) - - def test_download_error_handling(self): - """Test error handling in async download operations""" - error_file = self.File.create( - { - "source": "server", - "server_id": self.server_test_1.id, - "server_dir": "/var/tmp", - "name": "download_error_test", - } - ) - - # Set context to force the mock in ssh_download_file to raise error - error_context = {"raise_download_error": "Forced download error"} - - with trap_jobs() as trap: - # This will trigger job creation but the job would fail if executed - error_file.with_context(**error_context).download(raise_error=True) - - # Verify job was created - self.assertEqual(len(trap.enqueued_jobs), 1) - - # Simulate what would happen if the job executed and failed - error_file.write({"server_response": "error", "is_being_processed": False}) - error_file.invalidate_recordset() - - self.assertEqual(error_file.server_response, "error") - self.assertFalse(error_file.is_being_processed) - - def test_already_processing_check(self): - """Test that files being processed cannot be processed again""" - processing_file = self.File.create( - { - "source": "tower", - "template_id": self.file_template.id, - "server_id": self.server_test_1.id, - "name": "processing_test_file", - "is_being_processed": True, - } - ) - - self.assertTrue(processing_file.is_being_processed) - - # Test with raising error - with self.assertRaises(exceptions.UserError): - processing_file.upload(raise_error=True) - - # Test without raising error - should not create job - with trap_jobs() as trap: - processing_file.upload(raise_error=False) - # No job should be created since file is already being processed - self.assertEqual(len(trap.enqueued_jobs), 0) - - # Verify still marked as processing - self.assertTrue(processing_file.is_being_processed) - - # Same tests for download - with self.assertRaises(exceptions.UserError): - processing_file.download(raise_error=True) - - with trap_jobs() as trap: - processing_file.download(raise_error=False) - # No job should be created - self.assertEqual(len(trap.enqueued_jobs), 0) - - self.assertTrue(processing_file.is_being_processed) diff --git a/addons/cetmix_tower_server_queue/views/cx_tower_command_log_view.xml b/addons/cetmix_tower_server_queue/views/cx_tower_command_log_view.xml deleted file mode 100644 index 5488bb3..0000000 --- a/addons/cetmix_tower_server_queue/views/cx_tower_command_log_view.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - cx.tower.command.log.view.form - cx.tower.command.log - - - - - - - - - diff --git a/addons/cetmix_tower_server_queue/views/cx_tower_file_view.xml b/addons/cetmix_tower_server_queue/views/cx_tower_file_view.xml deleted file mode 100644 index 55d7cd1..0000000 --- a/addons/cetmix_tower_server_queue/views/cx_tower_file_view.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - cx.tower.file.view.form - cx.tower.file - - - - - - - - - - - - - - cx.tower.queue.file.view.tree - cx.tower.file - - - - - - - - - is_being_processed == True - - - is_being_processed != True and server_response == 'ok' - - - is_being_processed != True and server_response not in ('ok', False) - - - - - diff --git a/addons/cetmix_tower_server_queue1/README.rst b/addons/cetmix_tower_server_queue1/README.rst deleted file mode 100644 index a14f1fd..0000000 --- a/addons/cetmix_tower_server_queue1/README.rst +++ /dev/null @@ -1,122 +0,0 @@ -========================= -Cetmix Tower Server Queue -========================= - -.. - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! This file is generated by oca-gen-addon-readme !! - !! changes will be overwritten. !! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:bcdbf27340bb59ec9a0cf443b108e2d6b27cf7c64466b47585fbd02410ef071b - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png - :target: https://odoo-community.org/page/development-status - :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png - :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html - :alt: License: AGPL-3 -.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github - :target: https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server_queue - :alt: cetmix/cetmix-tower - -|badge1| |badge2| |badge3| - -This module implements asynchronous task execution for `Cetmix -Tower `__. - -It requires the `queue_job `__ -module to be installed and configured in the Odoo instance. - -Please refer to the `official -documentation `__ for detailed information. - -**Table of contents** - -.. contents:: - :local: - -Configuration -============= - -Please refer to the `official -documentation `__ for detailed configuration -instructions. - -Usage -===== - -Please refer to the `official -documentation `__ for detailed usage -instructions. - -Changelog -========= - -16.0.2.0.0 (2026-03-23) ------------------------ - -- Features: Jets! (4700) - -16.0.1.2.0 (2025-11-12) ------------------------ - -- Features: Use the 'web_notify' module to send user notifications. - (5074) - -16.0.1.1.4 (2025-11-05) ------------------------ - -- Bugfixes: Finish multiple commands at once. (5062) - -16.0.1.1.3 (2025-10-13) ------------------------ - -- Features: Terminate running flight plan manually (3410) - -16.0.1.1.0 (2025-07-16) ------------------------ - -- Features: cetmix_tower_server_queue: Add async file upload/download - via job queue (3720) -- Features: Terminate command with error if job has failed (4718) - -16.0.1.0.2 (2025-05-16) ------------------------ - -- Features: 'sudo' parameter is not passed to command. (4678) - -16.0.1.0.1 (2025-05-09) ------------------------ - -- Bugfixes: Non-critical issues and performance improvements. (4611) - -16.0.1.0.0 ----------- - -Release for Odoo 16.0 - -Bug Tracker -=========== - -Bugs are tracked on `GitHub Issues `_. -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 `_. - -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 `_ project on GitHub. - -You are welcome to contribute. diff --git a/addons/cetmix_tower_server_queue1/__init__.py b/addons/cetmix_tower_server_queue1/__init__.py deleted file mode 100644 index 0650744..0000000 --- a/addons/cetmix_tower_server_queue1/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import models diff --git a/addons/cetmix_tower_server_queue1/__manifest__.py b/addons/cetmix_tower_server_queue1/__manifest__.py deleted file mode 100644 index 2cceadc..0000000 --- a/addons/cetmix_tower_server_queue1/__manifest__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (C) 2022 Cetmix OÜ -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -{ - "name": "Cetmix Tower Server Queue", - "summary": "Cetmix Tower asynchronous task execution using 'queue_job'", - "version": "16.0.2.0.0", - "development_status": "Beta", - "category": "Productivity", - "website": "https://tower.cetmix.com", - "author": "Cetmix", - "license": "AGPL-3", - "installable": True, - "auto_install": True, - "depends": ["cetmix_tower_server", "queue_job"], - "data": [ - "views/cx_tower_command_log_view.xml", - "views/cx_tower_file_view.xml", - ], -} diff --git a/addons/cetmix_tower_server_queue1/i18n/cetmix_tower_server_queue.pot b/addons/cetmix_tower_server_queue1/i18n/cetmix_tower_server_queue.pot deleted file mode 100644 index bda7834..0000000 --- a/addons/cetmix_tower_server_queue1/i18n/cetmix_tower_server_queue.pot +++ /dev/null @@ -1,150 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * cetmix_tower_server_queue -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"Last-Translator: \n" -"Language-Team: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Plural-Forms: \n" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_command_log__command_status -msgid "" -"0 if command finished successfully.\n" -"-100 general error,\n" -"-101 not found,\n" -"-201 another instance of this command is running,\n" -"-202 no runner found for the command action,\n" -"-203 Python code execution failed\n" -"-205 plan line condition check failed\n" -"503 if SSH connection error occurred\n" -"601 if queue job failed" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_command_log -msgid "Cetmix Tower Command Log" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_file -msgid "Cetmix Tower File" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_server -msgid "Cetmix Tower Server" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -msgid "Error" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__command_status -msgid "Exit Code" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Failure" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File downloaded!" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed -msgid "File is currently being processed" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File uploaded!" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File(s) %(name)s download failed: %(error)s" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File(s) %(name)s upload failed: %(error)s" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Files downloaded!" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Files uploaded!" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed -msgid "Is Being Processed" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -msgid "Processing" -msgstr "" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_queue_job -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__queue_job_id -msgid "Queue Job" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -#, python-format -msgid "Success" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "The following files are already being processed: %(name)s" -msgstr "" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "" -"Unable to upload file '%(f)s'.\n" -"Upload operation is not supported for 'server' type files." -msgstr "" diff --git a/addons/cetmix_tower_server_queue1/i18n/it.po b/addons/cetmix_tower_server_queue1/i18n/it.po deleted file mode 100644 index 72ce79a..0000000 --- a/addons/cetmix_tower_server_queue1/i18n/it.po +++ /dev/null @@ -1,148 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * cetmix_tower_server_queue -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: \n" -"PO-Revision-Date: \n" -"Last-Translator: \n" -"Language-Team: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Language: it\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 2.3\n" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_command_log__command_status -msgid "" -"0 if command finished successfully.\n" -"-100 general error,\n" -"-101 not found,\n" -"-201 another instance of this command is running,\n" -"-202 no runner found for the command action,\n" -"-203 Python code execution failed\n" -"-205 plan line condition check failed\n" -"503 if SSH connection error occurred\n" -"601 if queue job failed" -msgstr "0 se il comando è stato completato correttamente.-100 errore generale,-101 non trovato,-201 un'altra istanza di questo comando è in esecuzione,-202 nessun runner trovato per l'azione del comando,-203 esecuzione del codice Python non riuscita,-205 controllo delle condizioni della riga del piano non riuscito,503 se si è verificato un errore di connessione SSH,601 se il processo in coda non è riuscito." - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_command_log -msgid "Cetmix Tower Command Log" -msgstr "Registro comando Cetmix Tower" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_file -msgid "Cetmix Tower File" -msgstr "File Cetmix Tower" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_server -msgid "Cetmix Tower Server" -msgstr "Server Cetmix Tower" - -#. module: cetmix_tower_server_queue -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -msgid "Error" -msgstr "Errore" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__command_status -msgid "Exit Code" -msgstr "Codice uscita" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Failure" -msgstr "Fallimento" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File downloaded!" -msgstr "File scaricato!" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed -msgid "File is currently being processed" -msgstr "Il file è in lavorazione" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "File uploaded!" -msgstr "File caricato!" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Files downloaded!" -msgstr "File scaricati!" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "Files uploaded!" -msgstr "File caricati!" - -#. module: cetmix_tower_server_queue -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed -msgid "Is Being Processed" -msgstr "In lavorazione" - -#. module: cetmix_tower_server_queue -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -msgid "Processing" -msgstr "Lavorazione" - -#. module: cetmix_tower_server_queue -#: model:ir.model,name:cetmix_tower_server_queue.model_queue_job -#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__queue_job_id -msgid "Queue Job" -msgstr "Accoda lavoro" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form -#, python-format -msgid "Success" -msgstr "Successo" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "The following files are already being processed: %(name)s" -msgstr "I seguenti file sono già in fase di elaborazione: %(name)s" - -#. module: cetmix_tower_server_queue -#. odoo-python -#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 -#, python-format -msgid "" -"Unable to upload file '%(f)s'.\n" -"Upload operation is not supported for 'server' type files." -msgstr "" -"Impossibile caricare il file '%(f)s'.\n" -"L'operazione di caricamento non è supportata per i file di tipo 'server'." - -#~ msgid "Display Name" -#~ msgstr "Nome visualizzato" - -#~ msgid "ID" -#~ msgstr "ID" - -#~ msgid "Last Modified on" -#~ msgstr "Ultima modifica il" diff --git a/addons/cetmix_tower_server_queue1/models/__init__.py b/addons/cetmix_tower_server_queue1/models/__init__.py deleted file mode 100644 index 4e40e4b..0000000 --- a/addons/cetmix_tower_server_queue1/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from . import cx_tower_command_log -from . import cx_tower_server -from . import queue_job -from . import cx_tower_file diff --git a/addons/cetmix_tower_server_queue1/models/cx_tower_command_log.py b/addons/cetmix_tower_server_queue1/models/cx_tower_command_log.py deleted file mode 100644 index 9e6c450..0000000 --- a/addons/cetmix_tower_server_queue1/models/cx_tower_command_log.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -import logging - -from odoo import fields, models, tools - -from odoo.addons.cetmix_tower_server.models.constants import ( - COMMAND_STOPPED, - COMMAND_TIMED_OUT, -) -from odoo.addons.queue_job.job import CANCELLED - -_logger = logging.getLogger(__name__) - - -class CxTowerCommandLog(models.Model): - _inherit = "cx.tower.command.log" - - queue_job_id = fields.Many2one( - "queue.job", - readonly=True, - groups="queue_job.group_queue_job_manager", - ) - - command_status = fields.Integer( - help="0 if command finished successfully.\n" - "-100 general error,\n" - "-101 not found,\n" - "-201 another instance of this command is running,\n" - "-202 no runner found for the command action,\n" - "-203 Python code execution failed\n" - "-205 plan line condition check failed\n" - "503 if SSH connection error occurred\n" - "601 if queue job failed" - ) - - def finish( - self, finish_date=None, status=None, response=None, error=None, **kwargs - ): - """Finish the command log - - Args: - finish_date (Datetime, optional): Command finish date. Defaults to None. - status (Integer, optional): Command status. Defaults to None. - response (Text, optional): Command response. Defaults to None. - error (Text, optional): Command error. Defaults to None. - """ - - # Filter out command logs that are already stopped - command_logs_to_process = self.filtered( - lambda log: log.command_status != COMMAND_STOPPED - ) - if not command_logs_to_process: - return - - # Avoid finishing the command log multiple times at the same time - try: - with self.env.cr.savepoint(), tools.mute_logger("odoo.sql_db"): - self.env.cr.execute( - f"SELECT command_status FROM {self._table} WHERE id IN %s FOR UPDATE NOWAIT", # noqa: E501 - (tuple(command_logs_to_process.ids),), - ) - except Exception as e: - _logger.error( - "Could not acquire lock on command logs %s, skipping finish: %s", - command_logs_to_process.ids, - e, - ) - return - - # Update the related queue job state if the command timed out - if status == COMMAND_TIMED_OUT: - for command_log in command_logs_to_process: - if command_log.queue_job_id: - command_log.queue_job_id.sudo()._change_job_state( - CANCELLED, result=error - ) - - return super(CxTowerCommandLog, command_logs_to_process).finish( - finish_date, status, response, error, **kwargs - ) diff --git a/addons/cetmix_tower_server_queue1/models/cx_tower_file.py b/addons/cetmix_tower_server_queue1/models/cx_tower_file.py deleted file mode 100644 index 327ffa1..0000000 --- a/addons/cetmix_tower_server_queue1/models/cx_tower_file.py +++ /dev/null @@ -1,184 +0,0 @@ -# Copyright (C) 2025 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -import logging - -from odoo import _, fields, models -from odoo.exceptions import UserError - -_logger = logging.getLogger(__name__) - - -class CxTowerFile(models.Model): - _inherit = "cx.tower.file" - - is_being_processed = fields.Boolean( - copy=False, - help="File is currently being processed", - ) - - def _check_files_being_processed(self, raise_error): - """ - Check if any file in the recordset is being processed. - True if at least one file is already processing and raise_error is False. - False if no files are currently being processed. - The caller uses the boolean to decide whether to continue or abort. - """ - processing_files = self.filtered(lambda rec: rec.is_being_processed) - if processing_files: - if raise_error: - raise UserError( - _( - "The following files are already being processed: %(name)s", - name=", ".join(processing_files.mapped("name")), - ) - ) - else: - return True - return False - - def upload(self, raise_error=False): - """ - Trigger asynchronous upload via job queue. - """ - # Check if the file is already being processed - if self._check_files_being_processed(raise_error): - return - - self.write({"server_response": False, "is_being_processed": True}) - - # Enqueue the upload if not already in a queue job; - # otherwise, execute immediately - if not self.env.context.get("job_uuid"): - self.with_delay()._do_upload(raise_error=raise_error) - else: - self._do_upload(raise_error=raise_error) - - def download(self, raise_error=False): - """ - Trigger asynchronous download via job queue. - """ - - # Check if the file is already being processed - if self._check_files_being_processed(raise_error): - return - - self.write({"server_response": False, "is_being_processed": True}) - - # Enqueue the download if not already in a queue job; - # otherwise, execute immediately - if not self.env.context.get("job_uuid"): - self.with_delay()._do_download(raise_error=raise_error) - else: - self._do_download(raise_error=raise_error) - - def _do_upload(self, raise_error=True): - """ - Uploads the files within a job context and notifies the user on success. - Logs the error if an exception occurs; - failure state is managed by the parent method. - """ - try: - with self.env.cr.savepoint(): - result = super().upload(raise_error=raise_error) - single_msg = _("File uploaded!") - plural_msg = _("Files uploaded!") - self.env.user.notify_success( - message=single_msg if len(self) == 1 else plural_msg, - title=_("Success"), - # This notification should not be sticky - # to avoid blocking the user's screen - sticky=False, - ) - return result - except Exception as e: - if not raise_error: - self.env.user.notify_danger( - message=_( - "File(s) %(name)s upload failed: %(error)s", - name=", ".join(self.mapped("name")), - error=str(e), - ), - title=_("Failure"), - sticky=self.env["ir.config_parameter"] - .sudo() - .get_param("cetmix_tower_server.notification_type_error", "sticky") - == "sticky", - ) - _logger.error("File %s upload failed: %s", str(self), str(e)) - else: - raise - finally: - self.write({"is_being_processed": False}) - - def _do_download(self, raise_error=True): - """ - Downloads the files within a job context and notifies the user on success. - Logs the error if an exception occurs; - failure state is managed by the parent method. - """ - try: - with self.env.cr.savepoint(): - result = super().download(raise_error=raise_error) - single_msg = _("File downloaded!") - plural_msg = _("Files downloaded!") - self.env.user.notify_success( - message=single_msg if len(self) == 1 else plural_msg, - title=_("Success"), - # This notification should not be sticky - # to avoid blocking the user's screen - sticky=False, - ) - return result - except Exception as e: - if not raise_error: - self.env.user.notify_danger( - message=_( - "File(s) %(name)s download failed: %(error)s", - name=", ".join(self.mapped("name")), - error=str(e), - ), - title=_("Failure"), - sticky=self.env["ir.config_parameter"] - .sudo() - .get_param("cetmix_tower_server.notification_type_error", "sticky") - == "sticky", - ) - _logger.error("File %s download failed: %s", str(self), str(e)) - else: - raise - finally: - self.write({"is_being_processed": False}) - - def action_pull_from_server(self): - """ - Pull file from server without notification. - """ - tower_files = self.filtered(lambda file_: file_.source == "tower") - server_files = self - tower_files - - tower_files.action_get_current_server_code() - - server_files.download(raise_error=False) - - def action_push_to_server(self): - """ - Push the file to server without success notification. - """ - server_files = self.filtered(lambda file_: file_.source == "server") - if server_files: - return { - "type": "ir.actions.client", - "tag": "display_notification", - "params": { - "title": _("Failure"), - "message": _( - "Unable to upload file '%(f)s'.\n" - "Upload operation is not supported for 'server' type files.", - f=", ".join(server_files.mapped("rendered_name")), - ), - "sticky": False, - }, - } - - self.upload(raise_error=False) diff --git a/addons/cetmix_tower_server_queue1/models/cx_tower_server.py b/addons/cetmix_tower_server_queue1/models/cx_tower_server.py deleted file mode 100644 index ff4cbb1..0000000 --- a/addons/cetmix_tower_server_queue1/models/cx_tower_server.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright (C) 2022 Cetmix OÜ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import models - - -class CxTowerServer(models.Model): - _inherit = "cx.tower.server" - - def _command_runner_wrapper( - self, - command, - log_record, - rendered_command_code, - sudo=None, - rendered_command_path=None, - ssh_connection=None, - **kwargs, - ): - # If the flight plan log has an entry on the parent flight plan log, - # it means that this flight plan was launched from another plan, - # this plan should be launched as a synchronous command to - # preserve the order of execution of commands with actions - # "Run Flight Plan", "Trigger Jet Action" and "Create Waypoint". - # Use runner only if command log record is provided. - if ( - log_record - and not log_record.plan_log_id.parent_flight_plan_log_id - and command.action - not in [ - "jet_action", - "create_waypoint", - ] - ): - job = self.with_delay()._queue_command_runner_wrapper( - command=command, - log_record=log_record, - rendered_command_code=rendered_command_code, - sudo=sudo, - rendered_command_path=rendered_command_path, - ssh_connection=ssh_connection, - **kwargs, - ) - log_record.sudo().queue_job_id = job.db_record().id - - # Otherwise fallback to `super` to return the command output - else: - return super()._command_runner_wrapper( - command=command, - log_record=log_record, - rendered_command_code=rendered_command_code, - sudo=sudo, - rendered_command_path=rendered_command_path, - ssh_connection=ssh_connection, - **kwargs, - ) - - def _queue_command_runner_wrapper( - self, - command, - log_record, - rendered_command_code, - sudo=None, - rendered_command_path=None, - ssh_connection=None, - **kwargs, - ): - # avoid executing command if plan was stopped - log_record.invalidate_recordset(["plan_log_id"]) - plan_log_id = log_record.plan_log_id - if plan_log_id: - plan_log_id.invalidate_recordset(["is_stopped"]) - - # If plan was stopped, stop the command - if plan_log_id.is_stopped: - log_record.stop() - return - - return self._command_runner( - command=command, - log_record=log_record, - rendered_command_code=rendered_command_code, - sudo=sudo, - rendered_command_path=rendered_command_path, - ssh_connection=ssh_connection, - **kwargs, - ) diff --git a/addons/cetmix_tower_server_queue1/models/queue_job.py b/addons/cetmix_tower_server_queue1/models/queue_job.py deleted file mode 100644 index 7b66eea..0000000 --- a/addons/cetmix_tower_server_queue1/models/queue_job.py +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright 2013-2020 Camptocamp SA -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) -from odoo import models - - -class QueueJob(models.Model): - _inherit = "queue.job" - - QUEUE_JOB_ERROR = 601 - - def write(self, vals): - """ - Override write method to update command status - and write error information in the log record - """ - if vals.get("state") == "failed": - log_record = self.kwargs.get("log_record") - if log_record: - log_record.finish( - status=self.QUEUE_JOB_ERROR, - error=vals.get("exc_info"), - ) - return super().write(vals) diff --git a/addons/cetmix_tower_server_queue1/pyproject.toml b/addons/cetmix_tower_server_queue1/pyproject.toml deleted file mode 100644 index 4231d0c..0000000 --- a/addons/cetmix_tower_server_queue1/pyproject.toml +++ /dev/null @@ -1,3 +0,0 @@ -[build-system] -requires = ["whool"] -build-backend = "whool.buildapi" diff --git a/addons/cetmix_tower_server_queue1/readme/CONFIGURE.md b/addons/cetmix_tower_server_queue1/readme/CONFIGURE.md deleted file mode 100644 index 8c717e5..0000000 --- a/addons/cetmix_tower_server_queue1/readme/CONFIGURE.md +++ /dev/null @@ -1 +0,0 @@ -Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions. diff --git a/addons/cetmix_tower_server_queue1/readme/DESCRIPTION.md b/addons/cetmix_tower_server_queue1/readme/DESCRIPTION.md deleted file mode 100644 index 54e6fc3..0000000 --- a/addons/cetmix_tower_server_queue1/readme/DESCRIPTION.md +++ /dev/null @@ -1,5 +0,0 @@ -This module implements asynchronous task execution for [Cetmix Tower](https://cetmix.com/tower). - -It requires the [queue_job](https://github.com/OCA/queue/queue_job) module to be installed and configured in the Odoo instance. - -Please refer to the [official documentation](https://cetmix.com/tower) for detailed information. diff --git a/addons/cetmix_tower_server_queue1/readme/HISTORY.md b/addons/cetmix_tower_server_queue1/readme/HISTORY.md deleted file mode 100644 index 1d802a2..0000000 --- a/addons/cetmix_tower_server_queue1/readme/HISTORY.md +++ /dev/null @@ -1,39 +0,0 @@ -## 16.0.2.0.0 (2026-03-23) - -- Features: Jets! (4700) - - -## 16.0.1.2.0 (2025-11-12) - -- Features: Use the 'web_notify' module to send user notifications. (5074) - - -## 16.0.1.1.4 (2025-11-05) - -- Bugfixes: Finish multiple commands at once. (5062) - - -## 16.0.1.1.3 (2025-10-13) - -- Features: Terminate running flight plan manually (3410) - - -## 16.0.1.1.0 (2025-07-16) - -- Features: cetmix_tower_server_queue: Add async file upload/download via job queue (3720) -- Features: Terminate command with error if job has failed (4718) - - -## 16.0.1.0.2 (2025-05-16) - -- Features: 'sudo' parameter is not passed to command. (4678) - - -## 16.0.1.0.1 (2025-05-09) - -- Bugfixes: Non-critical issues and performance improvements. (4611) - - -## 16.0.1.0.0 - -Release for Odoo 16.0 diff --git a/addons/cetmix_tower_server_queue1/readme/USAGE.md b/addons/cetmix_tower_server_queue1/readme/USAGE.md deleted file mode 100644 index 901f5a6..0000000 --- a/addons/cetmix_tower_server_queue1/readme/USAGE.md +++ /dev/null @@ -1 +0,0 @@ -Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions. diff --git a/addons/cetmix_tower_server_queue1/readme/newsfragments/.gitkeep b/addons/cetmix_tower_server_queue1/readme/newsfragments/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/addons/cetmix_tower_server_queue1/static/description/icon.png b/addons/cetmix_tower_server_queue1/static/description/icon.png deleted file mode 100644 index 2507f55..0000000 Binary files a/addons/cetmix_tower_server_queue1/static/description/icon.png and /dev/null differ diff --git a/addons/cetmix_tower_server_queue1/static/description/index.html b/addons/cetmix_tower_server_queue1/static/description/index.html deleted file mode 100644 index 3f70631..0000000 --- a/addons/cetmix_tower_server_queue1/static/description/index.html +++ /dev/null @@ -1,491 +0,0 @@ - - - - - -Cetmix Tower Server Queue - - - -
-

Cetmix Tower Server Queue

- - -

Beta License: AGPL-3 cetmix/cetmix-tower

-

This module implements asynchronous task execution for Cetmix -Tower.

-

It requires the queue_job -module to be installed and configured in the Odoo instance.

-

Please refer to the official -documentation for detailed information.

-

Table of contents

- -
-

Configuration

-

Please refer to the official -documentation for detailed configuration -instructions.

-
-
-

Usage

-

Please refer to the official -documentation for detailed usage -instructions.

-
-
-

Changelog

-
-

16.0.2.0.0 (2026-03-23)

-
    -
  • Features: Jets! (4700)
  • -
-
-
-

16.0.1.2.0 (2025-11-12)

-
    -
  • Features: Use the ‘web_notify’ module to send user notifications. -(5074)
  • -
-
-
-

16.0.1.1.4 (2025-11-05)

-
    -
  • Bugfixes: Finish multiple commands at once. (5062)
  • -
-
-
-

16.0.1.1.3 (2025-10-13)

-
    -
  • Features: Terminate running flight plan manually (3410)
  • -
-
-
-

16.0.1.1.0 (2025-07-16)

-
    -
  • Features: cetmix_tower_server_queue: Add async file upload/download -via job queue (3720)
  • -
  • Features: Terminate command with error if job has failed (4718)
  • -
-
-
-

16.0.1.0.2 (2025-05-16)

-
    -
  • Features: ‘sudo’ parameter is not passed to command. (4678)
  • -
-
-
-

16.0.1.0.1 (2025-05-09)

-
    -
  • Bugfixes: Non-critical issues and performance improvements. (4611)
  • -
-
-
-

16.0.1.0.0

-

Release for Odoo 16.0

-
-
-
-

Bug Tracker

-

Bugs are tracked on GitHub Issues. -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.

-

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 project on GitHub.

-

You are welcome to contribute.

-
-
-
- - diff --git a/addons/cetmix_tower_server_queue1/tests/__init__.py b/addons/cetmix_tower_server_queue1/tests/__init__.py deleted file mode 100644 index 306c04b..0000000 --- a/addons/cetmix_tower_server_queue1/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import test_command -from . import test_command_log -from . import test_file diff --git a/addons/cetmix_tower_server_queue1/tests/test_command.py b/addons/cetmix_tower_server_queue1/tests/test_command.py deleted file mode 100644 index 2f043d9..0000000 --- a/addons/cetmix_tower_server_queue1/tests/test_command.py +++ /dev/null @@ -1,145 +0,0 @@ -from datetime import timedelta -from unittest.mock import patch - -from odoo.fields import Datetime -from odoo.tools import mute_logger - -from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon - - -class TestTowerCommand(TestTowerCommon): - """Test suite for verifying zombie command detection and related - queue job cancellation. - - Tests in this class verify that commands which have been running - longer than the timeout are properly detected as zombies, and their - associated queue jobs are cancelled. - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - # Set command timeout to 10 seconds - cls.env["ir.config_parameter"].sudo().set_param( - "cetmix_tower_server.command_timeout", "10" - ) - # Set old time to 20 seconds ago (older than timeout) - # to simulate running command in past - now = Datetime.now() - cls.old_time = now - timedelta(seconds=20) - - def _patch_command_runner(self, command_type, runner_method): - """Helper to patch a command runner to simulate a zombie command. - - Args: - command_type: Type of command runner to patch ('ssh' or 'python_code') - runner_method: Original method to wrap - - Returns: - A context manager that applies the patch - """ - - def _wrapper(*args, **kwargs): - # Modify args to disable log record finishing - args = list(args) - if len(args) > 1: - args[1] = False # Set log_record to False - return runner_method(*args, **kwargs) - - return patch.object( - self.registry["cx.tower.server"], - f"_command_runner_{command_type}", - _wrapper, - ) - - def _verify_zombie_command_job_cancellation(self, command_action): - """Verify zombie command is detected and job is cancelled. - - Args: - command_action: Action type ('ssh_command' or 'python_code') - """ - # check zombie command logs - domain = [ - ("is_running", "=", True), - ("start_date", "=", self.old_time), - ("command_action", "=", command_action), - ] - zombie_command_logs = self.env["cx.tower.command.log"].search(domain) - - self.assertEqual( - len(zombie_command_logs), 1, "Zombie command log should be created" - ) - self.assertTrue( - zombie_command_logs.queue_job_id, - "Zombie command log should have queue job", - ) - - job = zombie_command_logs.queue_job_id - self.assertTrue(job.exists(), "Zombie command job should exist") - - self.assertEqual(job.state, "pending", "Zombie command job should be pending") - - # run process to kill zombie command - self.server_test_1._check_zombie_commands() - - # check that command log is cancelled - self.assertEqual( - job.state, "cancelled", "Zombie command job should be cancelled" - ) - - def test_check_zombie_ssh_command_queue(self): - """ - Test that zombie ssh command is killed and job is cancelled - """ - # Create test commands - ssh_command = self.Command.create( - { - "name": "Test SSH Command", - "code": "ls -la", - "action": "ssh_command", - } - ) - - # patch command runner to not finish log record - cx_tower_server_obj = self.registry["cx.tower.server"] - _command_runner_ssh_super = cx_tower_server_obj._command_runner_ssh - - with self._patch_command_runner("ssh", _command_runner_ssh_super): - # run zombie command with log creation in past - self.server_test_1.run_command( - ssh_command, log={"start_date": self.old_time} - ) - - # check zombie command logs - self._verify_zombie_command_job_cancellation("ssh_command") - - @mute_logger("py.warnings") - def test_check_zombie_python_command_queue(self): - """ - Test that zombie python command is killed and job is cancelled - """ - # Create test commands - python_command = self.Command.create( - { - "name": "Test Python Command", - "code": "print('test')", - "action": "python_code", - } - ) - - # patch command runner to not finish log record - cx_tower_server_obj = self.registry["cx.tower.server"] - _command_runner_python_code_super = ( - cx_tower_server_obj._command_runner_python_code - ) - - with self._patch_command_runner( - "python_code", _command_runner_python_code_super - ): - # run zombie command with log creation in past - self.server_test_1.run_command( - python_command, log={"start_date": self.old_time} - ) - - # check zombie command logs - self._verify_zombie_command_job_cancellation("python_code") diff --git a/addons/cetmix_tower_server_queue1/tests/test_command_log.py b/addons/cetmix_tower_server_queue1/tests/test_command_log.py deleted file mode 100644 index 5bef9eb..0000000 --- a/addons/cetmix_tower_server_queue1/tests/test_command_log.py +++ /dev/null @@ -1,37 +0,0 @@ -from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon -from odoo.addons.queue_job.job import Job - - -class TestTowerCommand(TestTowerCommon): - """ - Test cases for command log state on queue_job failure - """ - - def test_command_log_state_on_job_fail(self): - command = self.env["cx.tower.command"].create( - { - "name": "Test Command", - "action": "ssh_command", - "code": "echo 'Hello World'", - } - ) - self.assertTrue(command.id, "Command should be created successfully") - - self.server_test_1.run_command(command=command) - command_log = self.env["cx.tower.command.log"].search( - [("command_id", "=", command.id)], order="id desc", limit=1 - ) - self.assertTrue(command_log, "Command log should be created") - - job = command_log.queue_job_id - self.assertTrue(job, "Queue job should be associated with command log") - - job_obj = Job.load(self.env, job.uuid) - job_obj.set_failed() - job_obj.store() - self.assertEqual(job.state, "failed", "Job should be in failed state") - self.assertEqual( - command_log.command_status, - self.env["queue.job"].QUEUE_JOB_ERROR, - "Command log should be in failed state", - ) diff --git a/addons/cetmix_tower_server_queue1/tests/test_file.py b/addons/cetmix_tower_server_queue1/tests/test_file.py deleted file mode 100644 index c04a229..0000000 --- a/addons/cetmix_tower_server_queue1/tests/test_file.py +++ /dev/null @@ -1,201 +0,0 @@ -from odoo import exceptions - -from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon -from odoo.addons.queue_job.tests.common import trap_jobs - - -class TestCxTowerFileQueue(TestTowerCommon): - def setUp(self): - super().setUp() - self.file_template = self.FileTemplate.create( - { - "name": "Test", - "file_name": "test.txt", - "server_dir": "/var/tmp", - "code": "Hello, world!", - } - ) - - def test_async_upload_operations(self): - """Test that upload operations are processed asynchronously""" - # Create unique files specifically for this test - upload_file = self.File.create( - { - "source": "tower", - "template_id": self.file_template.id, - "server_id": self.server_test_1.id, - "name": "upload_test_1", - "auto_sync": False, - } - ) - - upload_file_2 = self.File.create( - { - "name": "upload_test_2", - "source": "server", - "server_id": self.server_test_1.id, - "server_dir": "/var/tmp", - "auto_sync": False, - } - ) - - with trap_jobs() as trap: - upload_file.upload() - upload_file_2.upload() - - self.assertEqual(len(trap.enqueued_jobs), 2) - - upload_file.write({"server_response": "ok", "is_being_processed": False}) - upload_file_2.write({"server_response": "ok", "is_being_processed": False}) - - # Refresh records to get updated values - upload_file.invalidate_recordset() - upload_file_2.invalidate_recordset() - - # Verify the expected state - self.assertEqual(upload_file.server_response, "ok") - self.assertFalse(upload_file.is_being_processed) - - self.assertEqual(upload_file_2.server_response, "ok") - self.assertFalse(upload_file_2.is_being_processed) - - def test_async_download_operations(self): - """Test that download operations are processed asynchronously""" - # Create unique files specifically for this test - download_file = self.File.create( - { - "source": "tower", - "template_id": self.file_template.id, - "server_id": self.server_test_1.id, - "name": "download_test_1", - "auto_sync": False, - } - ) - - download_file_2 = self.File.create( - { - "name": "download_test_2", - "source": "server", - "server_id": self.server_test_1.id, - "server_dir": "/var/tmp", - "auto_sync": False, - } - ) - - with trap_jobs() as trap: - download_file.download() - download_file_2.download() - - # Verify jobs were created - self.assertEqual(len(trap.enqueued_jobs), 2) - - download_file.write({"server_response": "ok", "is_being_processed": False}) - download_file_2.write( - {"server_response": "ok", "is_being_processed": False} - ) - - # Refresh records to get updated values - download_file.invalidate_recordset() - download_file_2.invalidate_recordset() - - # Verify the expected state - self.assertEqual(download_file.server_response, "ok") - self.assertFalse(download_file.is_being_processed) - - self.assertEqual(download_file_2.server_response, "ok") - self.assertFalse(download_file_2.is_being_processed) - - def test_upload_error_handling(self): - """Test error handling in async upload operations""" - error_file = self.File.create( - { - "source": "tower", - "template_id": self.file_template.id, - "server_id": self.server_test_1.id, - "name": "error_handling_test", - "auto_sync": False, - } - ) - - # Set context to force the mock in ssh_upload_file to raise error - error_context = {"raise_upload_error": "Forced upload error"} - - with trap_jobs() as trap: - # This will trigger job creation but the job would fail if executed - error_file.with_context(**error_context).upload(raise_error=True) - - # Verify job was created - self.assertEqual(len(trap.enqueued_jobs), 1) - - # Simulate what would happen if the job executed and failed - error_file.write({"server_response": "error", "is_being_processed": False}) - error_file.invalidate_recordset() - - self.assertEqual(error_file.server_response, "error") - self.assertFalse(error_file.is_being_processed) - - def test_download_error_handling(self): - """Test error handling in async download operations""" - error_file = self.File.create( - { - "source": "server", - "server_id": self.server_test_1.id, - "server_dir": "/var/tmp", - "name": "download_error_test", - } - ) - - # Set context to force the mock in ssh_download_file to raise error - error_context = {"raise_download_error": "Forced download error"} - - with trap_jobs() as trap: - # This will trigger job creation but the job would fail if executed - error_file.with_context(**error_context).download(raise_error=True) - - # Verify job was created - self.assertEqual(len(trap.enqueued_jobs), 1) - - # Simulate what would happen if the job executed and failed - error_file.write({"server_response": "error", "is_being_processed": False}) - error_file.invalidate_recordset() - - self.assertEqual(error_file.server_response, "error") - self.assertFalse(error_file.is_being_processed) - - def test_already_processing_check(self): - """Test that files being processed cannot be processed again""" - processing_file = self.File.create( - { - "source": "tower", - "template_id": self.file_template.id, - "server_id": self.server_test_1.id, - "name": "processing_test_file", - "is_being_processed": True, - } - ) - - self.assertTrue(processing_file.is_being_processed) - - # Test with raising error - with self.assertRaises(exceptions.UserError): - processing_file.upload(raise_error=True) - - # Test without raising error - should not create job - with trap_jobs() as trap: - processing_file.upload(raise_error=False) - # No job should be created since file is already being processed - self.assertEqual(len(trap.enqueued_jobs), 0) - - # Verify still marked as processing - self.assertTrue(processing_file.is_being_processed) - - # Same tests for download - with self.assertRaises(exceptions.UserError): - processing_file.download(raise_error=True) - - with trap_jobs() as trap: - processing_file.download(raise_error=False) - # No job should be created - self.assertEqual(len(trap.enqueued_jobs), 0) - - self.assertTrue(processing_file.is_being_processed) diff --git a/addons/cetmix_tower_server_queue1/views/cx_tower_command_log_view.xml b/addons/cetmix_tower_server_queue1/views/cx_tower_command_log_view.xml deleted file mode 100644 index 5488bb3..0000000 --- a/addons/cetmix_tower_server_queue1/views/cx_tower_command_log_view.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - cx.tower.command.log.view.form - cx.tower.command.log - - - - - - - - - diff --git a/addons/cetmix_tower_server_queue1/views/cx_tower_file_view.xml b/addons/cetmix_tower_server_queue1/views/cx_tower_file_view.xml deleted file mode 100644 index 55d7cd1..0000000 --- a/addons/cetmix_tower_server_queue1/views/cx_tower_file_view.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - cx.tower.file.view.form - cx.tower.file - - - - - - - - - - - - - - cx.tower.queue.file.view.tree - cx.tower.file - - - - - - - - - is_being_processed == True - - - is_being_processed != True and server_response == 'ok' - - - is_being_processed != True and server_response not in ('ok', False) - - - - -