From 207a122e37f29c812c63b442a2d1cbd7b76a6c63 Mon Sep 17 00:00:00 2001 From: git_admin Date: Sun, 3 May 2026 18:54:24 +0000 Subject: [PATCH] Tower: upload cetmix_tower_git 18.0.1.0.2 (was 18.0.1.0.2, via marketplace) --- addons/cetmix_tower_git/README.rst | 91 ++ addons/cetmix_tower_git/__init__.py | 1 + addons/cetmix_tower_git/__manifest__.py | 40 + addons/cetmix_tower_git/demo/demo_data.xml | 164 +++ .../i18n/cetmix_tower_git.pot | 1041 +++++++++++++++++ addons/cetmix_tower_git/i18n/fi.po | 595 ++++++++++ addons/cetmix_tower_git/i18n/hr.po | 635 ++++++++++ addons/cetmix_tower_git/i18n/it.po | 740 ++++++++++++ addons/cetmix_tower_git/models/__init__.py | 15 + .../cetmix_tower_git/models/cetmix_tower.py | 35 + .../models/cx_tower_command.py | 37 + .../cetmix_tower_git/models/cx_tower_file.py | 54 + .../models/cx_tower_file_template.py | 32 + .../models/cx_tower_git_project.py | 368 ++++++ .../cx_tower_git_project_file_template_rel.py | 115 ++ .../models/cx_tower_git_project_rel.py | 179 +++ .../models/cx_tower_git_remote.py | 403 +++++++ .../models/cx_tower_git_repo.py | 416 +++++++ .../models/cx_tower_git_repo_owner.py | 107 ++ .../models/cx_tower_git_source.py | 195 +++ .../models/cx_tower_plan_line.py | 32 + .../models/cx_tower_server.py | 187 +++ addons/cetmix_tower_git/pyproject.toml | 3 + addons/cetmix_tower_git/readme/CONFIGURE.md | 1 + addons/cetmix_tower_git/readme/DESCRIPTION.md | 3 + addons/cetmix_tower_git/readme/HISTORY.md | 10 + addons/cetmix_tower_git/readme/USAGE.md | 1 + .../readme/newsfragments/.gitkeep | 0 ...git_project_file_template_rel_security.xml | 46 + .../cx_tower_git_project_rel_security.xml | 43 + .../cx_tower_git_project_security.xml | 64 + .../security/cx_tower_git_remote_security.xml | 70 ++ .../cx_tower_git_repo_owner_security.xml | 24 + .../security/cx_tower_git_repo_security.xml | 24 + .../security/cx_tower_git_source_security.xml | 72 ++ .../security/ir.model.access.csv | 15 + .../static/description/banner.png | Bin 0 -> 86695 bytes .../static/description/icon.png | Bin 0 -> 22128 bytes .../static/description/index.html | 450 +++++++ addons/cetmix_tower_git/tests/__init__.py | 7 + addons/cetmix_tower_git/tests/common.py | 136 +++ .../cetmix_tower_git/tests/test_file_rel.py | 390 ++++++ .../tests/test_file_template_rel.py | 308 +++++ addons/cetmix_tower_git/tests/test_project.py | 315 +++++ addons/cetmix_tower_git/tests/test_remote.py | 462 ++++++++ addons/cetmix_tower_git/tests/test_repo.py | 84 ++ addons/cetmix_tower_git/tests/test_server.py | 416 +++++++ addons/cetmix_tower_git/tests/test_source.py | 226 ++++ .../cetmix_tower_git/tools/git_aggregator.py | 0 .../views/cx_tower_file_template_views.xml | 16 + .../views/cx_tower_file_views.xml | 13 + .../views/cx_tower_git_project_views.xml | 291 +++++ .../views/cx_tower_git_remote_views.xml | 83 ++ .../views/cx_tower_git_repo_owner_views.xml | 64 + .../views/cx_tower_git_repo_views.xml | 162 +++ .../views/cx_tower_git_source_views.xml | 93 ++ .../views/cx_tower_plan_line_view.xml | 36 + .../views/cx_tower_server_view.xml | 43 + addons/cetmix_tower_git/views/menuitems.xml | 32 + 59 files changed, 9485 insertions(+) create mode 100644 addons/cetmix_tower_git/README.rst create mode 100644 addons/cetmix_tower_git/__init__.py create mode 100644 addons/cetmix_tower_git/__manifest__.py create mode 100644 addons/cetmix_tower_git/demo/demo_data.xml create mode 100644 addons/cetmix_tower_git/i18n/cetmix_tower_git.pot create mode 100644 addons/cetmix_tower_git/i18n/fi.po create mode 100644 addons/cetmix_tower_git/i18n/hr.po create mode 100644 addons/cetmix_tower_git/i18n/it.po create mode 100644 addons/cetmix_tower_git/models/__init__.py create mode 100644 addons/cetmix_tower_git/models/cetmix_tower.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_command.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_file.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_file_template.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_git_project.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_git_project_file_template_rel.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_git_project_rel.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_git_remote.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_git_repo.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_git_repo_owner.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_git_source.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_plan_line.py create mode 100644 addons/cetmix_tower_git/models/cx_tower_server.py create mode 100644 addons/cetmix_tower_git/pyproject.toml create mode 100644 addons/cetmix_tower_git/readme/CONFIGURE.md create mode 100644 addons/cetmix_tower_git/readme/DESCRIPTION.md create mode 100644 addons/cetmix_tower_git/readme/HISTORY.md create mode 100644 addons/cetmix_tower_git/readme/USAGE.md create mode 100644 addons/cetmix_tower_git/readme/newsfragments/.gitkeep create mode 100644 addons/cetmix_tower_git/security/cx_tower_git_project_file_template_rel_security.xml create mode 100644 addons/cetmix_tower_git/security/cx_tower_git_project_rel_security.xml create mode 100644 addons/cetmix_tower_git/security/cx_tower_git_project_security.xml create mode 100644 addons/cetmix_tower_git/security/cx_tower_git_remote_security.xml create mode 100644 addons/cetmix_tower_git/security/cx_tower_git_repo_owner_security.xml create mode 100644 addons/cetmix_tower_git/security/cx_tower_git_repo_security.xml create mode 100644 addons/cetmix_tower_git/security/cx_tower_git_source_security.xml create mode 100644 addons/cetmix_tower_git/security/ir.model.access.csv create mode 100644 addons/cetmix_tower_git/static/description/banner.png create mode 100644 addons/cetmix_tower_git/static/description/icon.png create mode 100644 addons/cetmix_tower_git/static/description/index.html create mode 100644 addons/cetmix_tower_git/tests/__init__.py create mode 100644 addons/cetmix_tower_git/tests/common.py create mode 100644 addons/cetmix_tower_git/tests/test_file_rel.py create mode 100644 addons/cetmix_tower_git/tests/test_file_template_rel.py create mode 100644 addons/cetmix_tower_git/tests/test_project.py create mode 100644 addons/cetmix_tower_git/tests/test_remote.py create mode 100644 addons/cetmix_tower_git/tests/test_repo.py create mode 100644 addons/cetmix_tower_git/tests/test_server.py create mode 100644 addons/cetmix_tower_git/tests/test_source.py create mode 100644 addons/cetmix_tower_git/tools/git_aggregator.py create mode 100644 addons/cetmix_tower_git/views/cx_tower_file_template_views.xml create mode 100644 addons/cetmix_tower_git/views/cx_tower_file_views.xml create mode 100644 addons/cetmix_tower_git/views/cx_tower_git_project_views.xml create mode 100644 addons/cetmix_tower_git/views/cx_tower_git_remote_views.xml create mode 100644 addons/cetmix_tower_git/views/cx_tower_git_repo_owner_views.xml create mode 100644 addons/cetmix_tower_git/views/cx_tower_git_repo_views.xml create mode 100644 addons/cetmix_tower_git/views/cx_tower_git_source_views.xml create mode 100644 addons/cetmix_tower_git/views/cx_tower_plan_line_view.xml create mode 100644 addons/cetmix_tower_git/views/cx_tower_server_view.xml create mode 100644 addons/cetmix_tower_git/views/menuitems.xml diff --git a/addons/cetmix_tower_git/README.rst b/addons/cetmix_tower_git/README.rst new file mode 100644 index 0000000..176d47b --- /dev/null +++ b/addons/cetmix_tower_git/README.rst @@ -0,0 +1,91 @@ +================ +Cetmix Tower Git +================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2a3dd557b4ae104188b8e2912c02b135bcfe7cc6518f8633daa149ccb94850fe + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/18.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 +========= + +18.0.1.0.2 (2026-03-10) +----------------------- + +- 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) + +- Bugfixes: Link server to git project only once. (5214) + +18.0.1.0.1 (2025-12-17) +----------------------- + +- Features: Improve search views, implement the search panel for + selected views. (5139) + +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 new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/cetmix_tower_git/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/cetmix_tower_git/__manifest__.py b/addons/cetmix_tower_git/__manifest__.py new file mode 100644 index 0000000..b7050ef --- /dev/null +++ b/addons/cetmix_tower_git/__manifest__.py @@ -0,0 +1,40 @@ +# Copyright Cetmix OU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Cetmix Tower Git", + "summary": "Cetmix Tower Git Management Tools", + "version": "18.0.1.0.2", + "development_status": "Beta", + "category": "Productivity", + "website": "https://tower.cetmix.com", + "author": "Cetmix", + "license": "AGPL-3", + "application": False, + "depends": ["cetmix_tower_yaml"], + "external_dependencies": { + "python": ["giturlparse==0.12.0"], + }, + "data": [ + "security/ir.model.access.csv", + "security/cx_tower_git_project_security.xml", + "security/cx_tower_git_source_security.xml", + "security/cx_tower_git_remote_security.xml", + "security/cx_tower_git_repo_security.xml", + "security/cx_tower_git_repo_owner_security.xml", + "security/cx_tower_git_project_rel_security.xml", + "security/cx_tower_git_project_file_template_rel_security.xml", + "views/cx_tower_git_project_views.xml", + "views/cx_tower_git_source_views.xml", + "views/cx_tower_git_remote_views.xml", + "views/cx_tower_git_repo_views.xml", + "views/cx_tower_git_repo_owner_views.xml", + "views/cx_tower_file_views.xml", + "views/cx_tower_file_template_views.xml", + "views/cx_tower_server_view.xml", + "views/cx_tower_plan_line_view.xml", + "views/menuitems.xml", + ], + "demo": [ + "demo/demo_data.xml", + ], +} diff --git a/addons/cetmix_tower_git/demo/demo_data.xml b/addons/cetmix_tower_git/demo/demo_data.xml new file mode 100644 index 0000000..de7a7be --- /dev/null +++ b/addons/cetmix_tower_git/demo/demo_data.xml @@ -0,0 +1,164 @@ + + + + + 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 new file mode 100644 index 0000000..d9e8d12 --- /dev/null +++ b/addons/cetmix_tower_git/i18n/cetmix_tower_git.pot @@ -0,0 +1,1041 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_git +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.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 +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 +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 +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 \n" +" all\n" +" 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 \n" +" all\n" +" 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 +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.cx_tower_git_project_action +msgid "Create your first git project!" +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 +#: 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 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_tree +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 +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_template_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 +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 +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_terms:ir.actions.act_window,help:cetmix_tower_git.cx_tower_git_project_action +msgid "" +"Git projects represent collections of git repositories with their metadata and configuration.\n" +" They are used in deployment processes to define the source code to be deployed." +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__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 +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 +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 +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 +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_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form +msgid "Save record as YAML" +msgstr "" + +#. module: cetmix_tower_git +#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_tree +msgid "Save records as YAML" +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: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_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 new file mode 100644 index 0000000..4d98520 --- /dev/null +++ b/addons/cetmix_tower_git/i18n/fi.po @@ -0,0 +1,595 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_git +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server \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 new file mode 100644 index 0000000..cbf3682 --- /dev/null +++ b/addons/cetmix_tower_git/i18n/hr.po @@ -0,0 +1,635 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_git +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server \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 new file mode 100644 index 0000000..d1b6a28 --- /dev/null +++ b/addons/cetmix_tower_git/i18n/it.po @@ -0,0 +1,740 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_git +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: 2025-11-17 10:33+0100\n" +"Last-Translator: Stefano Consolaro \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 "" +"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 \"Responsabile\" e sono impostati come \"Responsabili\" in tutti i server relativi.\n" +" Questo è fatto per evitare conseguenze imprevedibili quando qualcuno dei server non è aggiornato per limitazioni di accesso quando viene aggiornato 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 \"Responsabile\" e sono impostati come \"Utenti\" o \"Responsabili\" in tutti i server relativi." + +#. 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_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 "Sincronizzazione automatica" + +#. module: cetmix_tower_git +#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__bitbucket +msgid "Bitbucket" +msgstr "Bitbucket" + +#. 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_source__reference +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_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 Cetmix Tower al modello 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 Cetmix Tower a 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_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: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 "Crea una copia del progetto Git invece di collegare il file a uno esistente." + +#. 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 "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_source__create_date +msgid "Created on" +msgstr "Creato il" + +#. 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_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_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à associato 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 modello di file è già associato 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_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_project_view_form +msgid "General" +msgstr "Generale" + +#. 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 recupero delle PR. In alternativa usare il 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 dell'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_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_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_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.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 le origini verranno clonate. 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 le origini verranno clonate. Lasciare vuoto per usare '.'" + +#. 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 "URL del remote Git. Es. 'https://github.com/cetmix/cetmix-tower.git' o '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 "Head remoto Git. Collegamento al branch, alla PR, al commit o all'hash del commit. Lasciare vuoto per il rilevamento automatico" + +#. module: cetmix_tower_git +#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__github +msgid "GitHub" +msgstr "GitHub" + +#. module: cetmix_tower_git +#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__gitlab +msgid "GitLab" +msgstr "GitLab" + +#. 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 "Head" + +#. 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 di head" + +#. 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_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 abilitato, il file verrà sincronizzato automaticamente tramite cron" + +#. 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,field_description:cetmix_tower_git.field_cx_tower_git_remote__is_private +msgid "Is Private" +msgstr "È privato" + +#. 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 "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_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_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 "Nome" + +#. module: cetmix_tower_git +#. odoo-python +#: 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 "URL non valido. L'URL deve terminare con '.git'" + +#. module: cetmix_tower_git +#. odoo-python +#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 +#, python-format +msgid "Not a valid URL. URL must start with 'https://', 'git@', or 'git://'" +msgstr "URL non valido. L'URL deve iniziare con 'https://', 'git@' o 'git://'" + +#. 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_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_server_view_form +msgid "Open" +msgstr "Apri" + +#. 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_project_view_form +msgid "Open server" +msgstr "Apri server" + +#. module: cetmix_tower_git +#: model:ir.model.fields.selection,name:cetmix_tower_git.selection__cx_tower_git_remote__repo_provider__other +msgid "Other" +msgstr "Altro" + +#. module: cetmix_tower_git +#: model_terms:ir.ui.view,arch_db:cetmix_tower_git.cx_tower_git_source_view_form +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: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 "Inserire qui le note..." + +#. 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_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_source__remote_ids +msgid "Remote" +msgstr "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_source_view_form +msgid "Remotes" +msgstr "Remote" + +#. module: cetmix_tower_git +#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__repo_provider +msgid "Repository Provider" +msgstr "Fornitore repository" + +#. 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 +#: 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,help:cetmix_tower_git.field_cx_tower_plan_line__git_project_id +#, fuzzy +msgid "Select a git project to be linked to the file and server." +msgstr "Seleziona 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 sono aggiunti automaticamente in base ai file collegati al progetto.\n" +"IMPORTANTE: questo campo può contenere duplicati a causa della 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 "" +"Il remote in cima verrà utilizzato come destinazione del merge.\n" +" È possibile riorganizzare i remote trascinandoli o modificando il loro valore di sequenza." + +#. module: cetmix_tower_git +#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url +msgid "URL" +msgstr "URL" + +#. module: cetmix_tower_git +#: model:ir.model.fields,field_description:cetmix_tower_git.field_cx_tower_git_remote__url_protocol +msgid "URL Protocol" +msgstr "Protocollo URL" + +#. module: cetmix_tower_git +#. odoo-python +#: code:addons/cetmix_tower_git/models/cx_tower_git_remote.py:0 +#, python-format +msgid "URL is required" +msgstr "È richiesto l'URL" + +#. 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: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 "Si cercherà di determinarlo dall'URL. Selezionarlo manualmente se fallisce l'auto determinazione." + +#. 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_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 "È possibile modificare questi campi a proprio rischio. Tuttavia, tenere presente che verranno aggiornati automaticamente ogni volta che i server correlati vengono aggiunti, rimossi o aggiornati." + +#. 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_project_view_form +msgid "managers who can modify this record" +msgstr "responsabili che possono modificare questo record" + +#. 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 visualizzare questo record" + +#~ 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 "Last Modified on" +#~ msgstr "Ultima modifica il" + +#~ 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/models/__init__.py b/addons/cetmix_tower_git/models/__init__.py new file mode 100644 index 0000000..06e0f96 --- /dev/null +++ b/addons/cetmix_tower_git/models/__init__.py @@ -0,0 +1,15 @@ +# cx_tower_git_project_rel must be the first one in the list +# in order to create the relation table properly +from . import cx_tower_git_project_rel +from . import cx_tower_git_project_file_template_rel +from . import cx_tower_file +from . import cx_tower_file_template +from . import cx_tower_git_project +from . import cx_tower_git_remote +from . import cx_tower_git_repo +from . import cx_tower_git_repo_owner +from . import cx_tower_git_source +from . import cx_tower_server +from . import cetmix_tower +from . import cx_tower_plan_line +from . import cx_tower_command diff --git a/addons/cetmix_tower_git/models/cetmix_tower.py b/addons/cetmix_tower_git/models/cetmix_tower.py new file mode 100644 index 0000000..4a7448f --- /dev/null +++ b/addons/cetmix_tower_git/models/cetmix_tower.py @@ -0,0 +1,35 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class CetmixTower(models.AbstractModel): + _inherit = "cetmix.tower" + + @api.model + def servers_by_git_ref(self, repository_url, head=None, head_type=None): + """ + Return servers linked to a given Git repository reference. + + This is a thin shortcut that delegates to + :meth:`cx.tower.server.get_servers_by_git_ref`. + + Parameters + ---------- + repository_url : str + Pre-normalized canonical Git URL + (e.g. ``https://host/owner/repo.git``). + head : str, optional + Branch name, commit SHA, or PR identifier. + head_type : {'branch', 'commit', 'pr'}, optional + Type of the ``head`` argument. + + Returns + ------- + recordset of cx.tower.server + Matching servers. Empty recordset if no matches. + """ + return self.env["cx.tower.server"].get_servers_by_git_ref( + repository_url, head, head_type + ) diff --git a/addons/cetmix_tower_git/models/cx_tower_command.py b/addons/cetmix_tower_git/models/cx_tower_command.py new file mode 100644 index 0000000..7726e36 --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_command.py @@ -0,0 +1,37 @@ +# Copyright 2024 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, models +from odoo.tools.safe_eval import wrap_module + +# Wrap giturlparse safely +giturlparse = wrap_module(__import__("giturlparse"), ["parse", "validate"]) + + +class CxTowerCommand(models.Model): + """Extends cx.tower.command to add giturlparse functionality.""" + + _inherit = "cx.tower.command" + + def _custom_python_libraries(self): + """ + Add the giturlparse library to the available libraries. + """ + custom_python_libraries = super()._custom_python_libraries() + custom_python_libraries.update( + { + "cetmix_tower_git": { + "giturlparse": { + "import": giturlparse, + "help": _( + "Python library for Git URL parsing. " + "Available methods: 'parse', 'validate'. " + " 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 new file mode 100644 index 0000000..38fbd04 --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_file.py @@ -0,0 +1,54 @@ +# 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, + } + ) + else: + # Reset only git project id as file still belongs to the server + record.update( + { + "git_project_id": False, + } + ) diff --git a/addons/cetmix_tower_git/models/cx_tower_file_template.py b/addons/cetmix_tower_git/models/cx_tower_file_template.py new file mode 100644 index 0000000..792c453 --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_file_template.py @@ -0,0 +1,32 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class CxTowerFileTemplate(models.Model): + _inherit = "cx.tower.file.template" + + git_project_ids = fields.Many2many( + comodel_name="cx.tower.git.project", + relation="cx_tower_git_project_file_template_rel", + column1="file_template_id", + column2="git_project_id", + string="Git Projects", + copy=False, + ) + git_project_id = fields.Many2one( + comodel_name="cx.tower.git.project", + compute="_compute_git_project_id", + ) + + @api.depends("git_project_ids") + def _compute_git_project_id(self): + """ + Link to project using the proxy model. + """ + for record in self: + # File is related to project via proxy model. + # So there can be only one record in o2m field. + record.git_project_id = ( + record.git_project_ids and record.git_project_ids[0].id + ) diff --git a/addons/cetmix_tower_git/models/cx_tower_git_project.py b/addons/cetmix_tower_git/models/cx_tower_git_project.py new file mode 100644 index 0000000..cd74c2d --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_git_project.py @@ -0,0 +1,368 @@ +# 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) + 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, + ) + 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 + """ + # This regex will find all variables where variables are denoted + # as $VAR or ${VAR}, e.g., $FOO or ${FOO_BAR123} + 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 new file mode 100644 index 0000000..906a7b9 --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_git_project_file_template_rel.py @@ -0,0 +1,115 @@ +# 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_template_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( + record.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 new file mode 100644 index 0000000..f8b6d97 --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_git_project_rel.py @@ -0,0 +1,179 @@ +# 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( + record.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 new file mode 100644 index 0000000..1bcd408 --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_git_remote.py @@ -0,0 +1,403 @@ +# 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" + + 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 + """ + projects = self.git_project_id + res = super().unlink() + + # Update related files and templates on unlink + if projects: + file_relations = projects.git_project_rel_ids # type: ignore + if file_relations: + file_relations._save_to_file() + template_relations = projects.git_project_file_template_rel_ids # type: ignore + if template_relations: + template_relations._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 + + 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_authenticated_url(self, url, auth_token): + """Helper to inject authentication token into HTTPS URL. + + Args: + url (Char): URL to prepare + auth_token (Char): Authentication token + + Returns: + Char: Prepared url for git aggregator + """ + url_without_protocol = url.replace("https://", "") + return f"https://{auth_token}@{url_without_protocol}" + + 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() + return self._git_aggregator_prepare_authenticated_url( + url, + "$GITHUB_TOKEN:x-oauth-basic", + ) + + 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() + return self._git_aggregator_prepare_authenticated_url( + url, "$GITLAB_TOKEN_NAME:$GITLAB_TOKEN" + ) + + def _git_aggregator_prepare_url_bitbucket(self, url): + """ + Prepare url for git aggregator for private Bitbucket repo + using https protocol. + + Args: + url (Char): URL to prepare + + Returns: + Char: Prepared url for git aggregator + """ + self.ensure_one() + return self._git_aggregator_prepare_authenticated_url( + url, "x-token-auth:$BITBUCKET_TOKEN" + ) + + 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 _extract_head_number(self): + """ + Extract the last component from head + (branch name, PR number, or commit hash). + + Raises: + ValidationError: If head number is empty + + Returns: + Char: Extracted head number + """ + self.ensure_one() + head_number = self.head.split("/")[-1] + if not head_number: + raise ValidationError( + _("Git Aggregator: Head number is empty in %(head)s", head=self.head) + ) + return head_number + + def _git_aggregator_prepare_head_github(self): + """Prepare head for git aggregator for Github. + + Returns: + Char: Prepared head for git aggregator + """ + self.ensure_one() + head_number = self._extract_head_number() + # 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 + """ + self.ensure_one() + head_number = self._extract_head_number() + # PR/MR + if self.head_type == "pr": + return f"merge-requests/{head_number}/head" + + # Commit + # https://gitlab.com/cetmix/test/-/list/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 + """ + self.ensure_one() + # 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, + ) + ) + + head_number = self._extract_head_number() + # 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 new file mode 100644 index 0000000..6e360a3 --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_git_repo.py @@ -0,0 +1,416 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +import giturlparse + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools import ormcache + +_logger = logging.getLogger(__name__) + + +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", "owner_id.name", "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: + repo.git_project_ids = repo.remote_ids.git_project_id + + @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 + _logger.error( + "Failed to parse constructed URL '%s' for repo %s", + https_url, + repo.display_name, + ) + 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) + # Add to create list (with or without URL) + 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.env.registry.clear_cache() + return res + + def write(self, vals): + """Write repositories.""" + res = super().write(vals) + self.env.registry.clear_cache() + return res + + def unlink(self): + """Unlink repositories.""" + res = super().unlink() + self.env.registry.clear_cache() + 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", "create", "raise_if_invalid") + 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 new file mode 100644 index 0000000..3e0545a --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_git_repo_owner.py @@ -0,0 +1,107 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.tools import ormcache + + +class CxTowerGitRepoOwner(models.Model): + """ + Git Repository Owner. + Represents an organization or user that owns repositories. + Examples: "cetmix", "OCA", etc. + """ + + _name = "cx.tower.git.repo.owner" + _inherit = ["cx.tower.reference.mixin", "cx.tower.yaml.mixin"] + _description = "Cetmix Tower Git Repository Owner" + _order = "name" + + display_name = fields.Char( + readonly=False, compute="_compute_display_name", store=True + ) + + name = fields.Char( + help="Name of the repository owner (e.g., 'cetmix', 'OCA')", + ) + reference = fields.Char( + index=True, + compute="_compute_display_name", + required=False, + store=True, + ) + repo_ids = fields.One2many( + comodel_name="cx.tower.git.repo", + inverse_name="owner_id", + string="Repositories", + copy=False, + help="Repositories owned by this organization/user", + ) + secret_id = fields.Many2one( + comodel_name="cx.tower.key", + string="Secret", + domain="[('key_type', '=', 's')]", + help="Custom secret used for this repository owner", + ) + + @api.depends("name") + def _compute_display_name(self): + """Compute display name.""" + for owner in self: + # By default, display name is the same as name + name = owner.name + owner.update( + { + "display_name": name or False, + "reference": owner._generate_or_fix_reference(name) + if name + else False, + } + ) + + @ormcache("self.env.uid", "self.env.su", "name", "create") + 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.env.registry.clear_cache() + return res + + def write(self, vals): + """Clear cache on write.""" + res = super().write(vals) + if "name" in vals: + self.env.registry.clear_cache() + return res + + def unlink(self): + """Clear cache on unlink.""" + res = super().unlink() + self.env.registry.clear_cache() + 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 new file mode 100644 index 0000000..41a7fa1 --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_git_source.py @@ -0,0 +1,195 @@ +# 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 + no_name = res.filtered(lambda s: not s.name) + if no_name: + no_name._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 + """ + projects = self.git_project_id + res = super().unlink() + + # Update related files and templates on unlink + if projects: + file_relations = projects.git_project_rel_ids # type: ignore + if file_relations: + file_relations._save_to_file() + template_relations = projects.git_project_file_template_rel_ids # type: ignore + if template_relations: + template_relations._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 + if not remote_repo or not remote_repo.owner_id: + source.name = "Empty Source" + continue + 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 new file mode 100644 index 0000000..0c676c0 --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_plan_line.py @@ -0,0 +1,32 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class CxTowerPlanLine(models.Model): + """Flight Plan Line""" + + _inherit = "cx.tower.plan.line" + + git_project_id = fields.Many2one( + comodel_name="cx.tower.git.project", + string="Git Project", + help="Select a git project to be linked to the file and server.", + ) + is_make_copy = fields.Boolean( + string="Make a Copy", + help="Create a copy of the Git Project instead of linking " + "the file to the existing one.", + ) + + # ------------------------------ + # YAML mixin methods + # ------------------------------ + def _get_fields_for_yaml(self): + res = super()._get_fields_for_yaml() + res += [ + "git_project_id", + "is_make_copy", + ] + return res diff --git a/addons/cetmix_tower_git/models/cx_tower_server.py b/addons/cetmix_tower_git/models/cx_tower_server.py new file mode 100644 index 0000000..55c9c56 --- /dev/null +++ b/addons/cetmix_tower_git/models/cx_tower_server.py @@ -0,0 +1,187 @@ +# 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 new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/addons/cetmix_tower_git/pyproject.toml @@ -0,0 +1,3 @@ +[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 new file mode 100644 index 0000000..8c717e5 --- /dev/null +++ b/addons/cetmix_tower_git/readme/CONFIGURE.md @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..19da7e3 --- /dev/null +++ b/addons/cetmix_tower_git/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module implements Git Management functionality for [Cetmix Tower](https://cetmix.com/tower). + +Please refer to the [official documentation](https://cetmix.com/tower) for detailed information. diff --git a/addons/cetmix_tower_git/readme/HISTORY.md b/addons/cetmix_tower_git/readme/HISTORY.md new file mode 100644 index 0000000..c9e6096 --- /dev/null +++ b/addons/cetmix_tower_git/readme/HISTORY.md @@ -0,0 +1,10 @@ +## 18.0.1.0.2 (2026-03-10) + +- 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) + +- Bugfixes: Link server to git project only once. (5214) + + +## 18.0.1.0.1 (2025-12-17) + +- Features: Improve search views, implement the search panel for selected views. (5139) diff --git a/addons/cetmix_tower_git/readme/USAGE.md b/addons/cetmix_tower_git/readme/USAGE.md new file mode 100644 index 0000000..901f5a6 --- /dev/null +++ b/addons/cetmix_tower_git/readme/USAGE.md @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..c01bc75 --- /dev/null +++ b/addons/cetmix_tower_git/security/cx_tower_git_project_file_template_rel_security.xml @@ -0,0 +1,46 @@ + + + + + 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 new file mode 100644 index 0000000..ff23797 --- /dev/null +++ b/addons/cetmix_tower_git/security/cx_tower_git_project_rel_security.xml @@ -0,0 +1,43 @@ + + + + + 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 new file mode 100644 index 0000000..b5aab7b --- /dev/null +++ b/addons/cetmix_tower_git/security/cx_tower_git_project_security.xml @@ -0,0 +1,64 @@ + + + + + Git Project: Manager Read Access + + ['|', ('user_ids', 'in', [user.id]), ('manager_ids', 'in', [user.id])] + + + + + + + + + + Git Project: Manager Read Access via Server + + ['|', + ('git_project_rel_ids.server_id.user_ids', 'in', [user.id]), + ('git_project_rel_ids.server_id.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 new file mode 100644 index 0000000..fc1a5dd --- /dev/null +++ b/addons/cetmix_tower_git/security/cx_tower_git_remote_security.xml @@ -0,0 +1,70 @@ + + + + + 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.git_project_rel_ids.server_id.user_ids', 'in', [user.id]), + ('git_project_id.git_project_rel_ids.server_id.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 new file mode 100644 index 0000000..6106223 --- /dev/null +++ b/addons/cetmix_tower_git/security/cx_tower_git_repo_owner_security.xml @@ -0,0 +1,24 @@ + + + + + + + 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 new file mode 100644 index 0000000..ff59427 --- /dev/null +++ b/addons/cetmix_tower_git/security/cx_tower_git_repo_security.xml @@ -0,0 +1,24 @@ + + + + + + + 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 new file mode 100644 index 0000000..706645a --- /dev/null +++ b/addons/cetmix_tower_git/security/cx_tower_git_source_security.xml @@ -0,0 +1,72 @@ + + + + + + + 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.git_project_rel_ids.server_id.user_ids', 'in', [user.id]), + ('git_project_id.git_project_rel_ids.server_id.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 new file mode 100644 index 0000000..750134f --- /dev/null +++ b/addons/cetmix_tower_git/security/ir.model.access.csv @@ -0,0 +1,15 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_git_config_manager,Git Config Manager,model_cx_tower_git_project,cetmix_tower_server.group_manager,1,1,1,1 +access_git_config_root,Git Config Root,model_cx_tower_git_project,cetmix_tower_server.group_root,1,1,1,1 +access_git_source_manager,Git Source Manager,model_cx_tower_git_source,cetmix_tower_server.group_manager,1,1,1,1 +access_git_source_root,Git Source Root,model_cx_tower_git_source,cetmix_tower_server.group_root,1,1,1,1 +access_git_remote_manager,Git Remote Manager,model_cx_tower_git_remote,cetmix_tower_server.group_manager,1,1,1,1 +access_git_remote_root,Git Remote Root,model_cx_tower_git_remote,cetmix_tower_server.group_root,1,1,1,1 +access_git_repo_manager,Git Repository Manager,model_cx_tower_git_repo,cetmix_tower_server.group_manager,1,1,1,1 +access_git_repo_root,Git Repository Root,model_cx_tower_git_repo,cetmix_tower_server.group_root,1,1,1,1 +access_git_repo_owner_manager,Git Repository Owner Manager,model_cx_tower_git_repo_owner,cetmix_tower_server.group_manager,1,1,1,0 +access_git_repo_owner_root,Git Repository Owner Root,model_cx_tower_git_repo_owner,cetmix_tower_server.group_root,1,1,1,1 +access_git_project_server_file_rel,Git Project Server File Rel Manager,model_cx_tower_git_project_rel,cetmix_tower_server.group_manager,1,1,1,1 +access_git_project_server_file_rel_root,Git Project Server File Rel Root,model_cx_tower_git_project_rel,cetmix_tower_server.group_root,1,1,1,1 +access_git_project_file_template_rel,Git Project File Template Rel Manager,model_cx_tower_git_project_file_template_rel,cetmix_tower_server.group_manager,1,1,1,1 +access_git_project_file_template_rel_root,Git Project File Template Rel Root,model_cx_tower_git_project_file_template_rel,cetmix_tower_server.group_root,1,1,1,1 diff --git a/addons/cetmix_tower_git/static/description/banner.png b/addons/cetmix_tower_git/static/description/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..48d0b508a8c55bc767c0422908f4644ca1284072 GIT binary patch literal 86695 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 z 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 }spw8BM!` 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 F{{#JqH_ZS5 literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_git/static/description/index.html b/addons/cetmix_tower_git/static/description/index.html new file mode 100644 index 0000000..cc0aa42 --- /dev/null +++ b/addons/cetmix_tower_git/static/description/index.html @@ -0,0 +1,450 @@ + + + + + +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

