From 203936cbd8d4d0fec3c2678e097c29e5f8febd72 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Tue, 29 Jul 2025 11:28:12 +0200 Subject: [PATCH 01/11] feature: work in progress --- rhodecode/api/views/repo_api.py | 4 +++- rhodecode/model/repo.py | 20 +++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/rhodecode/api/views/repo_api.py b/rhodecode/api/views/repo_api.py index 593a1fa6..7cf5d939 100644 --- a/rhodecode/api/views/repo_api.py +++ b/rhodecode/api/views/repo_api.py @@ -1359,6 +1359,8 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")): _perms = ("repository.admin",) validate_repo_permissions(apiuser, repoid, repo, _perms) + deprecation_warning = "NOTE: 'forks' option will be deprecated." + try: handle_forks = Optional.extract(forks) _forks_msg = "" @@ -1378,7 +1380,7 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")): ScmModel().mark_for_invalidation(repo_name, delete=True) Session().commit() - return {"msg": f"Deleted repository `{repo_name}`{_forks_msg}", "success": True} + return {"msg": f"Deleted repository `{repo_name}`{_forks_msg}; {deprecation_warning}", "success": True} except Exception: log.exception("Exception occurred while trying to delete repo") raise JSONRPCError(f"failed to delete repository `{repo_name}`") diff --git a/rhodecode/model/repo.py b/rhodecode/model/repo.py index 301997e6..c9079e26 100644 --- a/rhodecode/model/repo.py +++ b/rhodecode/model/repo.py @@ -15,7 +15,7 @@ # This program is dual-licensed. If you wish to learn more about the # RhodeCode Enterprise Edition, including its added features, Support services, # and proprietary license terms, please see https://rhodecode.com/licenses/ - +import itertools import os import re import shutil @@ -69,7 +69,6 @@ from rhodecode.model.db import ( UserLog, ) from rhodecode.model.permission import PermissionModel -from rhodecode.model.settings import VcsSettingsModel log = logging.getLogger(__name__) @@ -769,6 +768,10 @@ class RepoModel(BaseModel): if not repo: return False + if not forks: # left for backward compatibility + is_private = repo.private + forks = "delete" if is_private else "detach" + if forks == "detach": for r in repo.forks: r.fork = None @@ -782,15 +785,10 @@ class RepoModel(BaseModel): # check for pull requests pr_sources = repo.pull_requests_source pr_targets = repo.pull_requests_target - if pull_requests != "delete" and (pr_sources or pr_targets): - raise AttachedPullRequestsError() - - artifacts_objs = repo.artifacts - if artifacts == "delete": - for a in artifacts_objs: - self.sa.delete(a) - elif [a for a in artifacts_objs]: - raise AttachedArtifactsError() + artifacts = repo.artifacts + # FIXME: continue here + for pr in itertools.chain(pr_sources, pr_targets, artifacts): + self.sa.delete(pr) old_repo_dict = repo.get_dict() if call_events: From d4cb542581be83330fe6e00b06c034fa29193d65 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Wed, 30 Jul 2025 12:41:58 +0200 Subject: [PATCH 02/11] feature: implements repo deletion mechanism --- rhodecode/api/views/repo_api.py | 3 +- .../views/repo_settings_advanced.py | 25 +++++---- rhodecode/lib/utils.py | 2 +- rhodecode/model/repo.py | 52 ++++++++++++------- rhodecode/tests/fixtures/rc_fixture.py | 2 +- rhodecode/tests/models/test_repos.py | 4 +- 6 files changed, 56 insertions(+), 32 deletions(-) diff --git a/rhodecode/api/views/repo_api.py b/rhodecode/api/views/repo_api.py index 7cf5d939..f624f608 100644 --- a/rhodecode/api/views/repo_api.py +++ b/rhodecode/api/views/repo_api.py @@ -1371,8 +1371,9 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")): _forks_msg = " " + f"Deleted {len(_forks)} forks" elif _forks: raise JSONRPCError(f"Cannot delete `{repo.repo_name}` it still contains attached forks") + old_data = repo.get_api_data() - RepoModel().delete(repo, forks=forks) + RepoModel().delete(repo, forks=RepoModel.ForkAction(handle_forks)) repo = audit_logger.RepoWrap(repo_id=None, repo_name=repo.repo_name) diff --git a/rhodecode/apps/repository/views/repo_settings_advanced.py b/rhodecode/apps/repository/views/repo_settings_advanced.py index d6d18cd4..7a43705a 100644 --- a/rhodecode/apps/repository/views/repo_settings_advanced.py +++ b/rhodecode/apps/repository/views/repo_settings_advanced.py @@ -124,11 +124,8 @@ class RepoSettingsAdvancedView(RepoAppView): because of attached forks or other errors. """ _ = self.request.translate - handle_forks = self.request.POST.get("forks", None) - if handle_forks == "detach_forks": - handle_forks = "detach" - elif handle_forks == "delete_forks": - handle_forks = "delete" + + fork_action = self._get_fork_action() repo_advanced_url = h.route_path("edit_repo_advanced", repo_name=self.db_repo_name, _anchor="advanced-delete") try: @@ -137,13 +134,13 @@ class RepoSettingsAdvancedView(RepoAppView): delete_cache = True self._invalidate_remote_cache(delete=delete_cache) - RepoModel().delete(self.db_repo, forks=handle_forks) + RepoModel().delete(self.db_repo, forks=fork_action) _forks = self.db_repo.forks.count() - if _forks and handle_forks: - if handle_forks == "detach_forks": + if _forks and fork_action: + if fork_action is RepoModel.ForkAction.DETACH: h.flash(_("Detached %s forks") % _forks, category="success") - elif handle_forks == "delete_forks": + elif fork_action is RepoModel.ForkAction.DELETE: h.flash(_("Deleted %s forks") % _forks, category="success") repo = audit_logger.RepoWrap(repo_id=None, repo_name=self.db_repo.repo_name) @@ -199,6 +196,16 @@ class RepoSettingsAdvancedView(RepoAppView): raise HTTPFound(h.route_path("home")) + def _get_fork_action(self): + fork_action = self.request.POST.get("forks", None) + if fork_action == "detach_forks": + return RepoModel.ForkAction.DETACH + + if fork_action == "delete_forks": + return RepoModel.ForkAction.DELETE + + return RepoModel.ForkAction.UNKNOWN + def _invalidate_remote_cache(self, delete: bool): log.debug(f"Invalidating remote cache, for repository: {self.db_repo_name}") scm_repo = self.db_repo.scm_instance(cache=False) diff --git a/rhodecode/lib/utils.py b/rhodecode/lib/utils.py index bd85083a..ef258d21 100644 --- a/rhodecode/lib/utils.py +++ b/rhodecode/lib/utils.py @@ -688,7 +688,7 @@ def repo2db_cleanup(skip_repos=None, skip_groups=None): if not instance: log.debug("Removing non-existing repository found in db `%s`", db_repo_name) try: - RepoModel(sa).delete(db_repo, forks="detach", fs_remove=False, call_events=False) + RepoModel(sa).delete(db_repo, forks=RepoModel.ForkAction.DETACH, fs_remove=False, call_events=False) sa.commit() removed.append(db_repo_name) except Exception: diff --git a/rhodecode/model/repo.py b/rhodecode/model/repo.py index c9079e26..f7a033db 100644 --- a/rhodecode/model/repo.py +++ b/rhodecode/model/repo.py @@ -23,6 +23,8 @@ import time import logging import traceback import datetime +from enum import StrEnum, auto +from typing import Optional from sqlalchemy.orm import aliased from zope.cachedescriptors.property import Lazy as LazyProperty @@ -76,6 +78,15 @@ log = logging.getLogger(__name__) class RepoModel(BaseModel): cls = Repository + class ForkAction(StrEnum): + DELETE = auto() + DETACH = auto() + UNKNOWN = auto() + + @classmethod + def _missing_(cls, value): + return cls(cls.UNKNOWN) + def _get_user_group(self, users_group): return self._get_instance(UserGroup, users_group, callback=UserGroup.get_by_group_name) @@ -749,17 +760,20 @@ class RepoModel(BaseModel): raise def delete( - self, repo, forks=None, pull_requests=None, artifacts=None, fs_remove=True, cur_user=None, call_events=True - ): + self, + repo: Repository, + forks: ForkAction = None, # left for backward compatibility, do not use it + fs_remove: bool = True, + cur_user: User = None, + call_events: bool = True, + ) -> bool: """ Delete given repository, forks parameter defines what do do with attached forks. Throws AttachedForksError if deleted repo has attached forks :param repo: - :param forks: str 'delete' or 'detach' - :param pull_requests: str 'delete' or None - :param artifacts: str 'delete' or None + :param forks: ForkAction 'delete' or 'detach', NOTE: left for backward compatibility, do not use it :param fs_remove: remove(archive) repo from filesystem """ if not cur_user: @@ -768,27 +782,21 @@ class RepoModel(BaseModel): if not repo: return False - if not forks: # left for backward compatibility + if forks is None or forks is self.ForkAction.UNKNOWN: is_private = repo.private - forks = "delete" if is_private else "detach" + forks = self.ForkAction.DELETE if is_private else self.ForkAction.DETACH - if forks == "detach": + if forks is self.ForkAction.DETACH: for r in repo.forks: r.fork = None self.sa.add(r) - elif forks == "delete": + elif forks is self.ForkAction.DELETE: for r in repo.forks: - self.delete(r, forks="delete") - elif [f for f in repo.forks]: + self.delete(r, forks=self.ForkAction.DELETE) + elif repo.forks.first() is not None: raise AttachedForksError() - # check for pull requests - pr_sources = repo.pull_requests_source - pr_targets = repo.pull_requests_target - artifacts = repo.artifacts - # FIXME: continue here - for pr in itertools.chain(pr_sources, pr_targets, artifacts): - self.sa.delete(pr) + self._delete_repo_related_assets(repo) old_repo_dict = repo.get_dict() if call_events: @@ -815,6 +823,14 @@ class RepoModel(BaseModel): return True + def _delete_repo_related_assets(self, repo): + pr_sources = repo.pull_requests_source + pr_targets = repo.pull_requests_target + artifacts = repo.artifacts + logs = repo.logs + for obj_to_remove in itertools.chain(pr_sources, pr_targets, artifacts, logs): + self.sa.delete(obj_to_remove) + def grant_user_permission(self, repo, user, perm): """ Grant permission for user on given repository, or update existing one diff --git a/rhodecode/tests/fixtures/rc_fixture.py b/rhodecode/tests/fixtures/rc_fixture.py index 9488d086..91fd16fb 100644 --- a/rhodecode/tests/fixtures/rc_fixture.py +++ b/rhodecode/tests/fixtures/rc_fixture.py @@ -294,7 +294,7 @@ class Fixture(object): return r def destroy_repo(self, repo_name, **kwargs): - RepoModel().delete(repo_name, pull_requests="delete", artifacts="delete", **kwargs) + RepoModel().delete(repo_name, **kwargs) Session().commit() def destroy_repo_on_filesystem(self, repo_name): diff --git a/rhodecode/tests/models/test_repos.py b/rhodecode/tests/models/test_repos.py index 5cef0b42..389aaabc 100644 --- a/rhodecode/tests/models/test_repos.py +++ b/rhodecode/tests/models/test_repos.py @@ -61,7 +61,7 @@ class TestRepoModel(object): fork_of_fork = backend.create_fork() Session().commit() - RepoModel().delete(repo=repo, forks="delete") + RepoModel().delete(repo=repo, forks=RepoModel.ForkAction.DELETE) Session().commit() assert Repository.get_by_repo_name(repo_name=repo.repo_name) is None @@ -78,7 +78,7 @@ class TestRepoModel(object): fork_of_fork = backend.create_fork() Session().commit() - RepoModel().delete(repo=repo, forks="detach") + RepoModel().delete(repo=repo, forks=RepoModel.ForkAction.DETACH) Session().commit() assert Repository.get_by_repo_name(repo_name=repo.repo_name) is None From 03fc3ab5704edafc114c2c31aca4561ba7bed8c8 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Thu, 31 Jul 2025 09:08:19 +0200 Subject: [PATCH 03/11] feature: adds warning message to FE --- rhodecode/i18n/rhodecode.pot | 4 +- rhodecode/model/repo.py | 2 +- .../admin/repos/repo_edit_advanced.mako | 41 +++++++++---------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/rhodecode/i18n/rhodecode.pot b/rhodecode/i18n/rhodecode.pot index 5822ba2c..bdcf3c3e 100644 --- a/rhodecode/i18n/rhodecode.pot +++ b/rhodecode/i18n/rhodecode.pot @@ -7567,8 +7567,8 @@ msgstr "" msgid "Delete this repository" msgstr "" -#: rhodecode/templates/admin/repos/repo_edit_advanced.mako:242 -msgid "This repository will be renamed in a special way in order to make it inaccessible to RhodeCode Enterprise and its VCS systems. If you need to fully delete it from the file system, please do it manually, or with rhodecode-cleanup-repos command available in rhodecode-tools." +#: rhodecode/templates/admin/repos/repo_edit_advanced.mako:237 +msgid "Warning: You are about to delete the repository. This action cannot be undone!" msgstr "" #: rhodecode/templates/admin/repos/repo_edit_advanced.mako:276 diff --git a/rhodecode/model/repo.py b/rhodecode/model/repo.py index f7a033db..e63932d5 100644 --- a/rhodecode/model/repo.py +++ b/rhodecode/model/repo.py @@ -762,7 +762,7 @@ class RepoModel(BaseModel): def delete( self, repo: Repository, - forks: ForkAction = None, # left for backward compatibility, do not use it + forks: ForkAction = None, fs_remove: bool = True, cur_user: User = None, call_events: bool = True, diff --git a/rhodecode/templates/admin/repos/repo_edit_advanced.mako b/rhodecode/templates/admin/repos/repo_edit_advanced.mako index ffdc45fe..1ddfca2d 100644 --- a/rhodecode/templates/admin/repos/repo_edit_advanced.mako +++ b/rhodecode/templates/admin/repos/repo_edit_advanced.mako @@ -200,49 +200,53 @@
${h.secure_form(h.route_path('edit_repo_advanced_delete', repo_name=c.repo_name), request=request)} + <% forks = c.rhodecode_db_repo.forks.count() %> + % if forks: + % endif <% attached_prs = len(c.rhodecode_db_repo.pull_requests_source + c.rhodecode_db_repo.pull_requests_target) %> - % if c.rhodecode_db_repo.pull_requests_source or c.rhodecode_db_repo.pull_requests_target: + % if attached_prs: - - - % endif <% attached_artifacts = len(c.rhodecode_db_repo.artifacts) %> % if attached_artifacts: - - - % endif + + +
- ${_ungettext('This repository has %s fork.', 'This repository has %s forks.', c.rhodecode_db_repo.forks.count()) % c.rhodecode_db_repo.forks.count()} + ${_ungettext('This repository has %s fork.', 'This repository has %s forks.', forks) % forks} - %if c.rhodecode_db_repo.forks.count(): + %if forks: %endif - %if c.rhodecode_db_repo.forks.count(): + %if forks: %endif
+ ${_ungettext('This repository has %s attached pull request.', 'This repository has %s attached pull requests.', attached_prs) % attached_prs} -
-
- ${_('Consider to archive this repository instead.')}
+ ${_ungettext('This repository has %s attached artifact.', 'This repository has %s attached artifacts.', attached_artifacts) % attached_artifacts} -
-
- ${_('Consider to archive this repository instead.')}
+ % if attached_artifacts or attached_prs or forks: +
+
+ % endif + + ${_('Warning: You are about to delete the repository. This action cannot be undone!')} + +
@@ -254,11 +258,6 @@ ${_('Delete this repository')}
-
- - ${_('This repository will be renamed in a special way in order to make it inaccessible to RhodeCode Enterprise and its VCS systems. If you need to fully delete it from the file system, please do it manually, or with rhodecode-cleanup-repos command available in rhodecode-tools.')} - -
${h.end_form()} From d392589e5c81be2b4112d340eb2077336ec93de4 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Thu, 31 Jul 2025 15:14:23 +0200 Subject: [PATCH 04/11] feature: makes forks and PR's link to clearly show admin what will be done --- rhodecode/api/views/repo_api.py | 4 +- .../views/repo_settings_advanced.py | 41 +++++------ rhodecode/i18n/en/LC_MESSAGES/rhodecode.po | 6 +- rhodecode/i18n/rhodecode.pot | 2 +- rhodecode/lib/utils.py | 5 +- rhodecode/model/repo.py | 71 ++++++++++++------- .../admin/repos/repo_edit_advanced.mako | 40 ++++------- rhodecode/tests/models/test_repos.py | 6 +- 8 files changed, 92 insertions(+), 83 deletions(-) diff --git a/rhodecode/api/views/repo_api.py b/rhodecode/api/views/repo_api.py index f624f608..4da17e7b 100644 --- a/rhodecode/api/views/repo_api.py +++ b/rhodecode/api/views/repo_api.py @@ -50,7 +50,7 @@ from rhodecode.model.comment import CommentsModel from rhodecode.model.db import Session, ChangesetStatus, RepositoryField, Repository, RepoGroup, ChangesetComment from rhodecode.model.permission import PermissionModel from rhodecode.model.pull_request import PullRequestModel -from rhodecode.model.repo import RepoModel +from rhodecode.model.repo import RepoModel, ForksAction from rhodecode.model.scm import ScmModel, RepoList from rhodecode.model.settings import SettingsModel, VcsSettingsModel from rhodecode.model import validation_schema @@ -1373,7 +1373,7 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")): raise JSONRPCError(f"Cannot delete `{repo.repo_name}` it still contains attached forks") old_data = repo.get_api_data() - RepoModel().delete(repo, forks=RepoModel.ForkAction(handle_forks)) + RepoModel().delete(repo, forks_action=ForksAction(handle_forks)) repo = audit_logger.RepoWrap(repo_id=None, repo_name=repo.repo_name) diff --git a/rhodecode/apps/repository/views/repo_settings_advanced.py b/rhodecode/apps/repository/views/repo_settings_advanced.py index 7a43705a..f47934ea 100644 --- a/rhodecode/apps/repository/views/repo_settings_advanced.py +++ b/rhodecode/apps/repository/views/repo_settings_advanced.py @@ -31,7 +31,7 @@ from rhodecode.lib.utils2 import safe_int from rhodecode.lib.vcs import RepositoryError from rhodecode.model.db import Session, UserFollowing, User, Repository from rhodecode.model.permission import PermissionModel -from rhodecode.model.repo import RepoModel +from rhodecode.model.repo import RepoModel, ForksAction from rhodecode.model.scm import ScmModel log = logging.getLogger(__name__) @@ -56,6 +56,9 @@ class RepoSettingsAdvancedView(RepoAppView): c = self.load_default_context() c.active = "advanced" + c.fork_links = self._resolve_repo_forks_links() + c.pr_link = h.route_path("pullrequest_show_all", repo_name=self.db_repo_name) + c.default_user_id = User.get_default_user_id() c.in_public_journal = ( UserFollowing.query() @@ -80,6 +83,14 @@ class RepoSettingsAdvancedView(RepoAppView): return self._get_template_context(c) + def _resolve_repo_forks_links(self): + def resolve_fork_link(repo): + for fork in repo.forks: + yield h.route_path("repo_summary", repo_name=fork.repo_name) + yield from resolve_fork_link(fork) + + return list(resolve_fork_link(self.db_repo)) + @LoginRequired() @HasRepoPermissionAnyDecorator("repository.admin") @CSRFRequired() @@ -125,8 +136,6 @@ class RepoSettingsAdvancedView(RepoAppView): """ _ = self.request.translate - fork_action = self._get_fork_action() - repo_advanced_url = h.route_path("edit_repo_advanced", repo_name=self.db_repo_name, _anchor="advanced-delete") try: old_data = self.db_repo.get_api_data() @@ -134,14 +143,16 @@ class RepoSettingsAdvancedView(RepoAppView): delete_cache = True self._invalidate_remote_cache(delete=delete_cache) - RepoModel().delete(self.db_repo, forks=fork_action) + delete_res = RepoModel().delete(self.db_repo) - _forks = self.db_repo.forks.count() - if _forks and fork_action: - if fork_action is RepoModel.ForkAction.DETACH: - h.flash(_("Detached %s forks") % _forks, category="success") - elif fork_action is RepoModel.ForkAction.DELETE: - h.flash(_("Deleted %s forks") % _forks, category="success") + fork_action = delete_res.fork_action_result.action + fork_cnt = delete_res.fork_action_result.fork_count + + if fork_action is not None: + if fork_action is ForksAction.DETACH: + h.flash(_("Detached %s forks") % fork_cnt, category="success") + elif fork_action is ForksAction.DELETE: + h.flash(_("Deleted %s forks") % fork_cnt, category="success") repo = audit_logger.RepoWrap(repo_id=None, repo_name=self.db_repo.repo_name) audit_logger.store_web( @@ -196,16 +207,6 @@ class RepoSettingsAdvancedView(RepoAppView): raise HTTPFound(h.route_path("home")) - def _get_fork_action(self): - fork_action = self.request.POST.get("forks", None) - if fork_action == "detach_forks": - return RepoModel.ForkAction.DETACH - - if fork_action == "delete_forks": - return RepoModel.ForkAction.DELETE - - return RepoModel.ForkAction.UNKNOWN - def _invalidate_remote_cache(self, delete: bool): log.debug(f"Invalidating remote cache, for repository: {self.db_repo_name}") scm_repo = self.db_repo.scm_instance(cache=False) diff --git a/rhodecode/i18n/en/LC_MESSAGES/rhodecode.po b/rhodecode/i18n/en/LC_MESSAGES/rhodecode.po index 27cbe8eb..969a8763 100644 --- a/rhodecode/i18n/en/LC_MESSAGES/rhodecode.po +++ b/rhodecode/i18n/en/LC_MESSAGES/rhodecode.po @@ -2583,10 +2583,10 @@ msgstr "" msgid "Confirm to delete this repository" msgstr "" -#: rhodecode/templates/admin/repos/repo_edit.html:310 +#: rhodecode/templates/admin/repos/repo_edit.html:208 #, python-format -msgid "this repository has %s fork" -msgid_plural "this repository has %s forks" +msgid "Following fork will be %s" +msgid_plural "Following forks will be %s" msgstr[0] "" msgstr[1] "" diff --git a/rhodecode/i18n/rhodecode.pot b/rhodecode/i18n/rhodecode.pot index bdcf3c3e..26318954 100644 --- a/rhodecode/i18n/rhodecode.pot +++ b/rhodecode/i18n/rhodecode.pot @@ -7567,7 +7567,7 @@ msgstr "" msgid "Delete this repository" msgstr "" -#: rhodecode/templates/admin/repos/repo_edit_advanced.mako:237 +#: rhodecode/templates/admin/repos/repo_edit_advanced.mako:242 msgid "Warning: You are about to delete the repository. This action cannot be undone!" msgstr "" diff --git a/rhodecode/lib/utils.py b/rhodecode/lib/utils.py index ef258d21..b3116434 100644 --- a/rhodecode/lib/utils.py +++ b/rhodecode/lib/utils.py @@ -57,7 +57,6 @@ from rhodecode.model import meta from rhodecode.model.db import Repository, User, RhodeCodeUi, UserLog, RepoGroup, UserGroup from rhodecode.model.meta import Session - log = logging.getLogger(__name__) REMOVED_REPO_PAT = re.compile(r"rm__\d{8}_\d{6}_\d{6}__.*") @@ -665,7 +664,7 @@ def repo2db_mapper(initial_repo_list, force_hooks_rebuild=False): def repo2db_cleanup(skip_repos=None, skip_groups=None): - from rhodecode.model.repo import RepoModel + from rhodecode.model.repo import RepoModel, ForksAction from rhodecode.model.repo_group import RepoGroupModel sa = meta.Session() @@ -688,7 +687,7 @@ def repo2db_cleanup(skip_repos=None, skip_groups=None): if not instance: log.debug("Removing non-existing repository found in db `%s`", db_repo_name) try: - RepoModel(sa).delete(db_repo, forks=RepoModel.ForkAction.DETACH, fs_remove=False, call_events=False) + RepoModel(sa).delete(db_repo, forks_action=ForksAction.DETACH, fs_remove=False, call_events=False) sa.commit() removed.append(db_repo_name) except Exception: diff --git a/rhodecode/model/repo.py b/rhodecode/model/repo.py index e63932d5..ef65b9f5 100644 --- a/rhodecode/model/repo.py +++ b/rhodecode/model/repo.py @@ -23,8 +23,8 @@ import time import logging import traceback import datetime +from dataclasses import dataclass, field from enum import StrEnum, auto -from typing import Optional from sqlalchemy.orm import aliased from zope.cachedescriptors.property import Lazy as LazyProperty @@ -75,18 +75,31 @@ from rhodecode.model.permission import PermissionModel log = logging.getLogger(__name__) +class ForksAction(StrEnum): + DELETE = auto() + DETACH = auto() + UNKNOWN = auto() + + @classmethod + def _missing_(cls, value): + return cls(cls.UNKNOWN) + + +@dataclass +class ForkActionResult: + fork_count: int = 0 + action: ForksAction = None + + +@dataclass +class DeleteRepoResult: + is_success: bool + fork_action_result: ForkActionResult = field(default_factory=ForkActionResult) + + class RepoModel(BaseModel): cls = Repository - class ForkAction(StrEnum): - DELETE = auto() - DETACH = auto() - UNKNOWN = auto() - - @classmethod - def _missing_(cls, value): - return cls(cls.UNKNOWN) - def _get_user_group(self, users_group): return self._get_instance(UserGroup, users_group, callback=UserGroup.get_by_group_name) @@ -762,37 +775,43 @@ class RepoModel(BaseModel): def delete( self, repo: Repository, - forks: ForkAction = None, + forks_action: ForksAction = None, # used only in API to enforce action fs_remove: bool = True, cur_user: User = None, call_events: bool = True, - ) -> bool: + ) -> DeleteRepoResult: """ - Delete given repository, forks parameter defines what do do with - attached forks. Throws AttachedForksError if deleted repo has attached - forks + Delete a given repository. + Throws AttachedForksError if a deleted repo has attached forks :param repo: - :param forks: ForkAction 'delete' or 'detach', NOTE: left for backward compatibility, do not use it + :param forks_action: + NOTE: used only in API to enforce action; + If the repository is private, then forks will be removed. + If the repository is public, then forks will be detached. + :param fs_remove: remove(archive) repo from filesystem """ - if not cur_user: - cur_user = getattr(get_current_rhodecode_user(), "username", None) repo = self._get_repo(repo) if not repo: - return False + return DeleteRepoResult(is_success=False) - if forks is None or forks is self.ForkAction.UNKNOWN: + if not cur_user: + cur_user = getattr(get_current_rhodecode_user(), "username", None) + + if forks_action is None or forks_action is ForksAction.UNKNOWN: is_private = repo.private - forks = self.ForkAction.DELETE if is_private else self.ForkAction.DETACH + forks_action = ForksAction.DELETE if is_private else ForksAction.DETACH - if forks is self.ForkAction.DETACH: + forks_cnt = repo.forks.count() + + if forks_action is ForksAction.DETACH: for r in repo.forks: r.fork = None self.sa.add(r) - elif forks is self.ForkAction.DELETE: + elif forks_action is ForksAction.DELETE: for r in repo.forks: - self.delete(r, forks=self.ForkAction.DELETE) + self.delete(r, forks_action=forks_action) elif repo.forks.first() is not None: raise AttachedForksError() @@ -821,7 +840,9 @@ class RepoModel(BaseModel): log.error(traceback.format_exc()) raise - return True + return DeleteRepoResult( + is_success=True, fork_action_result=ForkActionResult(action=forks_action, fork_count=forks_cnt) + ) def _delete_repo_related_assets(self, repo): pr_sources = repo.pull_requests_source diff --git a/rhodecode/templates/admin/repos/repo_edit_advanced.mako b/rhodecode/templates/admin/repos/repo_edit_advanced.mako index 1ddfca2d..dd920288 100644 --- a/rhodecode/templates/admin/repos/repo_edit_advanced.mako +++ b/rhodecode/templates/admin/repos/repo_edit_advanced.mako @@ -200,21 +200,19 @@
${h.secure_form(h.route_path('edit_repo_advanced_delete', repo_name=c.repo_name), request=request)} - <% forks = c.rhodecode_db_repo.forks.count() %> + <% forks = len(c.fork_links) %> + <% delete_forks = c.rhodecode_db_repo.private %> % if forks: - - % endif @@ -222,8 +220,10 @@ <% attached_prs = len(c.rhodecode_db_repo.pull_requests_source + c.rhodecode_db_repo.pull_requests_target) %> % if attached_prs: - % endif @@ -231,23 +231,11 @@ <% attached_artifacts = len(c.rhodecode_db_repo.artifacts) %> % if attached_artifacts: - % endif - - - -
- ${_ungettext('This repository has %s fork.', 'This repository has %s forks.', forks) % forks} + + ${_ungettext('Following fork will be %s:', 'Following forks will be %s:', forks) % ('deleted' if delete_forks else 'detached')} - %if forks: - - %endif - - %if forks: - - %endif +
- ${_ungettext('This repository has %s attached pull request.', 'This repository has %s attached pull requests.', attached_prs) % attached_prs} + + + ${_ungettext('This repository has %s attached pull request (open/close).', 'This repository has %s attached pull requests (open/close).', attached_prs) % attached_prs} +
+ ${_ungettext('This repository has %s attached artifact.', 'This repository has %s attached artifacts.', attached_artifacts) % attached_artifacts}
- % if attached_artifacts or attached_prs or forks: -
-
- % endif - - ${_('Warning: You are about to delete the repository. This action cannot be undone!')} - -
diff --git a/rhodecode/tests/models/test_repos.py b/rhodecode/tests/models/test_repos.py index 389aaabc..2c387baa 100644 --- a/rhodecode/tests/models/test_repos.py +++ b/rhodecode/tests/models/test_repos.py @@ -26,7 +26,7 @@ from rhodecode.lib.exceptions import AttachedForksError from rhodecode.lib.utils import make_db_config from rhodecode.model.db import Repository from rhodecode.model.meta import Session -from rhodecode.model.repo import RepoModel +from rhodecode.model.repo import RepoModel, ForksAction from rhodecode.model.scm import ScmModel @@ -61,7 +61,7 @@ class TestRepoModel(object): fork_of_fork = backend.create_fork() Session().commit() - RepoModel().delete(repo=repo, forks=RepoModel.ForkAction.DELETE) + RepoModel().delete(repo=repo, forks_action=ForksAction.DELETE) Session().commit() assert Repository.get_by_repo_name(repo_name=repo.repo_name) is None @@ -78,7 +78,7 @@ class TestRepoModel(object): fork_of_fork = backend.create_fork() Session().commit() - RepoModel().delete(repo=repo, forks=RepoModel.ForkAction.DETACH) + RepoModel().delete(repo=repo, forks_action=ForksAction.DETACH) Session().commit() assert Repository.get_by_repo_name(repo_name=repo.repo_name) is None From 6d2c7a56740235f95fe3a4c0a90ebe73b770ad17 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Fri, 1 Aug 2025 10:29:54 +0200 Subject: [PATCH 05/11] release notes: adds release notes --- docs/release-notes/release-notes-5.7.0.rst | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/release-notes/release-notes-5.7.0.rst diff --git a/docs/release-notes/release-notes-5.7.0.rst b/docs/release-notes/release-notes-5.7.0.rst new file mode 100644 index 00000000..f99153d6 --- /dev/null +++ b/docs/release-notes/release-notes-5.7.0.rst @@ -0,0 +1,35 @@ +|RCE| 5.7.0 |RNS| +----------------- + +Release Date +^^^^^^^^^^^^ + +- 2025-08-15 + +New Features +^^^^^^^^^^^^ +- squash: he pull request UI now includes a dropdown menu, allowing users to select "Squash" or "Close" when merging pull requests in Git and Mercurial repositories, instead of only having the "Merge" option. This feature is exclusive to the Enterprise Edition (EE) and must be enabled by an administrator. +- pull request: Introduced a new checkbox in the pull request UI, allowing users to select the option to close the branch after merging. This feature is exclusive to the Enterprise Edition (EE). +- user: It is now possible to remove users from the system. When a user is deleted, all of their assets will automatically be reassigned to a new system user, ‘Ghost User’. + +General +^^^^^^^ + + +Security +^^^^^^^^ + + + +Performance +^^^^^^^^^^^ + + + +Fixes +^^^^^ + +- ssh: Fixed an issue where the app.service_api.token field could be left empty in the configuration. The application will now automatically populate this field if it is missing. + +Upgrade notes +^^^^^^^^^^^^^ From 66fe2b2d73a210dd5664ed3fed674fd50a6e19bb Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Fri, 1 Aug 2025 10:31:25 +0200 Subject: [PATCH 06/11] release notes: typo --- docs/release-notes/release-notes-5.7.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/release-notes-5.7.0.rst b/docs/release-notes/release-notes-5.7.0.rst index f99153d6..2ceb2659 100644 --- a/docs/release-notes/release-notes-5.7.0.rst +++ b/docs/release-notes/release-notes-5.7.0.rst @@ -8,7 +8,7 @@ Release Date New Features ^^^^^^^^^^^^ -- squash: he pull request UI now includes a dropdown menu, allowing users to select "Squash" or "Close" when merging pull requests in Git and Mercurial repositories, instead of only having the "Merge" option. This feature is exclusive to the Enterprise Edition (EE) and must be enabled by an administrator. +- squash: The pull request UI now includes a dropdown menu, allowing users to select "Squash" or "Close" when merging pull requests in Git and Mercurial repositories, instead of only having the "Merge" option. This feature is exclusive to the Enterprise Edition (EE) and must be enabled by an administrator. - pull request: Introduced a new checkbox in the pull request UI, allowing users to select the option to close the branch after merging. This feature is exclusive to the Enterprise Edition (EE). - user: It is now possible to remove users from the system. When a user is deleted, all of their assets will automatically be reassigned to a new system user, ‘Ghost User’. From 25d31cd588f953882afc3008ebc1a602c0537c71 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Fri, 1 Aug 2025 11:49:44 +0200 Subject: [PATCH 07/11] tests: fixes test_utils and reverts delete repo message --- rhodecode/i18n/rhodecode.pot | 4 ++-- rhodecode/templates/admin/repos/repo_edit_advanced.mako | 6 +++++- rhodecode/tests/lib/test_utils.py | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/rhodecode/i18n/rhodecode.pot b/rhodecode/i18n/rhodecode.pot index 26318954..118e3805 100644 --- a/rhodecode/i18n/rhodecode.pot +++ b/rhodecode/i18n/rhodecode.pot @@ -7567,8 +7567,8 @@ msgstr "" msgid "Delete this repository" msgstr "" -#: rhodecode/templates/admin/repos/repo_edit_advanced.mako:242 -msgid "Warning: You are about to delete the repository. This action cannot be undone!" +#: rhodecode/templates/admin/repos/repo_edit_advanced.mako:249 +msgid "This repository will be renamed in a special way in order to make it inaccessible to RhodeCode Enterprise and its VCS systems. If you need to fully delete it from the file system, please do it manually, or with rhodecode-cleanup-repos command available in rhodecode-tools." msgstr "" #: rhodecode/templates/admin/repos/repo_edit_advanced.mako:276 diff --git a/rhodecode/templates/admin/repos/repo_edit_advanced.mako b/rhodecode/templates/admin/repos/repo_edit_advanced.mako index dd920288..e3ade58b 100644 --- a/rhodecode/templates/admin/repos/repo_edit_advanced.mako +++ b/rhodecode/templates/admin/repos/repo_edit_advanced.mako @@ -246,7 +246,11 @@ ${_('Delete this repository')}
- +
+ + ${_('This repository will be renamed in a special way in order to make it inaccessible to RhodeCode Enterprise and its VCS systems. If you need to fully delete it from the file system, please do it manually, or with rhodecode-cleanup-repos command available in rhodecode-tools.')} + +
${h.end_form()} diff --git a/rhodecode/tests/lib/test_utils.py b/rhodecode/tests/lib/test_utils.py index 7f96f5c2..775bdb7b 100644 --- a/rhodecode/tests/lib/test_utils.py +++ b/rhodecode/tests/lib/test_utils.py @@ -293,7 +293,7 @@ def test_repo2db_mapper_adds_new_repos(request, backend): for _repo in cleanup_repos: del_result = RepoModel().delete(_repo, call_events=False) Session().commit() - assert del_result is True + assert del_result.is_success is True for _repo_group in cleanup_groups: del_result = RepoGroupModel().delete(_repo_group, force_delete=True, call_events=False) From bf64faedba30318174cf39eb1da2e77e527a2a7c Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Fri, 1 Aug 2025 12:24:30 +0200 Subject: [PATCH 08/11] tests: adds detach/delete fork tets --- rhodecode/tests/models/test_repos.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/rhodecode/tests/models/test_repos.py b/rhodecode/tests/models/test_repos.py index 2c387baa..1afd08e0 100644 --- a/rhodecode/tests/models/test_repos.py +++ b/rhodecode/tests/models/test_repos.py @@ -42,14 +42,34 @@ class TestRepoModel(object): assert Repository.get_by_repo_name(repo_name=backend.repo_name) is None assert repo.repo_name not in repos - def test_remove_repo_raises_exc_when_attached_forks(self, backend): + def test_remove_repo_forks_are_detached(self, backend): repo = backend.create_repo() Session().commit() - backend.create_fork() + fork = backend.create_fork() + fork_name = fork.repo_name Session().commit() - with pytest.raises(AttachedForksError): - RepoModel().delete(repo=repo) + assert fork.parent is not None + + RepoModel().delete(repo=repo) + + fork = Repository.get_by_repo_name(repo_name=fork_name) + assert fork.parent is None + + def test_remove_repo_forks_are_deleted(self, backend): + repo = backend.create_repo() + repo.private = True + Session().commit() + fork = backend.create_fork() + fork_name = fork.repo_name + Session().commit() + + assert fork.parent is not None + + RepoModel().delete(repo=repo) + + fork = Repository.get_by_repo_name(repo_name=fork_name) + assert fork is None def test_remove_repo_delete_forks(self, backend): repo = backend.create_repo() From 91a731620ef9ef1e6ea06b1490530daa19d44033 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Fri, 1 Aug 2025 13:20:41 +0200 Subject: [PATCH 09/11] tests: fixes existing tests; adds tests for forks detach\delete --- rhodecode/api/tests/test_delete_repo.py | 45 +++++++++++++++++++ rhodecode/api/tests/utils.py | 2 +- rhodecode/api/views/repo_api.py | 17 ++++--- .../tests/test_repo_settings_advanced.py | 10 +++-- .../views/repo_settings_advanced.py | 2 +- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/rhodecode/api/tests/test_delete_repo.py b/rhodecode/api/tests/test_delete_repo.py index 8893d136..f73593c4 100644 --- a/rhodecode/api/tests/test_delete_repo.py +++ b/rhodecode/api/tests/test_delete_repo.py @@ -19,6 +19,8 @@ import mock import pytest +from rhodecode.model.db import Repository +from rhodecode.model.meta import Session from rhodecode.model.repo import RepoModel from rhodecode.api.tests.utils import build_data, api_call, assert_error, assert_ok, crash @@ -27,6 +29,9 @@ from rhodecode.api.tests.utils import build_data, api_call, assert_error, assert class TestApiDeleteRepo(object): def test_api_delete_repo(self, backend): repo = backend.create_repo() + repo.private = True + Session().commit() + repo_name = repo.repo_name id_, params = build_data( self.apikey, @@ -38,6 +43,46 @@ class TestApiDeleteRepo(object): expected = {"msg": "Deleted repository `%s`" % (repo_name,), "success": True} assert_ok(id_, expected, given=response.body) + @pytest.mark.parametrize( + "fork_action, expected_fork_msg", + [ + ("detach", "Detached 1 forks"), + ("delete", "Deleted 1 forks"), + ], + ) + def test_api_delete_repo_with_forks(self, backend, fork_action, expected_fork_msg): + repo = backend.create_repo() + repo.private = True + fork = backend.create_fork() + fork_name = fork.repo_name + Session().commit() + + assert fork.parent is not None + + repo_name = repo.repo_name + id_, params = build_data( + self.apikey, + "delete_repo", + repoid=repo.repo_name, + forks=fork_action, + ) + response = api_call(self.app, params) + + expected = { + "msg": "Deleted repository `%s` %s; NOTE: 'forks' option will be deprecated." + % (repo_name, expected_fork_msg), + "success": True, + } + assert_ok(id_, expected, given=response.body) + self._validate_fork_db_state(fork_action, fork_name) + + def _validate_fork_db_state(self, fork_action, fork_name): + fork = Repository.get_by_repo_name(repo_name=fork_name) + if fork_action == "detach": + assert fork.parent is None + elif fork_action == "delete": + assert fork is None + def test_api_delete_repo_by_non_admin(self, backend, user_regular): repo = backend.create_repo(cur_user=user_regular.username) repo_name = repo.repo_name diff --git a/rhodecode/api/tests/utils.py b/rhodecode/api/tests/utils.py index 930f898d..138df62a 100644 --- a/rhodecode/api/tests/utils.py +++ b/rhodecode/api/tests/utils.py @@ -48,7 +48,7 @@ def assert_ok(id_, expected, given): expected = jsonify({"id": id_, "error": None, "result": expected}) - assert expected == given + assert expected == given, "Expected: %s, Given: %s" % (expected, given) def assert_error(id_, expected, given): diff --git a/rhodecode/api/views/repo_api.py b/rhodecode/api/views/repo_api.py index 4da17e7b..cee7e38b 100644 --- a/rhodecode/api/views/repo_api.py +++ b/rhodecode/api/views/repo_api.py @@ -1359,21 +1359,19 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")): _perms = ("repository.admin",) validate_repo_permissions(apiuser, repoid, repo, _perms) - deprecation_warning = "NOTE: 'forks' option will be deprecated." - try: - handle_forks = Optional.extract(forks) + str_fork_action = Optional.extract(forks) _forks_msg = "" _forks = [f for f in repo.forks] - if handle_forks == "detach": + if str_fork_action == "detach": _forks_msg = " " + f"Detached {len(_forks)} forks" - elif handle_forks == "delete": + elif str_fork_action == "delete": _forks_msg = " " + f"Deleted {len(_forks)} forks" elif _forks: raise JSONRPCError(f"Cannot delete `{repo.repo_name}` it still contains attached forks") old_data = repo.get_api_data() - RepoModel().delete(repo, forks_action=ForksAction(handle_forks)) + RepoModel().delete(repo, forks_action=ForksAction(str_fork_action)) repo = audit_logger.RepoWrap(repo_id=None, repo_name=repo.repo_name) @@ -1381,7 +1379,12 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")): ScmModel().mark_for_invalidation(repo_name, delete=True) Session().commit() - return {"msg": f"Deleted repository `{repo_name}`{_forks_msg}; {deprecation_warning}", "success": True} + res_message = {"msg": f"Deleted repository `{repo_name}`{_forks_msg}", "success": True} + if str_fork_action: + deprecation_warning = "; NOTE: 'forks' option will be deprecated." + res_message["msg"] += deprecation_warning + + return res_message except Exception: log.exception("Exception occurred while trying to delete repo") raise JSONRPCError(f"failed to delete repository `{repo_name}`") diff --git a/rhodecode/apps/repository/tests/test_repo_settings_advanced.py b/rhodecode/apps/repository/tests/test_repo_settings_advanced.py index b32a9cad..9d1d3124 100644 --- a/rhodecode/apps/repository/tests/test_repo_settings_advanced.py +++ b/rhodecode/apps/repository/tests/test_repo_settings_advanced.py @@ -21,7 +21,8 @@ from unittest.mock import patch, MagicMock from rhodecode.apps.repository.views.repo_settings_advanced import RepoSettingsAdvancedView from rhodecode.lib.str_utils import safe_str from rhodecode.model.db import Repository -from rhodecode.model.repo import RepoModel +from rhodecode.model.meta import Session +from rhodecode.model.repo import RepoModel, ForksAction from rhodecode.tests import HG_REPO, GIT_REPO, assert_session_flash, no_newline_id_generator from rhodecode.tests.fixtures.rc_fixture import Fixture from rhodecode.tests.utils import repo_on_filesystem @@ -61,7 +62,7 @@ class TestAdminRepoSettingsAdvanced(object): response.mustcontain(opt) - fixture.destroy_repo(target_repo, forks="detach") + fixture.destroy_repo(target_repo, forks_action=ForksAction.DETACH) @pytest.mark.backends("hg", "git") def test_set_fork_of_other_type_repo(self, autologin_user, backend, csrf_token): @@ -97,7 +98,10 @@ class TestAdminRepoSettingsAdvanced(object): @pytest.mark.parametrize("suffix", ["", "ąęł", "123"], ids=no_newline_id_generator) def test_advanced_repo_delete(self, autologin_user, backend, suffix, csrf_token): - repo = backend.create_repo(name_suffix=suffix) + repo = backend.create_repo(name_suffix=suffix, private_repo=True) + repo.private = True + Session().commit() + repo_name = repo.repo_name repo_name_str = safe_str(repo.repo_name) diff --git a/rhodecode/apps/repository/views/repo_settings_advanced.py b/rhodecode/apps/repository/views/repo_settings_advanced.py index f47934ea..82b45f0b 100644 --- a/rhodecode/apps/repository/views/repo_settings_advanced.py +++ b/rhodecode/apps/repository/views/repo_settings_advanced.py @@ -148,7 +148,7 @@ class RepoSettingsAdvancedView(RepoAppView): fork_action = delete_res.fork_action_result.action fork_cnt = delete_res.fork_action_result.fork_count - if fork_action is not None: + if fork_cnt > 0 and fork_action is not None: if fork_action is ForksAction.DETACH: h.flash(_("Detached %s forks") % fork_cnt, category="success") elif fork_action is ForksAction.DELETE: From 8f63e50f3a9dc8e18dab6e1848052e625784a04b Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Fri, 1 Aug 2025 14:08:15 +0200 Subject: [PATCH 10/11] tests: fixed config maker --- rhodecode/config/config_maker.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rhodecode/config/config_maker.py b/rhodecode/config/config_maker.py index d6d70df1..d2e1c306 100644 --- a/rhodecode/config/config_maker.py +++ b/rhodecode/config/config_maker.py @@ -35,6 +35,10 @@ def initialize_ini_config_default_values_if_not_present(ini_path: str): with open(ini_path, "w") as configfile: updater.write(configfile) + if not ini_path or not os.path.exists(ini_path): + log.warning("Config file %s not found.", ini_path) + return + updater = ConfigUpdater() updater.read(ini_path) From e1a775b1b273411ed004bd297689582c9651c4d7 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Fri, 1 Aug 2025 18:25:51 +0200 Subject: [PATCH 11/11] doc: adds release notes --- docs/release-notes/release-notes-5.7.0.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/release-notes-5.7.0.rst b/docs/release-notes/release-notes-5.7.0.rst index 2ceb2659..48c54346 100644 --- a/docs/release-notes/release-notes-5.7.0.rst +++ b/docs/release-notes/release-notes-5.7.0.rst @@ -11,6 +11,7 @@ New Features - squash: The pull request UI now includes a dropdown menu, allowing users to select "Squash" or "Close" when merging pull requests in Git and Mercurial repositories, instead of only having the "Merge" option. This feature is exclusive to the Enterprise Edition (EE) and must be enabled by an administrator. - pull request: Introduced a new checkbox in the pull request UI, allowing users to select the option to close the branch after merging. This feature is exclusive to the Enterprise Edition (EE). - user: It is now possible to remove users from the system. When a user is deleted, all of their assets will automatically be reassigned to a new system user, ‘Ghost User’. +- active directory: Added the ability to sync active and inactive users from LDAP/Active Directory. This feature must be enabled in the LDAP authentication plugin by an administrator, is available only for Active Directory, and is exclusive to the Enterprise Edition (EE). General ^^^^^^^