Wipe addons/: full reset for clean re-upload

This commit is contained in:
Tower Deploy
2026-04-27 11:20:53 +03:00
parent 2cf3b5185d
commit 9bb80002c8
363 changed files with 0 additions and 112641 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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