+
+

18.0.1.0.2 (2026-03-10)

+
    +
  • 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)
  • +
  • Bugfixes: Link server to git project only once. (5214)
  • +
+
+
+

18.0.1.0.1 (2025-12-17)

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

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 new file mode 100644 index 0000000..97c9645 --- /dev/null +++ b/addons/cetmix_tower_git/tests/__init__.py @@ -0,0 +1,7 @@ +from . import test_remote +from . import test_source +from . import test_project +from . import test_file_rel +from . import test_file_template_rel +from . import test_server +from . import test_repo diff --git a/addons/cetmix_tower_git/tests/common.py b/addons/cetmix_tower_git/tests/common.py new file mode 100644 index 0000000..b3c9643 --- /dev/null +++ b/addons/cetmix_tower_git/tests/common.py @@ -0,0 +1,136 @@ +from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon + + +class CommonTest(TestTowerCommon): + """Common test class for all tests.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Models + cls.GitProject = cls.env["cx.tower.git.project"] + cls.GitProjectRel = cls.env["cx.tower.git.project.rel"] + cls.GitProjectFileTemplateRel = cls.env[ + "cx.tower.git.project.file.template.rel" + ] + cls.GitSource = cls.env["cx.tower.git.source"] + cls.GitRemote = cls.env["cx.tower.git.remote"] + + # Data + # Project + cls.git_project_1 = cls.GitProject.create({"name": "Git Project 1"}) + + # Sources + cls.git_source_1 = cls.GitSource.create( + {"name": "Git Source 1", "git_project_id": cls.git_project_1.id} + ) + cls.git_source_2 = cls.GitSource.create( + {"name": "Git Source 2", "git_project_id": cls.git_project_1.id} + ) + # Repositories + cls.Repo = cls.env["cx.tower.git.repo"] + cls.RepoOwner = cls.env["cx.tower.git.repo.owner"] + + cls.repo_cetmix_tower = cls.Repo.create( + { + "name": "Cetmix Tower", + "url": "https://github.com/cetmix-test/cetmix-tower-test.git", + } + ) + cls.repo_oca_web = cls.Repo.create( + { + "name": "OCA Web", + "url": "https://github.com/oca-test/web-test.git", + } + ) + cls.repo_odoo_enterprise = cls.Repo.create( + { + "name": "Odoo Enterprise", + "url": "https://github.com/odoo-test/enterprise-test.git", + "is_private": True, + } + ) + cls.repo_gitlab_private = cls.Repo.create( + { + "name": "GitLab Private", + "url": "git@my.gitlab.com:cetmix-test/cetmix-tower-test.git", + "is_private": True, + } + ) + cls.repo_bitbucket_private = cls.Repo.create( + { + "name": "Bitbucket Private", + "url": "https://bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git", + "is_private": True, + } + ) + + # Same urls, different protocols (intentionally aliased) + cls.repo_other_ssh = cls.Repo.create( + {"url": "git@memegit.com:cetmix-test/cetmix-tower-test.git"} + ) + cls.repo_other_https = cls.repo_other_ssh + + # Remotes + cls.remote_github_https = cls.GitRemote.create( + { + "repo_id": cls.repo_cetmix_tower.id, + "source_id": cls.git_source_1.id, + "head_type": "pr", + "head": "https://github.com/cetmix-test/cetmix-tower-test/pull/123", + "sequence": 1, + } + ) + cls.remote_gitlab_https = cls.GitRemote.create( + { + "repo_id": cls.repo_gitlab_private.id, + "source_id": cls.git_source_1.id, + "head_type": "branch", + "head": "main", + "sequence": 2, + } + ) + cls.remote_gitlab_ssh = cls.GitRemote.create( + { + "repo_id": cls.repo_gitlab_private.id, + "source_id": cls.git_source_1.id, + "head_type": "commit", + "url_protocol": "ssh", + "head": "10000000", + "sequence": 3, + } + ) + cls.remote_bitbucket_https = cls.GitRemote.create( + { + "repo_id": cls.repo_bitbucket_private.id, + "source_id": cls.git_source_2.id, + "head_type": "branch", + "head": "dev", + "sequence": 4, + } + ) + cls.remote_other_ssh = cls.GitRemote.create( + { + "repo_id": cls.repo_other_ssh.id, + "source_id": cls.git_source_2.id, + "head_type": "branch", + "url_protocol": "ssh", + "head": "old", + "sequence": 5, + } + ) + + # File + cls.server_1_file_1 = cls.File.create( + { + "name": "File 1", + "server_id": cls.server_test_1.id, + "source": "tower", + } + ) + cls.file_template_1 = cls.FileTemplate.create( + { + "name": "File Template 1", + } + ) diff --git a/addons/cetmix_tower_git/tests/test_file_rel.py b/addons/cetmix_tower_git/tests/test_file_rel.py new file mode 100644 index 0000000..a958341 --- /dev/null +++ b/addons/cetmix_tower_git/tests/test_file_rel.py @@ -0,0 +1,390 @@ +from odoo.exceptions import AccessError + +from .common import CommonTest + + +class TestFileRel(CommonTest): + """Test class for git file relation.""" + + def setUp(self): + super().setUp() + self.file_1_rel = self.GitProjectRel.create( + { + "server_id": self.server_test_1.id, + "file_id": self.server_1_file_1.id, + "git_project_id": self.git_project_1.id, + "project_format": "git_aggregator", + } + ) + + def test_file_rel_create(self): + """Test if file relation is created correctly""" + + # -- 1 -- + # Check if file content is updated + + # Get code from project + yaml_code_from_project = ( + self.file_1_rel.git_project_id._generate_code_git_aggregator( + self.file_1_rel + ) + ) + + self.assertEqual( + self.server_1_file_1.code, + yaml_code_from_project, + "File content is not updated correctly", + ) + + # Check specific if remote is present in file + self.assertIn( + self.remote_other_ssh.repo_id.url_ssh, + self.server_1_file_1.code, + "Remote is not present in file", + ) + + # -- 2 -- + # Modify remove and check if file content is updated + self.remote_other_ssh.repo_id = self.Repo.create( + { + "url": "https://github.com/cetmix/cetmix-memes.git", + } + ) + self.remote_other_ssh.url_protocol = "https" + + # Must be different from previous project code + self.assertNotEqual( + self.server_1_file_1.code, + yaml_code_from_project, + "File content is not updated correctly", + ) + # New remote must be present in file + self.assertIn( + "https://github.com/cetmix/cetmix-memes.git", + self.server_1_file_1.code, + "Remote is not present in file", + ) + + # -- 3 -- + # Disable source and check if file content is updated + self.git_source_2.active = False + self.assertNotIn( + "https://github.com/cetmix/cetmix-memes.git", + self.server_1_file_1.code, + "Remote is present in file", + ) + + def test_format_git_aggregator(self): + """Test if format git aggregator works correctly""" + + # -- 1 -- + # Check if YAML code is generated correctly + + yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower +# It's designed to be used with git-aggregator tool developed by Acsone. +# Documentation for git-aggregator: https://github.com/acsone/git-aggregator + +# You need to set the following variables in your environment: +# BITBUCKET_TOKEN, GITLAB_TOKEN, GITLAB_TOKEN_NAME +# and run git-aggregator with '--expand-env' parameter. + +./git_project_1_git_source_1: + remotes: + remote_1: https://github.com/cetmix-test/cetmix-tower-test.git + remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git + remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git + merges: + - remote: remote_1 + ref: refs/pull/123/head + - remote: remote_2 + ref: main + - remote: remote_3 + ref: '10000000' + target: remote_1 +./git_project_1_git_source_1_2: + remotes: + remote_1: https://x-token-auth:$BITBUCKET_TOKEN@bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git + remote_2: git@memegit.com:cetmix-test/cetmix-tower-test.git + merges: + - remote: remote_1 + ref: dev + - remote: remote_2 + ref: old + target: remote_1 +""" # noqa: E501 + + # Get code from project + yaml_code_from_project = ( + self.file_1_rel.git_project_id._generate_code_git_aggregator( + self.file_1_rel + ) + ) + self.assertEqual( + yaml_code_from_project, + yaml_code, + "YAML code is not generated correctly", + ) + + # -- 2 -- + # Unlink remote and check if file content is updated + self.remote_github_https.unlink() + yaml_code_from_project = ( + self.file_1_rel.git_project_id._generate_code_git_aggregator( + self.file_1_rel + ) + ) + yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower +# It's designed to be used with git-aggregator tool developed by Acsone. +# Documentation for git-aggregator: https://github.com/acsone/git-aggregator + +# You need to set the following variables in your environment: +# BITBUCKET_TOKEN, GITLAB_TOKEN, GITLAB_TOKEN_NAME +# and run git-aggregator with '--expand-env' parameter. + +./git_project_1_git_source_1: + remotes: + remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git + remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git + merges: + - remote: remote_2 + ref: main + - remote: remote_3 + ref: '10000000' + target: remote_2 +./git_project_1_git_source_1_2: + remotes: + remote_1: https://x-token-auth:$BITBUCKET_TOKEN@bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git + remote_2: git@memegit.com:cetmix-test/cetmix-tower-test.git + merges: + - remote: remote_1 + ref: dev + - remote: remote_2 + ref: old + target: remote_1 +""" # noqa: E501 + + self.assertEqual( + yaml_code_from_project, + yaml_code, + "YAML code is not generated correctly", + ) + + # -- 3 -- + # Unlink source and check if file content is updated + self.git_source_2.unlink() + yaml_code_from_project = ( + self.file_1_rel.git_project_id._generate_code_git_aggregator( + self.file_1_rel + ) + ) + yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower +# It's designed to be used with git-aggregator tool developed by Acsone. +# Documentation for git-aggregator: https://github.com/acsone/git-aggregator + +# You need to set the following variables in your environment: +# GITLAB_TOKEN, GITLAB_TOKEN_NAME +# and run git-aggregator with '--expand-env' parameter. + +./git_project_1_git_source_1: + remotes: + remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git + remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git + merges: + - remote: remote_2 + ref: main + - remote: remote_3 + ref: '10000000' + target: remote_2 +""" # noqa: E501 + self.assertEqual( + yaml_code_from_project, + yaml_code, + "YAML code is not generated correctly", + ) + + def test_user_access(self): + """Test that regular users have no access to git project relations""" + user_rel = self.GitProjectRel.with_user(self.user) + + # Try create - should fail + with self.assertRaises(AccessError): + user_rel.create( + { + "server_id": self.server_test_1.id, + "file_id": self.server_1_file_1.id, + "git_project_id": self.git_project_1.id, + "project_format": "git_aggregator", + } + ) + + # Try read - should fail + with self.assertRaises(AccessError): + user_rel.browse(self.file_1_rel.id).read(["name"]) + + # Try write - should fail + with self.assertRaises(AccessError): + user_rel.browse(self.file_1_rel.id).write( + {"project_format": "git_aggregator"} + ) + + # Try unlink - should fail + with self.assertRaises(AccessError): + user_rel.browse(self.file_1_rel.id).unlink() + + def test_manager_read_access(self): + """Test manager read access rules""" + manager_rel = self.GitProjectRel.with_user(self.manager) + + # Initially manager should not have access + with self.assertRaises(AccessError): + manager_rel.browse(self.file_1_rel.id).read(["name"]) + + # Add manager as project user - should have read access + self.git_project_1.write({"user_ids": [(4, self.manager.id)]}) + self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1") + + # Remove from project, add as server user - should have read access + self.git_project_1.write({"user_ids": [(3, self.manager.id)]}) + self.server_test_1.write({"user_ids": [(4, self.manager.id)]}) + self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1") + + # Remove from server users, add as project manager - should have read access + self.server_test_1.write({"user_ids": [(3, self.manager.id)]}) + self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) + self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1") + + # Remove from project, add as server manager - should have read access + self.git_project_1.write({"manager_ids": [(3, self.manager.id)]}) + self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) + self.assertEqual(manager_rel.browse(self.file_1_rel.id).name, "Git Project 1") + + def test_manager_write_access(self): + """Test manager write/create access rules""" + manager_rel = self.GitProjectRel.with_user(self.manager) + + # Create new file to avoid unique constraint violation + file_2 = self.File.create( + { + "name": "test_file_2", + "server_id": self.server_test_1.id, + "source": "tower", + "file_type": "text", + } + ) + + # Try create without being project and server manager - should fail + with self.assertRaises(AccessError): + manager_rel.create( + { + "server_id": self.server_test_1.id, + "file_id": file_2.id, + "git_project_id": self.git_project_1.id, + "project_format": "git_aggregator", + } + ) + + # Add as project manager only - should still fail + file_3 = self.File.create( + { + "name": "test_file_3", + "server_id": self.server_test_1.id, + "source": "tower", + "file_type": "text", + } + ) + self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) + with self.assertRaises(AccessError): + manager_rel.create( + { + "server_id": self.server_test_1.id, + "file_id": file_3.id, + "git_project_id": self.git_project_1.id, + "project_format": "git_aggregator", + } + ) + + # Add as server manager - should succeed + file_4 = self.File.create( + { + "name": "test_file_4", + "server_id": self.server_test_1.id, + "source": "tower", + "file_type": "text", + } + ) + self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) + rel = manager_rel.create( + { + "server_id": self.server_test_1.id, + "file_id": file_4.id, + "git_project_id": self.git_project_1.id, + "project_format": "git_aggregator", + } + ) + self.assertTrue(rel.exists()) + + # Test write access + rel.write({"project_format": "git_aggregator"}) + + # Remove server manager access - should fail to write + self.server_test_1.write({"manager_ids": [(3, self.manager.id)]}) + with self.assertRaises(AccessError): + rel.write({"project_format": "git_aggregator"}) + + # Remove project manager access - should fail to write + self.git_project_1.write({"manager_ids": [(3, self.manager.id)]}) + with self.assertRaises(AccessError): + rel.write({"project_format": "git_aggregator"}) + + def test_manager_unlink_access(self): + """Test manager unlink access rules""" + manager_rel = self.GitProjectRel.with_user(self.manager) + + # Try delete without being project and server manager - should fail + with self.assertRaises(AccessError): + manager_rel.browse(self.file_1_rel.id).unlink() + + # Add as project manager only - should fail + self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) + with self.assertRaises(AccessError): + manager_rel.browse(self.file_1_rel.id).unlink() + + # Add as server manager - should succeed + self.server_test_1.write({"manager_ids": [(4, self.manager.id)]}) + self.file_1_rel.with_user(self.manager).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 new file mode 100644 index 0000000..11e8849 --- /dev/null +++ b/addons/cetmix_tower_git/tests/test_file_template_rel.py @@ -0,0 +1,308 @@ +from odoo.exceptions import AccessError + +from .common import CommonTest + + +class TestFileTemplateRel(CommonTest): + """Test class for git file template relation.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.file_template_1_rel = cls.GitProjectFileTemplateRel.create( + { + "git_project_id": cls.git_project_1.id, + "file_template_id": cls.file_template_1.id, + "project_format": "git_aggregator", + } + ) + + def test_file_template_rel_create(self): + """Test if file template relation is created correctly""" + + # -- 1 -- + # Check if file content is updated + + # Get code from project + yaml_code_from_project = ( + self.file_template_1_rel.git_project_id._generate_code_git_aggregator( + self.file_template_1_rel + ) + ) + + self.assertEqual( + self.file_template_1.code, + yaml_code_from_project, + "File template content is not updated correctly", + ) + + # Check specific if remote is present in file + self.assertIn( + self.remote_other_ssh.repo_id.url_ssh, + self.file_template_1.code, + "Remote is not present in file template", + ) + + # -- 2 -- + # Modify remove and check if file template content is updated + self.remote_other_ssh.repo_id = self.Repo.create( + { + "url": "https://github.com/cetmix/cetmix-memes.git", + } + ) + self.remote_other_ssh.url_protocol = "https" + + # Must be different from previous project code + self.assertNotEqual( + self.file_template_1.code, + yaml_code_from_project, + "File template content is not updated correctly", + ) + # New remote must be present in file + self.assertIn( + "https://github.com/cetmix/cetmix-memes.git", + self.file_template_1.code, + "Remote is not present in file template", + ) + + # -- 3 -- + # Disable source and check if file content is updated + self.git_source_2.active = False + self.assertNotIn( + "https://github.com/cetmix/cetmix-memes.git", + self.file_template_1.code, + "Remote is present in file template", + ) + + def test_format_git_aggregator(self): + """Test if format git aggregator works correctly""" + + # -- 1 -- + # Check if YAML code is generated correctly + + yaml_code = """# This file is generated with Cetmix Tower https://cetmix.com/tower +# It's designed to be used with git-aggregator tool developed by Acsone. +# Documentation for git-aggregator: https://github.com/acsone/git-aggregator + +# You need to set the following variables in your environment: +# BITBUCKET_TOKEN, GITLAB_TOKEN, GITLAB_TOKEN_NAME +# and run git-aggregator with '--expand-env' parameter. + +./git_project_1_git_source_1: + remotes: + remote_1: https://github.com/cetmix-test/cetmix-tower-test.git + remote_2: https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git + remote_3: git@my.gitlab.com:cetmix-test/cetmix-tower-test.git + merges: + - remote: remote_1 + ref: refs/pull/123/head + - remote: remote_2 + ref: main + - remote: remote_3 + ref: '10000000' + target: remote_1 +./git_project_1_git_source_1_2: + remotes: + remote_1: https://x-token-auth:$BITBUCKET_TOKEN@bitbucket.com/cetmix-test/cetmix-tower-test-enterprise.git + remote_2: git@memegit.com:cetmix-test/cetmix-tower-test.git + merges: + - remote: remote_1 + ref: dev + - remote: remote_2 + ref: old + target: remote_1 +""" # noqa: E501 + + # Get code from project + yaml_code_from_project = ( + self.file_template_1_rel.git_project_id._generate_code_git_aggregator( + self.file_template_1_rel + ) + ) + self.assertEqual( + yaml_code_from_project, + yaml_code, + "YAML code is not generated correctly", + ) + + def test_user_access(self): + """Test that regular users have no access to git project relations""" + user_rel = self.GitProjectFileTemplateRel.with_user(self.user) + + # Try create - should fail + with self.assertRaises(AccessError): + user_rel.create( + { + "git_project_id": self.git_project_1.id, + "file_template_id": self.file_template_1.id, + "project_format": "git_aggregator", + } + ) + + # Try read - should fail + with self.assertRaises(AccessError): + user_rel.browse(self.file_template_1_rel.id).read(["name"]) + + # Try write - should fail + with self.assertRaises(AccessError): + user_rel.browse(self.file_template_1_rel.id).write( + {"project_format": "git_aggregator"} + ) + + # Try unlink - should fail + with self.assertRaises(AccessError): + user_rel.browse(self.file_template_1_rel.id).unlink() + + def test_manager_read_access(self): + """Test manager read access rules""" + manager_rel = self.GitProjectFileTemplateRel.with_user(self.manager) + + # Initially manager should not have access + with self.assertRaises(AccessError): + manager_rel.browse(self.file_template_1_rel.id).read(["name"]) + + # Add manager as project user - should have read access + self.git_project_1.write({"user_ids": [(4, self.manager.id)]}) + self.assertEqual( + manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1" + ) + + # Remove from project, add as file template user + # should have read access + self.git_project_1.write({"user_ids": [(3, self.manager.id)]}) + self.file_template_1.write({"user_ids": [(4, self.manager.id)]}) + self.assertEqual( + manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1" + ) + + # Remove from file template users, add as project manager + # should have read access + self.file_template_1.write({"user_ids": [(3, self.manager.id)]}) + self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) + self.assertEqual( + manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1" + ) + + # Remove from project, add as file template manager + # should have read access + self.git_project_1.write({"manager_ids": [(3, self.manager.id)]}) + self.file_template_1.write({"manager_ids": [(4, self.manager.id)]}) + self.assertEqual( + manager_rel.browse(self.file_template_1_rel.id).name, "Git Project 1" + ) + + def test_manager_write_access(self): + """Test manager write/create access rules""" + manager_rel = self.GitProjectFileTemplateRel.with_user(self.manager) + + # Create new file template to avoid unique constraint violation + file_template_2 = self.FileTemplate.create( + { + "name": "test_file_template_2", + } + ) + + # Try create without being project and file template manager - should fail + with self.assertRaises(AccessError): + manager_rel.create( + { + "git_project_id": self.git_project_1.id, + "file_template_id": file_template_2.id, + "project_format": "git_aggregator", + } + ) + + # Add as project manager only - should still fail + file_template_3 = self.FileTemplate.create( + { + "name": "test_file_template_3", + } + ) + self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) + with self.assertRaises(AccessError): + manager_rel.create( + { + "git_project_id": self.git_project_1.id, + "file_template_id": file_template_3.id, + "project_format": "git_aggregator", + } + ) + + # Add as file template manager - should succeed + file_template_4 = self.FileTemplate.create( + { + "name": "test_file_template_4", + } + ) + file_template_4.write({"manager_ids": [(4, self.manager.id)]}) + rel = manager_rel.create( + { + "git_project_id": self.git_project_1.id, + "file_template_id": file_template_4.id, + "project_format": "git_aggregator", + } + ) + self.assertTrue(rel.exists()) + + # Test write access + rel.write({"project_format": "git_aggregator"}) + + # Remove file template manager access - should fail to write + file_template_4.write({"manager_ids": [(3, self.manager.id)]}) + with self.assertRaises(AccessError): + rel.write({"project_format": "git_aggregator"}) + + # Remove project manager access - should fail to write + self.git_project_1.write({"manager_ids": [(3, self.manager.id)]}) + file_template_4.write({"manager_ids": [(4, self.manager.id)]}) + with self.assertRaises(AccessError): + rel.write({"project_format": "git_aggregator"}) + + def test_manager_unlink_access(self): + """Test manager unlink access rules""" + manager_rel = self.GitProjectFileTemplateRel.with_user(self.manager) + + # Try delete without being project and server manager - should fail + with self.assertRaises(AccessError): + manager_rel.browse(self.file_template_1_rel.id).unlink() + + # Add as project manager only - should fail + self.git_project_1.write({"manager_ids": [(4, self.manager.id)]}) + with self.assertRaises(AccessError): + manager_rel.browse(self.file_template_1_rel.id).unlink() + + # Add as file template manager - should succeed + self.file_template_1.write({"manager_ids": [(4, self.manager.id)]}) + self.file_template_1_rel.unlink() + self.assertFalse(self.file_template_1_rel.exists()) + + def test_root_access(self): + """Test root access rules""" + root_rel = self.GitProjectFileTemplateRel.with_user(self.root) + + # Create new file to avoid unique constraint violation + file_template_3 = self.FileTemplate.create( + { + "name": "test_file_template_3", + } + ) + + # Create - should succeed + rel = root_rel.create( + { + "git_project_id": self.git_project_1.id, + "file_template_id": file_template_3.id, + "project_format": "git_aggregator", + } + ) + self.assertTrue(rel.exists()) + + # Read - should succeed + self.assertEqual(root_rel.browse(rel.id).name, "Git Project 1") + + # Write - should succeed + root_rel.browse(rel.id).write({"project_format": "git_aggregator"}) + + # Delete - should succeed + rel.unlink() + self.assertFalse(rel.exists()) diff --git a/addons/cetmix_tower_git/tests/test_project.py b/addons/cetmix_tower_git/tests/test_project.py new file mode 100644 index 0000000..588d520 --- /dev/null +++ b/addons/cetmix_tower_git/tests/test_project.py @@ -0,0 +1,315 @@ +from odoo.exceptions import AccessError + +from .common import CommonTest + + +class TestProject(CommonTest): + """Test class for git project.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Remove user bob from all groups + cls.remove_from_group( + cls.user_bob, + [ + "cetmix_tower_server.group_user", + "cetmix_tower_server.group_manager", + "cetmix_tower_server.group_root", + ], + ) + + # Create another manager for testing + cls.manager_2 = cls.Users.create( + { + "name": "Second Manager", + "login": "manager2", + "email": "manager2@test.com", + "groups_id": [(4, cls.env.ref("cetmix_tower_server.group_manager").id)], + } + ) + + # Create test project as root + cls.project = cls.GitProject.create( + { + "name": "Test Project", + } + ) + + def test_user_access(self): + """Test that regular users have no access to git projects""" + user_project = self.GitProject.with_user(self.user) + + # Test CRUD operations + with self.assertRaises(AccessError): + user_project.create({"name": "New Project"}) + with self.assertRaises(AccessError): + user_project.browse(self.project.id).read(["name"]) + with self.assertRaises(AccessError): + user_project.browse(self.project.id).write({"name": "Updated Name"}) + with self.assertRaises(AccessError): + user_project.browse(self.project.id).unlink() + + def test_manager_read_access(self): + """Test manager read access rules""" + manager_project = self.GitProject.with_user(self.manager) + + # Manager not in user_ids or manager_ids - should not read + with self.assertRaises(AccessError): + manager_project.browse(self.project.id).read(["name"]) + + # Add manager to user_ids - should read + self.project.write({"user_ids": [(4, self.manager.id)]}) + self.assertEqual(manager_project.browse(self.project.id).name, "Test Project") + + # Remove from user_ids, add to manager_ids - should read + self.project.write( + {"user_ids": [(3, self.manager.id)], "manager_ids": [(4, self.manager.id)]} + ) + self.assertEqual(manager_project.browse(self.project.id).name, "Test Project") + + def test_manager_write_access(self): + """Test manager write/create access rules""" + manager_project = self.GitProject.with_user(self.manager) + + # Create - should succeed as manager is added by default + new_project = manager_project.create({"name": "New Project"}) + self.assertTrue(new_project.exists()) + self.assertIn(self.manager, new_project.manager_ids) + + # Write - not in manager_ids, should fail + with self.assertRaises(AccessError): + manager_project.browse(self.project.id).write({"name": "Updated Name"}) + + # Add to manager_ids - should write + self.project.write({"manager_ids": [(4, self.manager.id)]}) + manager_project.browse(self.project.id).write({"name": "Updated Name"}) + self.assertEqual(self.project.name, "Updated Name") + + def test_manager_unlink_access(self): + """Test manager unlink access rules""" + # Create project as manager_2 + project = self.GitProject.with_user(self.manager_2).create( + {"name": "Project to Delete"} + ) + manager_project = self.GitProject.with_user(self.manager) + + # Try delete as different manager - should fail + with self.assertRaises(AccessError): + manager_project.browse(project.id).unlink() + + # Add to manager_ids but not creator - should fail + project.write({"manager_ids": [(4, self.manager.id)]}) + with self.assertRaises(AccessError): + manager_project.browse(project.id).unlink() + + # Create as manager and try delete - should succeed + own_project = manager_project.create({"name": "Own Project"}) + self.assertTrue(own_project.exists()) + own_project.unlink() + self.assertFalse(own_project.exists()) + + def test_root_access(self): + """Test root access rules""" + root_project = self.GitProject.with_user(self.root) + + # Create + new_project = root_project.create({"name": "Root Project"}) + self.assertTrue(new_project.exists()) + + # Read + self.assertEqual(root_project.browse(self.project.id).name, "Test Project") + + # Write + root_project.browse(self.project.id).write({"name": "Updated by Root"}) + self.assertEqual(self.project.name, "Updated by Root") + + # Delete + new_project.unlink() + self.assertFalse(new_project.exists()) + + def test_compute_user_ids(self): + """Test computation of user_ids and manager_ids for git projects""" + # Add users "Bob" and "user" to the group "cetmix_tower_server.group_manager" + self.add_to_group(self.user_bob, "cetmix_tower_server.group_manager") + self.add_to_group(self.user, "cetmix_tower_server.group_manager") + + # -- 1 -- + # Create project as manager + project_as_manager = self.GitProject.with_user(self.manager).create( + { + "name": "Project As Manager", + } + ) + # Check that manager is added to both user_ids and manager_ids by default + self.assertEqual(len(project_as_manager.user_ids), 1) + self.assertIn(self.manager, project_as_manager.user_ids) + self.assertEqual(len(project_as_manager.manager_ids), 1) + self.assertIn(self.manager, project_as_manager.manager_ids) + + # -- 2 -- + # Create servers with multiple users and managers + server_1 = self.Server.create( + { + "name": "Test Server 1", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "os_id": self.os_debian_10.id, + "user_ids": [(6, 0, [self.user_bob.id, self.user.id])], # Two users + "manager_ids": [ + (6, 0, [self.manager.id, self.manager_2.id]) + ], # Two managers + } + ) + + server_2 = self.Server.create( + { + "name": "Test Server 2", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "os_id": self.os_debian_10.id, + "user_ids": [ + (6, 0, [self.user_bob.id, self.user.id]) + ], # Same two users + "manager_ids": [ + (6, 0, [self.manager.id, self.manager_2.id]) + ], # Same two managers + } + ) + + # Create project and link servers + project = self.GitProject.create( + { + "name": "Test Project", + } + ) + + # Create files and link them to the project + for server in [server_1, server_2]: + file = self.File.create( + { + "name": f"test_file_{server.name}", + "server_id": server.id, + } + ) + self.GitProjectRel.create( + { + "server_id": server.id, + "file_id": file.id, + "git_project_id": project.id, + "project_format": "git_aggregator", + } + ) + + # Invalidate cache to ensure computed fields are updated + project.invalidate_recordset(["server_ids", "user_ids", "manager_ids"]) + + # -- 3 -- + # Test computed values with linked servers + # Each user/manager should be counted only once even if present in both servers + self.assertEqual(len(project.server_ids), 2) + self.assertEqual(len(project.user_ids), 2) # Two unique users + self.assertIn(self.user_bob, project.user_ids) + self.assertIn(self.user, project.user_ids) + self.assertEqual(len(project.manager_ids), 2) # Two unique managers + self.assertIn(self.manager, project.manager_ids) + self.assertIn(self.manager_2, project.manager_ids) + + # -- 4 -- + # Add server with different users/managers + server_3 = self.Server.create( + { + "name": "Test Server 3", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "os_id": self.os_debian_10.id, + "user_ids": [(6, 0, [self.user_bob.id])], # Only one user + "manager_ids": [(6, 0, [self.manager_2.id])], # Only second manager + } + ) + file_3 = self.File.create( + { + "name": "test_file_3", + "server_id": server_3.id, + } + ) + self.GitProjectRel.create( + { + "server_id": server_3.id, + "file_id": file_3.id, + "git_project_id": project.id, + "project_format": "git_aggregator", + } + ) + + # Invalidate cache to ensure computed fields are updated + project.invalidate_recordset(["server_ids", "user_ids", "manager_ids"]) + + # Test that computed values are updated correctly + # Only users/managers present in all servers should remain + self.assertEqual(len(project.server_ids), 3) + self.assertEqual(len(project.user_ids), 1) # Only bob is in all servers + self.assertIn(self.user_bob, project.user_ids) + self.assertEqual( + len(project.manager_ids), 1 + ) # Only manager_2 is in all servers + self.assertIn(self.manager_2, project.manager_ids) + + # -- 5 -- + # Verify that first manager can still access the project + project_as_manager_1 = self.GitProject.with_user(self.manager).browse( + project.id + ) + self.assertTrue(project_as_manager_1.exists()) + self.assertEqual(project_as_manager_1.name, "Test Project") + + def test_manager_server_based_access(self): + """Test manager access through server relationships""" + manager_project = self.GitProject.with_user(self.manager) + + # Create a server where manager is a user + server = self.Server.create( + { + "name": "Test Server", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "os_id": self.os_debian_10.id, + "user_ids": [(4, self.manager.id)], + } + ) + + # Create a file and link project to server + file = self.File.create( + { + "name": "test_file", + "server_id": server.id, + } + ) + self.GitProjectRel.create( + { + "server_id": server.id, + "file_id": file.id, + "git_project_id": self.project.id, + "project_format": "git_aggregator", + } + ) + + # Manager should be able to read project through server relationship + self.assertEqual(manager_project.browse(self.project.id).name, "Test Project") + + # Remove manager from server users + server.write({"user_ids": [(3, self.manager.id)]}) + + # Manager should not be able to read project anymore + with self.assertRaises(AccessError): + manager_project.browse(self.project.id).read(["name"]) + + # Add manager to server managers + server.write({"manager_ids": [(4, self.manager.id)]}) + + # Manager should be able to read project again + self.assertEqual(manager_project.browse(self.project.id).name, "Test Project") diff --git a/addons/cetmix_tower_git/tests/test_remote.py b/addons/cetmix_tower_git/tests/test_remote.py new file mode 100644 index 0000000..f75bffe --- /dev/null +++ b/addons/cetmix_tower_git/tests/test_remote.py @@ -0,0 +1,462 @@ +from odoo.exceptions import AccessError + +from .common import CommonTest + + +class TestRemote(CommonTest): + """Test class for git remote.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create another manager for testing + cls.manager_2 = cls.Users.create( + { + "name": "Second Manager", + "login": "manager2", + "email": "manager2@test.com", + "groups_id": [(4, cls.env.ref("cetmix_tower_server.group_manager").id)], + } + ) + + # Create test project and source as root + cls.project = cls.GitProject.create( + { + "name": "Test Project", + } + ) + cls.source = cls.GitSource.create( + { + "name": "Test Source", + "git_project_id": cls.project.id, + } + ) + cls.repo_cetmix_tower = cls.Repo.create( + { + "name": "Cetmix Tower", + "url": "https://github.com/cetmix-test/cetmix-tower.git", + } + ) + cls.remote = cls.GitRemote.create( + { + "repo_id": cls.repo_cetmix_tower.id, + "source_id": cls.source.id, + "head_type": "branch", + "head": "main", + } + ) + cls.repo_test = cls.Repo.create( + { + "name": "Test Repository", + "url": "https://github.com/cetmix-test/test.git", + } + ) + + def test_user_access(self): + """Test that regular users have no access to git remotes""" + user_remote = self.GitRemote.with_user(self.user) + + # Test CRUD operations + with self.assertRaises(AccessError): + user_remote.create( + { + "repo_id": self.repo_test.id, + "url_protocol": "https", + "source_id": self.source.id, + "head": "main", + } + ) + with self.assertRaises(AccessError): + user_remote.search([("id", "=", self.remote.id)]) + with self.assertRaises(AccessError): + self.remote.with_user(self.user).write({"head": "dev"}) + with self.assertRaises(AccessError): + self.remote.with_user(self.user).unlink() + + def test_manager_read_access(self): + """Test manager read access rules""" + manager_remote = self.GitRemote.with_user(self.manager) + + # Manager not in project user_ids or manager_ids - should not read + self.assertFalse(manager_remote.search([("id", "=", self.remote.id)])) + + # Add manager to project user_ids - should read + self.project.write({"user_ids": [(4, self.manager.id)]}) + remote = manager_remote.search([("id", "=", self.remote.id)]) + self.assertTrue(remote) + self.assertEqual(remote.head, "main") + + # Remove from user_ids, add to manager_ids - should read + self.project.write( + {"user_ids": [(3, self.manager.id)], "manager_ids": [(4, self.manager.id)]} + ) + remote = manager_remote.search([("id", "=", self.remote.id)]) + self.assertTrue(remote.exists()) + + def test_manager_write_access(self): + """Test manager write/create access rules""" + manager_remote = self.GitRemote.with_user(self.manager) + + # Create project as manager - should be added to manager_ids automatically + project = self.GitProject.with_user(self.manager).create( + { + "name": "Manager Project", + } + ) + source = self.GitSource.with_user(self.manager).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/list/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 new file mode 100644 index 0000000..05db569 --- /dev/null +++ b/addons/cetmix_tower_git/tests/test_repo.py @@ -0,0 +1,84 @@ +from odoo.exceptions import ValidationError + +from .common import CommonTest + + +class TestRepo(CommonTest): + """Test class for git repository.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_repo_create_from_url_https_success(self): + """Test if repository is created correctly""" + # -- 1 -- + # Valid HTTPS URL + repo = self.Repo.create( + { + "url": "https://github.com/memes-demo/doge-memes.git", + } + ) + repo.invalidate_recordset() + + self.assertEqual(repo.name, "github.com/memes-demo/doge-memes") + self.assertEqual(repo.host, "github.com") + self.assertEqual(repo.owner_id.name, "memes-demo") + self.assertEqual(repo.provider, "github") + self.assertEqual(repo.is_private, False) + self.assertEqual(repo.url_ssh, "git@github.com:memes-demo/doge-memes.git") + self.assertEqual(repo.url_git, "git://github.com/memes-demo/doge-memes.git") + + def test_repo_create_from_url_ssh_success(self): + """Test if repository is created correctly""" + # -- 1 -- + # Valid SSH URL + repo = self.Repo.create( + { + "url": "git@gitlab.com:chad-guy/chad-guy.git", + } + ) + repo.invalidate_recordset() + + self.assertEqual(repo.name, "gitlab.com/chad-guy/chad-guy") + self.assertEqual(repo.host, "gitlab.com") + self.assertEqual(repo.owner_id.name, "chad-guy") + self.assertEqual(repo.provider, "gitlab") + self.assertEqual(repo.is_private, False) + self.assertEqual(repo.url, "https://gitlab.com/chad-guy/chad-guy.git") + self.assertEqual(repo.url_git, "git://gitlab.com/chad-guy/chad-guy.git") + + def test_repo_create_from_url_git_success(self): + """Test if repository is created correctly""" + # -- 1 -- + # Valid GIT URL + repo = self.Repo.create( + { + "url": "git://bitbucket.com/much-pepe/pepe-memes.git", + } + ) + self.assertEqual(repo.name, "bitbucket.com/much-pepe/pepe-memes") + self.assertEqual(repo.host, "bitbucket.com") + self.assertEqual(repo.owner_id.name, "much-pepe") + self.assertEqual(repo.provider, "bitbucket") + self.assertEqual(repo.is_private, False) + self.assertEqual(repo.url_ssh, "git@bitbucket.com:much-pepe/pepe-memes.git") + self.assertEqual(repo.url, "https://bitbucket.com/much-pepe/pepe-memes.git") + + def test_repo_create_from_url_fails(self): + """Test if repository creation fails with invalid URLs""" + + # Invalid URL 1 + with self.assertRaises(ValidationError): + self.Repo.create( + { + "url": "something.com", + } + ) + # Invalid URL 2 + with self.assertRaises(ValidationError): + self.Repo.create( + { + "url": "random string", + } + ) diff --git a/addons/cetmix_tower_git/tests/test_server.py b/addons/cetmix_tower_git/tests/test_server.py new file mode 100644 index 0000000..473b825 --- /dev/null +++ b/addons/cetmix_tower_git/tests/test_server.py @@ -0,0 +1,416 @@ +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, + "skip_host_key": True, + } + ) + ) + + # 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 new file mode 100644 index 0000000..de67eef --- /dev/null +++ b/addons/cetmix_tower_git/tests/test_source.py @@ -0,0 +1,226 @@ +from odoo.exceptions import AccessError + +from .common import CommonTest + + +class TestSource(CommonTest): + """Test class for git source.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create another manager for testing + cls.manager_2 = cls.Users.create( + { + "name": "Second Manager", + "login": "manager2", + "email": "manager2@test.com", + "groups_id": [(4, cls.env.ref("cetmix_tower_server.group_manager").id)], + } + ) + + # Create test project and source as root + cls.project = cls.GitProject.create( + { + "name": "Test Project", + } + ) + cls.source = cls.GitSource.create( + { + "name": "Test Source", + "git_project_id": cls.project.id, + } + ) + + def test_user_access(self): + """Test that regular users have no access to git sources""" + user_source = self.GitSource.with_user(self.user) + + # Test CRUD operations + with self.assertRaises(AccessError): + user_source.create( + { + "name": "New Source", + "git_project_id": self.project.id, + } + ) + with self.assertRaises(AccessError): + user_source.browse(self.source.id).read(["name"]) + with self.assertRaises(AccessError): + user_source.browse(self.source.id).write({"name": "Updated Name"}) + with self.assertRaises(AccessError): + user_source.browse(self.source.id).unlink() + + def test_manager_read_access(self): + """Test manager read access rules""" + manager_source = self.GitSource.with_user(self.manager) + + # Manager not in project user_ids or manager_ids - should not read + with self.assertRaises(AccessError): + manager_source.browse(self.source.id).read(["name"]) + + # Add manager to project user_ids - should read + self.project.write({"user_ids": [(4, self.manager.id)]}) + self.assertEqual(manager_source.browse(self.source.id).name, "Test Source") + + # Remove from user_ids, add to manager_ids - should read + self.project.write( + {"user_ids": [(3, self.manager.id)], "manager_ids": [(4, self.manager.id)]} + ) + self.assertEqual(manager_source.browse(self.source.id).name, "Test Source") + + def test_manager_write_access(self): + """Test manager write/create access rules""" + manager_source = self.GitSource.with_user(self.manager) + + # Create project as manager - should be added to manager_ids automatically + project = self.GitProject.with_user(self.manager).create( + { + "name": "Manager Project", + } + ) + self.assertIn(self.manager, project.manager_ids) + + # Create source in own project - should succeed + new_source = manager_source.create( + { + "name": "New Source", + "git_project_id": project.id, + } + ) + self.assertTrue(new_source.exists()) + + # Write to own source - should succeed + new_source.write({"name": "Updated Name"}) + self.assertEqual(new_source.name, "Updated Name") + + # Write to other's source - should fail + with self.assertRaises(AccessError): + manager_source.browse(self.source.id).write({"name": "Updated Name"}) + + def test_manager_unlink_access(self): + """Test manager unlink access rules""" + # Create project and source as manager_2 + project = self.GitProject.with_user(self.manager_2).create( + { + "name": "Manager 2 Project", + } + ) + source = self.GitSource.with_user(self.manager_2).create( + { + "name": "Source to Delete", + "git_project_id": project.id, + } + ) + manager_source = self.GitSource.with_user(self.manager) + + # Try delete as different manager - should fail even if added to manager_ids + project.write({"manager_ids": [(4, self.manager.id)]}) + with self.assertRaises(AccessError): + manager_source.browse(source.id).unlink() + + # Create source as manager and try delete - should succeed + own_source = manager_source.create( + { + "name": "Own Source", + "git_project_id": project.id, + } + ) + self.assertTrue(own_source.exists()) + own_source.unlink() + self.assertFalse(own_source.exists()) + + def test_root_access(self): + """Test root access rules""" + root_source = self.GitSource.with_user(self.root) + + # Create + new_source = root_source.create( + { + "name": "Root Source", + "git_project_id": self.project.id, + } + ) + self.assertTrue(new_source.exists()) + + # Read + self.assertEqual(root_source.browse(self.source.id).name, "Test Source") + + # Write + root_source.browse(self.source.id).write({"name": "Updated by Root"}) + self.assertEqual(self.source.name, "Updated by Root") + + # Delete + new_source.unlink() + self.assertFalse(new_source.exists()) + + def test_source_git_aggregator_prepare_record(self): + """Test if source prepare record method works correctly.""" + + # -- 1 -- + # Source 1 + expected_result = { + "remotes": { + "remote_1": "https://github.com/cetmix-test/cetmix-tower-test.git", + "remote_2": "https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@my.gitlab.com/cetmix-test/cetmix-tower-test.git", + "remote_3": "git@my.gitlab.com:cetmix-test/cetmix-tower-test.git", + }, + "merges": [ + {"remote": "remote_1", "ref": "refs/pull/123/head"}, + {"remote": "remote_2", "ref": "main"}, + {"remote": "remote_3", "ref": "10000000"}, + ], + "target": "remote_1", + } + prepared_result = self.git_source_1._git_aggregator_prepare_record() + self.assertEqual( + prepared_result, expected_result, "Prepared result is not correct" + ) + + def test_manager_server_based_access(self): + """Test manager access to sources through server relationships""" + manager_source = self.GitSource.with_user(self.manager) + + # Create a server where manager is a user + server = self.Server.create( + { + "name": "Test Server", + "ip_v4_address": "localhost", + "ssh_username": "admin", + "ssh_password": "password", + "os_id": self.os_debian_10.id, + "user_ids": [(4, self.manager.id)], + } + ) + + # Link project to server + file = self.File.create( + { + "name": "test_file", + "server_id": server.id, + } + ) + self.GitProjectRel.create( + { + "server_id": server.id, + "file_id": file.id, + "git_project_id": self.project.id, + "project_format": "git_aggregator", + } + ) + + # Manager should be able to read source through server relationship + self.assertEqual(manager_source.browse(self.source.id).name, "Test Source") + + # Remove manager from server users + server.write({"user_ids": [(3, self.manager.id)]}) + + # Manager should not be able to read source anymore + with self.assertRaises(AccessError): + manager_source.browse(self.source.id).read(["name"]) + + # Add manager to server managers + server.write({"manager_ids": [(4, self.manager.id)]}) + + # Manager should be able to read source again + self.assertEqual(manager_source.browse(self.source.id).name, "Test Source") diff --git a/addons/cetmix_tower_git/tools/git_aggregator.py b/addons/cetmix_tower_git/tools/git_aggregator.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..d2dff20 --- /dev/null +++ b/addons/cetmix_tower_git/views/cx_tower_file_template_views.xml @@ -0,0 +1,16 @@ + + + + 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 new file mode 100644 index 0000000..bbf23f4 --- /dev/null +++ b/addons/cetmix_tower_git/views/cx_tower_file_views.xml @@ -0,0 +1,13 @@ + + + + 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 new file mode 100644 index 0000000..62d66ed --- /dev/null +++ b/addons/cetmix_tower_git/views/cx_tower_git_project_views.xml @@ -0,0 +1,291 @@ + + + + + cx.tower.git.project.list + 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 + cetmix_tower_git_repositories + cx.tower.git.repo + list,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 new file mode 100644 index 0000000..b6dcd79 --- /dev/null +++ b/addons/cetmix_tower_git/views/cx_tower_git_source_views.xml @@ -0,0 +1,93 @@ + + + + + cx.tower.git.source.list + 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 new file mode 100644 index 0000000..851ac2a --- /dev/null +++ b/addons/cetmix_tower_git/views/cx_tower_plan_line_view.xml @@ -0,0 +1,36 @@ + + + + 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 new file mode 100644 index 0000000..9caf64e --- /dev/null +++ b/addons/cetmix_tower_git/views/cx_tower_server_view.xml @@ -0,0 +1,43 @@ + + + + cx.tower.server.view.form.shortcuts + cx.tower.server + + + + + + + + + + + +