From 746c5b4699c4d1ca4a337da03a0d086d69486e67 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Thu, 6 Nov 2025 16:25:54 +0100 Subject: [PATCH 1/6] feature: initial implementation of qoutas for a user, repo count part --- configs/development.ini | 4 + configs/production.ini | 4 + .../apps/admin/tests/test_admin_repos.py | 77 +++++++++++- rhodecode/apps/admin/views/repositories.py | 11 ++ .../tests/test_repo_settings_advanced.py | 42 ++++++- .../views/repo_settings_advanced.py | 4 + rhodecode/config/config_maker.py | 4 + rhodecode/lib/celerylib/tasks.py | 4 + rhodecode/model/db.py | 9 ++ rhodecode/model/quota_model.py | 110 ++++++++++++++++++ rhodecode/model/user.py | 7 ++ 11 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 rhodecode/model/quota_model.py diff --git a/configs/development.ini b/configs/development.ini index 7a367583..54a2b0bd 100644 --- a/configs/development.ini +++ b/configs/development.ini @@ -74,6 +74,10 @@ prefix = / use = egg:rhodecode-enterprise-ce +user.quotas.enabled = false +user.quotas.disk_space_mb = 100 +user.quotas.repository_count = 5 + ; enable proxy prefix middleware, defined above #filter-with = proxy-prefix diff --git a/configs/production.ini b/configs/production.ini index defb2512..61c76441 100644 --- a/configs/production.ini +++ b/configs/production.ini @@ -68,6 +68,10 @@ prefix = / use = egg:rhodecode-enterprise-ce +user.quotas.enabled = false +user.quotas.disk_space_mb = 100 +user.quotas.repository_count = 5 + ; enable proxy prefix middleware, defined above #filter-with = proxy-prefix diff --git a/rhodecode/apps/admin/tests/test_admin_repos.py b/rhodecode/apps/admin/tests/test_admin_repos.py index dca359ac..99f75b2c 100644 --- a/rhodecode/apps/admin/tests/test_admin_repos.py +++ b/rhodecode/apps/admin/tests/test_admin_repos.py @@ -19,6 +19,7 @@ import urllib.request import urllib.parse import urllib.error +from unittest.mock import PropertyMock import mock import pytest @@ -29,6 +30,7 @@ from rhodecode.lib.utils2 import safe_str from rhodecode.lib import helpers as h from rhodecode.model.db import Repository, RepoGroup, UserRepoToPerm, User, Permission from rhodecode.model.meta import Session +from rhodecode.model.quota_model import UserQuotaModel from rhodecode.model.repo import RepoModel from rhodecode.model.repo_group import RepoGroupModel from rhodecode.model.user import UserModel @@ -85,7 +87,7 @@ class TestAdminRepos(object): description_unicode = "description for newly created repo" + suffix description = description_unicode - response = self.app.post( + self.app.post( route_path("repo_create"), fixture._get_repo_create_params( repo_private=False, @@ -99,6 +101,79 @@ class TestAdminRepos(object): self.assert_repository_is_created_correctly(repo_name, description, backend) + def test_create_fails_due_to_quota(self, backend, monkeypatch): + import rhodecode as app + + monkeypatch.setitem(app.CONFIG, "user.quotas.enabled", True) + + session = login_user_session(self.app, TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS) + csrf_token = auth.get_csrf_token(session) + + repo_name_unicode = backend.new_repo_name(suffix="no_quota") + repo_name = repo_name_unicode + + user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) + user.update_quotas( + max_disk_space_mb_allowance=200, + max_repository_count_allowance=2, + current_repos_count=2, + used_disk_space_bytes=0, + ) + + self.app.post( + route_path("repo_create"), + fixture._get_repo_create_params( + repo_name=repo_name, + repo_type=backend.alias, + repo_description="no_quota", + csrf_token=csrf_token, + ), + status=302, + ) + + response = self.app.get(route_path("repo_creating_check", repo_name=repo_name)) + assert response.json == {"result": False} + + flash_msg = "User quotas exceeded. You can't create more repositories." + assert_session_flash(response, flash_msg) + + new_repo = RepoModel().get_by_repo_name(repo_name) + assert new_repo is None + + def test_create_count_user_repos(self, backend, monkeypatch): + import rhodecode as app + + monkeypatch.setitem(app.CONFIG, "user.quotas.enabled", True) + + session = login_user_session(self.app, TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS) + csrf_token = auth.get_csrf_token(session) + + repo_name_unicode = backend.new_repo_name(suffix="no_quota") + repo_name = repo_name_unicode + + user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) + user.update_quotas( + max_disk_space_mb_allowance=200, + max_repository_count_allowance=2, + current_repos_count=0, + used_disk_space_bytes=0, + ) + + self.app.post( + route_path("repo_create"), + fixture._get_repo_create_params( + repo_name=repo_name, + repo_type=backend.alias, + repo_description="quota", + csrf_token=csrf_token, + ), + status=302, + ) + + self.assert_repository_is_created_correctly(repo_name, "quota", backend) + user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) + assert user.get_quotas()["current_repos_count"] == 1 + def test_create_numeric_name(self, autologin_user, backend, csrf_token): numeric_repo = "1234" repo_name = numeric_repo diff --git a/rhodecode/apps/admin/views/repositories.py b/rhodecode/apps/admin/views/repositories.py index 1a49832d..e47d75d6 100644 --- a/rhodecode/apps/admin/views/repositories.py +++ b/rhodecode/apps/admin/views/repositories.py @@ -36,6 +36,7 @@ from rhodecode.lib.utils import repo_name_slug from rhodecode.lib.utils2 import safe_int, safe_str from rhodecode.model.forms import RepoForm from rhodecode.model.permission import PermissionModel +from rhodecode.model.quota_model import UserQuotaModel from rhodecode.model.repo import RepoModel from rhodecode.model.scm import RepoList, RepoGroupList, ScmModel from rhodecode.model.settings import SettingsModel @@ -220,6 +221,11 @@ class AdminReposView(BaseAppView, DataGridAppView): copy_permissions = form_result.get("repo_copy_permissions") # create is done sometimes async on celery, db transaction # management is handled there. + + quota_model = UserQuotaModel(self._rhodecode_db_user) + if not quota_model.is_repo_creation_allowed(): + raise UserQuotaModel.QuotaError("User quotas exceeded. You can't create more repositories.") + task = RepoModel().create(form_result, self._rhodecode_user.user_id) task_id = get_task_id(task) except formencode.Invalid as errors: @@ -234,6 +240,11 @@ class AdminReposView(BaseAppView, DataGridAppView): ) return Response(html) + except UserQuotaModel.QuotaError as e: + msg = str(e) + log.error(msg) + h.flash(msg, category="error") + raise HTTPFound(h.route_path("home")) except Exception as e: msg = self._log_creation_exception(e, form_result.get("repo_name")) h.flash(msg, category="error") diff --git a/rhodecode/apps/repository/tests/test_repo_settings_advanced.py b/rhodecode/apps/repository/tests/test_repo_settings_advanced.py index 94dbe7f8..de404afb 100644 --- a/rhodecode/apps/repository/tests/test_repo_settings_advanced.py +++ b/rhodecode/apps/repository/tests/test_repo_settings_advanced.py @@ -19,11 +19,13 @@ import pytest from unittest.mock import patch, MagicMock from rhodecode.apps.repository.views.repo_settings_advanced import RepoSettingsAdvancedView +from rhodecode.bootstrap import TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS +from rhodecode.lib import auth from rhodecode.lib.str_utils import safe_str -from rhodecode.model.db import Repository +from rhodecode.model.db import Repository, User 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 import HG_REPO, GIT_REPO, assert_session_flash, no_newline_id_generator, login_user_session from rhodecode.tests.fixtures.rc_fixture import Fixture from rhodecode.tests.utils import repo_on_filesystem from rhodecode.tests.routes import route_path @@ -115,6 +117,42 @@ class TestAdminRepoSettingsAdvanced(object): assert RepoModel().get_by_repo_name(repo_name) is None assert not repo_on_filesystem(repo_name_str) + def test_advanced_repo_delete_decrement_repo_quota(self, backend, monkeypatch): + import rhodecode as app + + monkeypatch.setitem(app.CONFIG, "user.quotas.enabled", True) + + session = login_user_session(self.app, TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS) + csrf_token = auth.get_csrf_token(session) + + user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) + user.update_quotas( + max_disk_space_mb_allowance=200, + max_repository_count_allowance=2, + current_repos_count=1, + used_disk_space_bytes=0, + ) + + repo = backend.create_repo(name_suffix="quota") + repo.user = user + Session().commit() + + repo_name = repo.repo_name + repo_name_str = safe_str(repo.repo_name) + + response = self.app.post( + route_path("edit_repo_advanced_delete", repo_name=repo_name_str), params={"csrf_token": csrf_token} + ) + assert_session_flash(response, "Deleted repository `{}`".format(repo_name)) + response.follow() + + user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) + + # check if repo was deleted from db + assert RepoModel().get_by_repo_name(repo_name) is None + assert not repo_on_filesystem(repo_name_str) + assert user.get_quotas()["current_repos_count"] == 0 + @pytest.mark.parametrize("suffix", ["", "ąęł", "123"], ids=no_newline_id_generator) def test_delete_broken_repo(self, autologin_user, backend, suffix, csrf_token): repo = backend.create_repo(name_suffix=suffix, private_repo=True) diff --git a/rhodecode/apps/repository/views/repo_settings_advanced.py b/rhodecode/apps/repository/views/repo_settings_advanced.py index b4bc2bb3..feff3b09 100644 --- a/rhodecode/apps/repository/views/repo_settings_advanced.py +++ b/rhodecode/apps/repository/views/repo_settings_advanced.py @@ -32,6 +32,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.quota_model import UserQuotaModel from rhodecode.model.repo import RepoModel, ForksAction from rhodecode.model.scm import ScmModel @@ -157,6 +158,9 @@ class RepoSettingsAdvancedView(RepoAppView): self.request.POST.get("backup", "false") ) # if this option is absent, this means that on FE side checkbox was unchecked delete_res = RepoModel().delete(self.db_repo, fs_backup=backup) + repo_owner = self.db_repo.user + quota_model = UserQuotaModel(repo_owner) + quota_model.decrement_repo_count() fork_action = delete_res.fork_action_result.action fork_cnt = delete_res.fork_action_result.fork_count diff --git a/rhodecode/config/config_maker.py b/rhodecode/config/config_maker.py index 5f710d83..70039db5 100644 --- a/rhodecode/config/config_maker.py +++ b/rhodecode/config/config_maker.py @@ -174,6 +174,10 @@ def sanitize_settings_and_apply_defaults(global_config, settings): settings_maker.make_setting("celery.deduplicate_lock_store", "redis://redis:6379/9", parser="string") settings_maker.make_setting("celery.lock_ttl_seconds", 3600, parser="int") + settings_maker.make_setting("user.quotas.enabled", False, parser="bool") + settings_maker.make_setting("user.quotas.disk_space_mb", 200, parser="int") + settings_maker.make_setting("user.quotas.repository_count", 5, parser="int") + settings_maker.make_setting("exception_tracker.send_email", False, parser="bool") settings_maker.make_setting("exception_tracker.email_prefix", "[RHODECODE ERROR]", default_when_empty=True) diff --git a/rhodecode/lib/celerylib/tasks.py b/rhodecode/lib/celerylib/tasks.py index 9a1d5065..4ab112a4 100644 --- a/rhodecode/lib/celerylib/tasks.py +++ b/rhodecode/lib/celerylib/tasks.py @@ -59,6 +59,7 @@ from rhodecode.model.db import ( ) from rhodecode.model.permission import PermissionModel from rhodecode.model.pull_request import PullRequestModel +from rhodecode.model.quota_model import UserQuotaModel from rhodecode.model.settings import SettingsModel @@ -279,6 +280,9 @@ def create_repo(form_data, cur_user): repo=audit_logger.RepoWrap(repo_name=repo_name, repo_id=repo_id), ) + quota_model = UserQuotaModel(cur_user) + quota_model.increment_repo_count() + Session().commit() PermissionModel().trigger_permission_flush() diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py index 32627a72..1bb50f9d 100644 --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -1256,6 +1256,15 @@ class User(Base, BaseModel): if user: return user + def update_quotas(self, **kwargs): + quotas = self.user_data.get("quotas", {}) + quotas.update(**kwargs) + self.update_userdata(quotas=quotas) + Session().commit() + + def get_quotas(self): + return self.user_data.get("quotas", {}) + def update_userdata(self, **kwargs): usr = self old = usr.user_data diff --git a/rhodecode/model/quota_model.py b/rhodecode/model/quota_model.py new file mode 100644 index 00000000..f9c13916 --- /dev/null +++ b/rhodecode/model/quota_model.py @@ -0,0 +1,110 @@ +import logging +from functools import wraps + +from rhodecode.model.db import User +import rhodecode as app + + +log = logging.getLogger(__name__) + + +def always_valid_for_super_admin(f): + @wraps(f) + def inner(self, *args, **kwargs): + if self.user.is_admin: + return True + return f(self, *args, **kwargs) + + return inner + + +def skip_for_super_admin(f): + @wraps(f) + def inner(self, *args, **kwargs): + if self.user.is_admin: + log.debug(f"Skipping {f.__name__} for super admin.") + return + return f(self, *args, **kwargs) + + return inner + + +class UserQuotaModel: + """ + NOTE: quotas are applied only when a user registers itself or the admin adds a user, + in plugins there are more ways to add a user. + """ + + class QuotaError(Exception): + pass + + def __init__(self, user: User): + self._settings = app.CONFIG + self.enabled = self._settings.get("user.quotas.enabled") + self.default_max_disk_space_mb_allowance = self._settings.get("user.quotas.disk_space_mb") + self.default_max_repository_count_allowance = self._settings.get("user.quotas.repository_count") + self.user = user + + def init_quotas(self): + if not self.enabled: + log.debug("quotas are disabled") + return + + self.user.update_quotas( + max_disk_space_mb_allowance=self.default_max_disk_space_mb_allowance, + max_repository_count_allowance=self.default_max_repository_count_allowance, + current_repos_count=0, + used_disk_space_bytes=0, + ) + + @always_valid_for_super_admin + def is_repo_creation_allowed(self): + if not self.enabled: + return True + + if self._user_quotas_valid(): + u_quotas = self.user.get_quotas() + return u_quotas.get("max_repository_count_allowance") > u_quotas.get("current_repos_count") + + log.warning("User quotas not present, probably old user, allowing repo creation.") + return True + + @skip_for_super_admin + def increment_repo_count(self): + if not self.enabled: + return + + if self._user_quotas_valid(): + u_quotas = self.user.get_quotas() + new_count = u_quotas["current_repos_count"] + 1 + self.user.update_quotas(current_repos_count=new_count) + + log.warning("User quotas not present, probably old user, skipping.") + + @skip_for_super_admin + def decrement_repo_count(self): + if not self.enabled: + return + + u_quotas = self.user.get_quotas() + if self._user_quotas_valid() and u_quotas["current_repos_count"] > 0: + new_count = u_quotas["current_repos_count"] - 1 + self.user.update_quotas(current_repos_count=new_count) + + log.warning("User quotas not present, probably old user, skipping.") + + def _user_quotas_valid(self): + u_quotas = self.user.get_quotas() + for key in [ + "max_disk_space_mb_allowance", + "max_repository_count_allowance", + "current_repos_count", + "used_disk_space_bytes", + ]: + if u_quotas.get(key) is None: + log.error( + f"User {self.user.username} has no quota value for {key}, probably quota initialization was not done." + ) + return False + + return True diff --git a/rhodecode/model/user.py b/rhodecode/model/user.py index b303b50d..9bc2552f 100644 --- a/rhodecode/model/user.py +++ b/rhodecode/model/user.py @@ -62,6 +62,7 @@ from rhodecode.model.db import ( ) from rhodecode.model.meta import Session from rhodecode.model.auth_token import AuthTokenModel +from rhodecode.model.quota_model import UserQuotaModel from rhodecode.model.repo_group import RepoGroupModel log = logging.getLogger(__name__) @@ -409,6 +410,10 @@ class UserModel(BaseModel): new_user.update_userdata(language=language) new_user.update_userdata(notification_status=True) + if new_user.is_new_user: + qm = UserQuotaModel(new_user) + qm.init_quotas() + self.sa.add(new_user) if not edit and create_repo_group: @@ -443,6 +448,8 @@ class UserModel(BaseModel): form_data["extern_type"] = extern_type new_user = self.create(form_data) + qm = UserQuotaModel(new_user) + qm.init_quotas() self.sa.add(new_user) self.sa.flush() From c4db0d33e5d04bba69aed2501963cff95f9927cf Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Fri, 7 Nov 2025 11:54:20 +0100 Subject: [PATCH 2/6] feature: simplify code --- .../apps/admin/tests/test_admin_repos.py | 18 ++-- .../tests/test_repo_settings_advanced.py | 8 +- .../views/repo_settings_advanced.py | 3 - rhodecode/lib/celerylib/tasks.py | 3 - rhodecode/model/quota_model.py | 82 +++++++------------ 5 files changed, 43 insertions(+), 71 deletions(-) diff --git a/rhodecode/apps/admin/tests/test_admin_repos.py b/rhodecode/apps/admin/tests/test_admin_repos.py index 99f75b2c..b2763f96 100644 --- a/rhodecode/apps/admin/tests/test_admin_repos.py +++ b/rhodecode/apps/admin/tests/test_admin_repos.py @@ -106,6 +106,11 @@ class TestAdminRepos(object): monkeypatch.setitem(app.CONFIG, "user.quotas.enabled", True) + user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) + existing_repo = backend.create_repo() + existing_repo.user = user + Session().commit() + session = login_user_session(self.app, TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS) csrf_token = auth.get_csrf_token(session) @@ -114,9 +119,8 @@ class TestAdminRepos(object): user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) user.update_quotas( - max_disk_space_mb_allowance=200, - max_repository_count_allowance=2, - current_repos_count=2, + max_disk_space_mb_allowance=100, + max_repository_count_allowance=1, used_disk_space_bytes=0, ) @@ -153,9 +157,8 @@ class TestAdminRepos(object): user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) user.update_quotas( - max_disk_space_mb_allowance=200, - max_repository_count_allowance=2, - current_repos_count=0, + max_disk_space_mb_allowance=100, + max_repository_count_allowance=1, used_disk_space_bytes=0, ) @@ -172,7 +175,8 @@ class TestAdminRepos(object): self.assert_repository_is_created_correctly(repo_name, "quota", backend) user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) - assert user.get_quotas()["current_repos_count"] == 1 + user_repos = Repository.get_all_repos(user.user_id) + assert len(user_repos) == 1 def test_create_numeric_name(self, autologin_user, backend, csrf_token): numeric_repo = "1234" diff --git a/rhodecode/apps/repository/tests/test_repo_settings_advanced.py b/rhodecode/apps/repository/tests/test_repo_settings_advanced.py index de404afb..5a8690e1 100644 --- a/rhodecode/apps/repository/tests/test_repo_settings_advanced.py +++ b/rhodecode/apps/repository/tests/test_repo_settings_advanced.py @@ -127,9 +127,8 @@ class TestAdminRepoSettingsAdvanced(object): user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) user.update_quotas( - max_disk_space_mb_allowance=200, - max_repository_count_allowance=2, - current_repos_count=1, + max_disk_space_mb_allowance=100, + max_repository_count_allowance=1, used_disk_space_bytes=0, ) @@ -151,7 +150,8 @@ class TestAdminRepoSettingsAdvanced(object): # check if repo was deleted from db assert RepoModel().get_by_repo_name(repo_name) is None assert not repo_on_filesystem(repo_name_str) - assert user.get_quotas()["current_repos_count"] == 0 + user_repos = Repository.get_all_repos(user.user_id) + assert len(user_repos) == 0 @pytest.mark.parametrize("suffix", ["", "ąęł", "123"], ids=no_newline_id_generator) def test_delete_broken_repo(self, autologin_user, backend, suffix, csrf_token): diff --git a/rhodecode/apps/repository/views/repo_settings_advanced.py b/rhodecode/apps/repository/views/repo_settings_advanced.py index feff3b09..6b81ca3a 100644 --- a/rhodecode/apps/repository/views/repo_settings_advanced.py +++ b/rhodecode/apps/repository/views/repo_settings_advanced.py @@ -158,9 +158,6 @@ class RepoSettingsAdvancedView(RepoAppView): self.request.POST.get("backup", "false") ) # if this option is absent, this means that on FE side checkbox was unchecked delete_res = RepoModel().delete(self.db_repo, fs_backup=backup) - repo_owner = self.db_repo.user - quota_model = UserQuotaModel(repo_owner) - quota_model.decrement_repo_count() fork_action = delete_res.fork_action_result.action fork_cnt = delete_res.fork_action_result.fork_count diff --git a/rhodecode/lib/celerylib/tasks.py b/rhodecode/lib/celerylib/tasks.py index 4ab112a4..83a36d5e 100644 --- a/rhodecode/lib/celerylib/tasks.py +++ b/rhodecode/lib/celerylib/tasks.py @@ -280,9 +280,6 @@ def create_repo(form_data, cur_user): repo=audit_logger.RepoWrap(repo_name=repo_name, repo_id=repo_id), ) - quota_model = UserQuotaModel(cur_user) - quota_model.increment_repo_count() - Session().commit() PermissionModel().trigger_permission_flush() diff --git a/rhodecode/model/quota_model.py b/rhodecode/model/quota_model.py index f9c13916..2fa23bf6 100644 --- a/rhodecode/model/quota_model.py +++ b/rhodecode/model/quota_model.py @@ -1,40 +1,22 @@ import logging -from functools import wraps -from rhodecode.model.db import User +from sqlalchemy import func, select + import rhodecode as app - +from rhodecode.model.db import User, Repository +from rhodecode.model.meta import Session log = logging.getLogger(__name__) -def always_valid_for_super_admin(f): - @wraps(f) - def inner(self, *args, **kwargs): - if self.user.is_admin: - return True - return f(self, *args, **kwargs) - - return inner - - -def skip_for_super_admin(f): - @wraps(f) - def inner(self, *args, **kwargs): - if self.user.is_admin: - log.debug(f"Skipping {f.__name__} for super admin.") - return - return f(self, *args, **kwargs) - - return inner - - class UserQuotaModel: """ NOTE: quotas are applied only when a user registers itself or the admin adds a user, in plugins there are more ways to add a user. """ + UNLIMITED = float("inf") + class QuotaError(Exception): pass @@ -53,52 +35,44 @@ class UserQuotaModel: self.user.update_quotas( max_disk_space_mb_allowance=self.default_max_disk_space_mb_allowance, max_repository_count_allowance=self.default_max_repository_count_allowance, - current_repos_count=0, used_disk_space_bytes=0, ) - @always_valid_for_super_admin + def _init_quotas_for_old_users(self): + if not self.enabled: + log.debug("quotas are disabled") + return + + log.debug("Initializing quotas for old users.") + + self.user.update_quotas( + max_disk_space_mb_allowance=self.UNLIMITED, + max_repository_count_allowance=self.UNLIMITED, + used_disk_space_bytes=0, + ) + + def user_repositories_count(self): + return Session().scalar( + select(func.count()).select_from(Repository).where(Repository.user_id == self.user.user_id) + ) + def is_repo_creation_allowed(self): if not self.enabled: return True if self._user_quotas_valid(): u_quotas = self.user.get_quotas() - return u_quotas.get("max_repository_count_allowance") > u_quotas.get("current_repos_count") + return u_quotas.get("max_repository_count_allowance") > self.user_repositories_count() - log.warning("User quotas not present, probably old user, allowing repo creation.") - return True - - @skip_for_super_admin - def increment_repo_count(self): - if not self.enabled: - return - - if self._user_quotas_valid(): - u_quotas = self.user.get_quotas() - new_count = u_quotas["current_repos_count"] + 1 - self.user.update_quotas(current_repos_count=new_count) - - log.warning("User quotas not present, probably old user, skipping.") - - @skip_for_super_admin - def decrement_repo_count(self): - if not self.enabled: - return - - u_quotas = self.user.get_quotas() - if self._user_quotas_valid() and u_quotas["current_repos_count"] > 0: - new_count = u_quotas["current_repos_count"] - 1 - self.user.update_quotas(current_repos_count=new_count) - - log.warning("User quotas not present, probably old user, skipping.") + log.warning("User quotas not present, probably old user.") + self._init_quotas_for_old_users() + return self.is_repo_creation_allowed() def _user_quotas_valid(self): u_quotas = self.user.get_quotas() for key in [ "max_disk_space_mb_allowance", "max_repository_count_allowance", - "current_repos_count", "used_disk_space_bytes", ]: if u_quotas.get(key) is None: From a960c9f99c0b01d23320360cd24de28267b9a318 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Thu, 13 Nov 2025 16:02:18 +0100 Subject: [PATCH 3/6] refactoring: implements setting up global repo quotas UI for super admin --- configs/development.ini | 4 - configs/production.ini | 4 - rhodecode/apps/_base/navigation.py | 1 + rhodecode/apps/admin/__init__.py | 19 ++++ .../apps/admin/tests/test_admin_repos.py | 26 +++-- rhodecode/apps/admin/views/user_quotas.py | 96 +++++++++++++++++++ .../tests/test_repo_settings_advanced.py | 12 ++- rhodecode/model/forms.py | 9 ++ rhodecode/model/quota_model.py | 52 +++++----- rhodecode/model/user.py | 4 - .../admin/settings/settings_user_quotas.mako | 33 +++++++ 11 files changed, 205 insertions(+), 55 deletions(-) create mode 100644 rhodecode/apps/admin/views/user_quotas.py create mode 100644 rhodecode/templates/admin/settings/settings_user_quotas.mako diff --git a/configs/development.ini b/configs/development.ini index 54a2b0bd..7a367583 100644 --- a/configs/development.ini +++ b/configs/development.ini @@ -74,10 +74,6 @@ prefix = / use = egg:rhodecode-enterprise-ce -user.quotas.enabled = false -user.quotas.disk_space_mb = 100 -user.quotas.repository_count = 5 - ; enable proxy prefix middleware, defined above #filter-with = proxy-prefix diff --git a/configs/production.ini b/configs/production.ini index 61c76441..defb2512 100644 --- a/configs/production.ini +++ b/configs/production.ini @@ -68,10 +68,6 @@ prefix = / use = egg:rhodecode-enterprise-ce -user.quotas.enabled = false -user.quotas.disk_space_mb = 100 -user.quotas.repository_count = 5 - ; enable proxy prefix middleware, defined above #filter-with = proxy-prefix diff --git a/rhodecode/apps/_base/navigation.py b/rhodecode/apps/_base/navigation.py index a9ae29e6..6506f275 100644 --- a/rhodecode/apps/_base/navigation.py +++ b/rhodecode/apps/_base/navigation.py @@ -90,6 +90,7 @@ class NavigationRegistry(object): NavEntry("process_management", _("Processes"), "admin_settings_process_management"), NavEntry("sessions", _("User Sessions"), "admin_settings_sessions"), NavEntry("open_source", _("Open Source Licenses"), "admin_settings_open_source"), + NavEntry("user_quotas", _("User Quotas"), "admin_settings_user_quotas"), ] _labs_entry = NavEntry("labs", _("Labs"), "admin_settings_labs") diff --git a/rhodecode/apps/admin/__init__.py b/rhodecode/apps/admin/__init__.py index 85373f20..453a482a 100644 --- a/rhodecode/apps/admin/__init__.py +++ b/rhodecode/apps/admin/__init__.py @@ -20,6 +20,7 @@ from rhodecode.apps._base import ADMIN_PREFIX from rhodecode.apps._base.navigation import includeme as nav_includeme from rhodecode.apps.admin.views.main_views import AdminMainView +from rhodecode.apps.admin.views.user_quotas import AdminUserQuotasView def admin_routes(config): @@ -163,6 +164,24 @@ def admin_routes(config): renderer="json_ext", ) + config.add_route("admin_settings_user_quotas", pattern="/settings/user_quotas") + config.add_view( + AdminUserQuotasView, + attr="admin_settings_user_quotas", + route_name="admin_settings_user_quotas", + request_method="GET", + renderer="rhodecode:templates/admin/settings/settings.mako", + ) + + config.add_route("admin_settings_user_quotas_update", pattern="/settings/user_quotas/update") + config.add_view( + AdminUserQuotasView, + attr="admin_settings_user_quotas_update", + route_name="admin_settings_user_quotas_update", + request_method="POST", + renderer="rhodecode:templates/admin/settings/settings.mako", + ) + config.add_route(name="admin_settings_vcs_svn_generate_cfg", pattern="/settings/vcs/svn_generate_cfg") config.add_view( AdminSvnConfigView, diff --git a/rhodecode/apps/admin/tests/test_admin_repos.py b/rhodecode/apps/admin/tests/test_admin_repos.py index b2763f96..2134607f 100644 --- a/rhodecode/apps/admin/tests/test_admin_repos.py +++ b/rhodecode/apps/admin/tests/test_admin_repos.py @@ -19,7 +19,7 @@ import urllib.request import urllib.parse import urllib.error -from unittest.mock import PropertyMock +from unittest.mock import PropertyMock, MagicMock, patch import mock import pytest @@ -101,10 +101,15 @@ class TestAdminRepos(object): self.assert_repository_is_created_correctly(repo_name, description, backend) - def test_create_fails_due_to_quota(self, backend, monkeypatch): - import rhodecode as app + @patch("rhodecode.model.quota_model.SettingsModel") + def test_create_fails_due_to_quota(self, settings_model_mock, backend): + test_quota_config = { + "rhodecode_user_quotas_enabled": True, + "rhodecode_global_user_repo_quotas": 1, + } - monkeypatch.setitem(app.CONFIG, "user.quotas.enabled", True) + mock_instance = settings_model_mock.return_value + mock_instance.get_all_settings.return_value = test_quota_config user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) existing_repo = backend.create_repo() @@ -121,7 +126,6 @@ class TestAdminRepos(object): user.update_quotas( max_disk_space_mb_allowance=100, max_repository_count_allowance=1, - used_disk_space_bytes=0, ) self.app.post( @@ -144,10 +148,15 @@ class TestAdminRepos(object): new_repo = RepoModel().get_by_repo_name(repo_name) assert new_repo is None - def test_create_count_user_repos(self, backend, monkeypatch): - import rhodecode as app + @patch("rhodecode.model.quota_model.SettingsModel") + def test_create_count_user_repos(self, settings_model_mock, backend): + test_quota_config = { + "rhodecode_user_quotas_enabled": True, + "rhodecode_global_user_repo_quotas": 1, + } - monkeypatch.setitem(app.CONFIG, "user.quotas.enabled", True) + mock_instance = settings_model_mock.return_value + mock_instance.get_all_settings.return_value = test_quota_config session = login_user_session(self.app, TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS) csrf_token = auth.get_csrf_token(session) @@ -159,7 +168,6 @@ class TestAdminRepos(object): user.update_quotas( max_disk_space_mb_allowance=100, max_repository_count_allowance=1, - used_disk_space_bytes=0, ) self.app.post( diff --git a/rhodecode/apps/admin/views/user_quotas.py b/rhodecode/apps/admin/views/user_quotas.py new file mode 100644 index 00000000..2d0fb1b0 --- /dev/null +++ b/rhodecode/apps/admin/views/user_quotas.py @@ -0,0 +1,96 @@ +import json +import logging + +import formencode + +from pyramid.httpexceptions import HTTPFound +from rhodecode.apps._base import BaseAppView +from rhodecode.apps._base.navigation import navigation_list +from rhodecode.apps.ai_agents.ai_service import get_ai_service +from rhodecode.apps.ai_agents.ai_settings import AIModelName +from rhodecode.apps.ai_agents.models.base import AIService +from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator, CSRFRequired +from rhodecode.lib import helpers as h +from rhodecode.model.db import User +from rhodecode.model.forms import AiSettingsForm, AiSettingsModelVersionUpdateForm, UserQuotasSettingsForm +from rhodecode.model.quota_model import UserQuotaModel +from rhodecode.model.settings import SettingsModel +from rhodecode.model.meta import Session + +log = logging.getLogger(__name__) + + +class AdminUserQuotasView(BaseAppView): + def load_default_context(self): + c = self._get_local_tmpl_context() + return c + + @LoginRequired() + @HasPermissionAllDecorator("hg.admin") + def admin_settings_user_quotas(self): + c = self.load_default_context() + c.active = "user_quotas" + c.navlist = navigation_list(self.request) + + app_settings = c.rc_config + c.user_quotas_enabled = app_settings.get("rhodecode_user_quotas_enabled", UserQuotaModel.DEFAULT_QUOTAS_ENABLED) + c.global_user_repo_quotas = app_settings.get( + "rhodecode_global_user_repo_quotas", UserQuotaModel.DEFAULT_REPO_QUOTA + ) + + return self._get_template_context(c) + + @CSRFRequired() + @LoginRequired() + @HasPermissionAllDecorator("hg.admin") + def admin_settings_user_quotas_update(self): + c = self.load_default_context() + c.active = "user_quotas" + c.navlist = navigation_list(self.request) + _ = self.request.translate + + parsed_form = self._parse_form(_) + self._save_settings(_, parsed_form) + + return HTTPFound(h.route_path("admin_settings_user_quotas")) + + def _save_settings(self, _, data): + try: + repo_quotas_key = "rhodecode_global_user_repo_quotas" + apply_user_quotas = False + + settings = [ + ("user_quotas_enabled", "rhodecode_user_quotas_enabled", "bool"), + ("global_user_repo_quotas", repo_quotas_key, "int"), + ] + for setting, form_key, type_ in settings: + sett = SettingsModel().create_or_update_setting(setting, data[form_key], type_) + if setting == "user_quotas_enabled" and isinstance(data[form_key], bool): + apply_user_quotas = data[form_key] + + Session().add(sett) + + Session().commit() + SettingsModel().invalidate_settings_cache() + if apply_user_quotas: + h.flash(_(f"Applying user quotas, value: {data[repo_quotas_key]}"), category="success") + self._apply_user_repo_quotas() + + h.flash(_("User Quotas settings saved"), category="success") + except Exception as e: + log.exception("Exception saving User Quotas settings: %s", e) + h.flash(_(f"Error occurred during saving User Quotas settings, error: {str(e)}"), category="error") + + def _parse_form(self, _): + try: + form = UserQuotasSettingsForm()() + data = form.to_python(self.request.POST) + except formencode.Invalid as errors: + error = errors + h.flash(_(f"Invalid form error: {error}"), category="error") + raise HTTPFound(h.route_path("admin_settings_user_quotas")) + return data + + def _apply_user_repo_quotas(self): + for user in User.get_all(): + UserQuotaModel(user).init_quotas() diff --git a/rhodecode/apps/repository/tests/test_repo_settings_advanced.py b/rhodecode/apps/repository/tests/test_repo_settings_advanced.py index 5a8690e1..84dcc40f 100644 --- a/rhodecode/apps/repository/tests/test_repo_settings_advanced.py +++ b/rhodecode/apps/repository/tests/test_repo_settings_advanced.py @@ -117,10 +117,15 @@ class TestAdminRepoSettingsAdvanced(object): assert RepoModel().get_by_repo_name(repo_name) is None assert not repo_on_filesystem(repo_name_str) - def test_advanced_repo_delete_decrement_repo_quota(self, backend, monkeypatch): - import rhodecode as app + @patch("rhodecode.model.quota_model.SettingsModel") + def test_advanced_repo_delete_decrement_repo_quota(self, settings_model_mock, backend): + test_quota_config = { + "rhodecode_user_quotas_enabled": True, + "rhodecode_global_user_repo_quotas": 1, + } - monkeypatch.setitem(app.CONFIG, "user.quotas.enabled", True) + mock_instance = settings_model_mock.return_value + mock_instance.get_all_settings.return_value = test_quota_config session = login_user_session(self.app, TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS) csrf_token = auth.get_csrf_token(session) @@ -129,7 +134,6 @@ class TestAdminRepoSettingsAdvanced(object): user.update_quotas( max_disk_space_mb_allowance=100, max_repository_count_allowance=1, - used_disk_space_bytes=0, ) repo = backend.create_repo(name_suffix="quota") diff --git a/rhodecode/model/forms.py b/rhodecode/model/forms.py index d1d88bff..8e68a252 100644 --- a/rhodecode/model/forms.py +++ b/rhodecode/model/forms.py @@ -706,3 +706,12 @@ def AiSettingsModelVersionUpdateForm(): rhodecode_ai_api_key = v.UnicodeString(strip=True) return _AiSettingsModelVersionUpdateForm + + +def UserQuotasSettingsForm(): + class _UserQuotasSettingsForm(formencode.Schema): + allow_extra_fields = True + rhodecode_user_quotas_enabled = v.StringBoolean(if_missing=False) + rhodecode_global_user_repo_quotas = v.Int(strip=True, required=True) + + return _UserQuotasSettingsForm diff --git a/rhodecode/model/quota_model.py b/rhodecode/model/quota_model.py index 2fa23bf6..aa8a2427 100644 --- a/rhodecode/model/quota_model.py +++ b/rhodecode/model/quota_model.py @@ -2,53 +2,46 @@ import logging from sqlalchemy import func, select -import rhodecode as app -from rhodecode.model.db import User, Repository +from rhodecode.model.db import Repository from rhodecode.model.meta import Session +from rhodecode.model.settings import SettingsModel log = logging.getLogger(__name__) class UserQuotaModel: - """ - NOTE: quotas are applied only when a user registers itself or the admin adds a user, - in plugins there are more ways to add a user. - """ - UNLIMITED = float("inf") + DEFAULT_REPO_QUOTA = 5 + DEFAULT_QUOTAS_ENABLED = False class QuotaError(Exception): pass - def __init__(self, user: User): - self._settings = app.CONFIG - self.enabled = self._settings.get("user.quotas.enabled") - self.default_max_disk_space_mb_allowance = self._settings.get("user.quotas.disk_space_mb") - self.default_max_repository_count_allowance = self._settings.get("user.quotas.repository_count") + def __init__(self, user): + self._settings = SettingsModel().get_all_settings() + self.enabled = self._settings.get("rhodecode_user_quotas_enabled", UserQuotaModel.DEFAULT_QUOTAS_ENABLED) + self.max_disk_space_mb_allowance = 100 # not implemented yet + self.max_repository_count_allowance = self._settings.get( + "rhodecode_global_user_repo_quotas", UserQuotaModel.DEFAULT_REPO_QUOTA + ) self.user = user def init_quotas(self): + from rhodecode.model.db import User # resolve circular dependency + if not self.enabled: log.debug("quotas are disabled") return - self.user.update_quotas( - max_disk_space_mb_allowance=self.default_max_disk_space_mb_allowance, - max_repository_count_allowance=self.default_max_repository_count_allowance, - used_disk_space_bytes=0, - ) - - def _init_quotas_for_old_users(self): - if not self.enabled: - log.debug("quotas are disabled") - return - - log.debug("Initializing quotas for old users.") + mb_allowance = self.max_disk_space_mb_allowance + repo_count_allowance = self.max_repository_count_allowance + if self.user.admin or self.user.username in [User.AI_USER, User.DEFAULT_USER, User.GHOST_USER]: + mb_allowance = self.UNLIMITED + repo_count_allowance = self.UNLIMITED self.user.update_quotas( - max_disk_space_mb_allowance=self.UNLIMITED, - max_repository_count_allowance=self.UNLIMITED, - used_disk_space_bytes=0, + max_disk_space_mb_allowance=mb_allowance, + max_repository_count_allowance=repo_count_allowance, ) def user_repositories_count(self): @@ -64,8 +57,8 @@ class UserQuotaModel: u_quotas = self.user.get_quotas() return u_quotas.get("max_repository_count_allowance") > self.user_repositories_count() - log.warning("User quotas not present, probably old user.") - self._init_quotas_for_old_users() + log.warning("User quotas not present, re-initialize quotas.") + self.init_quotas() return self.is_repo_creation_allowed() def _user_quotas_valid(self): @@ -73,7 +66,6 @@ class UserQuotaModel: for key in [ "max_disk_space_mb_allowance", "max_repository_count_allowance", - "used_disk_space_bytes", ]: if u_quotas.get(key) is None: log.error( diff --git a/rhodecode/model/user.py b/rhodecode/model/user.py index 9bc2552f..bf3a8327 100644 --- a/rhodecode/model/user.py +++ b/rhodecode/model/user.py @@ -410,10 +410,6 @@ class UserModel(BaseModel): new_user.update_userdata(language=language) new_user.update_userdata(notification_status=True) - if new_user.is_new_user: - qm = UserQuotaModel(new_user) - qm.init_quotas() - self.sa.add(new_user) if not edit and create_repo_group: diff --git a/rhodecode/templates/admin/settings/settings_user_quotas.mako b/rhodecode/templates/admin/settings/settings_user_quotas.mako new file mode 100644 index 00000000..93ce280b --- /dev/null +++ b/rhodecode/templates/admin/settings/settings_user_quotas.mako @@ -0,0 +1,33 @@ +
+
+

${_('Global User Quotas')}

+
+
+ ${h.secure_form(h.route_path('admin_settings_user_quotas_update'), id='user_quotas_form', request=request)} +
+ +
+
+ +
+
+ ${h.checkbox('rhodecode_user_quotas_enabled',value=True, checked=c.user_quotas_enabled==True)} +
+
+ +
+
+ +
+
+ ${h.text('rhodecode_global_user_repo_quotas', c.global_user_repo_quotas)} +
+
+ +
+ ${h.submit('save',_('Save'),class_="btn")} +
+ ${h.end_form()} +
+
+
From 0e08a98987539ac986e2f71a1e366b6e2a0c707d Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Fri, 14 Nov 2025 17:06:25 +0100 Subject: [PATCH 4/6] feature: implements user profile quotas update --- rhodecode/apps/admin/__init__.py | 21 ++++++++++ rhodecode/apps/admin/views/users.py | 42 +++++++++++++++++++ rhodecode/model/forms.py | 8 ++++ rhodecode/model/quota_model.py | 27 ++++++++++-- rhodecode/model/user.py | 7 ++++ .../admin/settings/settings_user_quotas.mako | 1 + .../templates/admin/users/user_edit.mako | 3 ++ .../admin/users/user_edit_user_quotas.mako | 34 +++++++++++++++ 8 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 rhodecode/templates/admin/users/user_edit_user_quotas.mako diff --git a/rhodecode/apps/admin/__init__.py b/rhodecode/apps/admin/__init__.py index 453a482a..d43e1077 100644 --- a/rhodecode/apps/admin/__init__.py +++ b/rhodecode/apps/admin/__init__.py @@ -961,6 +961,27 @@ def admin_routes(config): request_method="POST", ) + # user quotas + config.add_route(name="edit_user_quotas", pattern=r"/users/{user_id:\d+}/edit/quotas", user_route=True) + config.add_view( + UsersView, + attr="user_quotas", + route_name="edit_user_quotas", + request_method="GET", + renderer="rhodecode:templates/admin/users/user_edit.mako", + ) + + config.add_route( + name="edit_user_quotas_update", pattern=r"/users/{user_id:\d+}/edit/quotas/update", user_route=True + ) + config.add_view( + UsersView, + attr="user_quotas_update", + route_name="edit_user_quotas_update", + request_method="POST", + renderer="rhodecode:templates/admin/users/user_edit.mako", + ) + # user audit logs config.add_route(name="edit_user_audit_logs", pattern=r"/users/{user_id:\d+}/edit/audit", user_route=True) config.add_view( diff --git a/rhodecode/apps/admin/views/users.py b/rhodecode/apps/admin/views/users.py index 1dee66b8..f9c8f6b5 100644 --- a/rhodecode/apps/admin/views/users.py +++ b/rhodecode/apps/admin/views/users.py @@ -55,8 +55,10 @@ from rhodecode.model.forms import ( UserPermissionsForm, UserExtraEmailForm, UserExtraIpForm, + UserQuotasUpdateForm, ) from rhodecode.model.permission import PermissionModel +from rhodecode.model.quota_model import UserQuotaModel from rhodecode.model.repo_group import RepoGroupModel from rhodecode.model.ssh_key import SshKeyModel from rhodecode.model.user import UserModel @@ -283,6 +285,9 @@ class UsersView(UserAppView): ("zh", "Chinese (zh)"), ] + app_settings = c.rc_config + c.quotas_enabled = app_settings.get("rhodecode_user_quotas_enabled", UserQuotaModel.DEFAULT_QUOTAS_ENABLED) + c.allowed_extern_types = [(x.uid, x.get_display_name()) for x in self.get_auth_plugins()] perms = req.registry.settings.get("available_permissions") if not perms: @@ -1122,6 +1127,43 @@ class UsersView(UserAppView): return HTTPFound(h.route_path("edit_user_groups_management", user_id=user_id)) + @LoginRequired() + @HasPermissionAllDecorator("hg.admin") + def user_quotas(self): + _ = self.request.translate + c = self.load_default_context() + c.user = self.db_user + + quota_model = UserQuotaModel(c.user) + + c.active = "user_quotas" + c.user_repo_quotas = quota_model.formatted_user_max_repository_count_allowance() + c.user_repo_count = quota_model.user_repositories_count + + return self._get_template_context(c) + + @LoginRequired() + @HasPermissionAllDecorator("hg.admin") + def user_quotas_update(self): + _ = self.request.translate + c = self.load_default_context() + c.user = self.db_user + + c.active = "user_quotas" + + user_form = UserQuotasUpdateForm()() + try: + quota_model = UserQuotaModel(c.user) + form_result = user_form.to_python(dict(self.request.POST)) + quota_model.user_max_repository_count_allowance = form_result["user_repo_quotas"] + h.flash(_("Updated user '%s' repository quota") % c.user.username, category="success") + except Exception as e: + log.exception("Exception updating user quotas, %s", e) + h.flash( + _("Exception updating user quotas of user %s") % self.request.POST.get("username"), category="error" + ) + raise HTTPFound(h.route_path("edit_user_quotas", user_id=c.user.user_id)) + @LoginRequired() @HasPermissionAllDecorator("hg.admin") def user_audit_logs(self): diff --git a/rhodecode/model/forms.py b/rhodecode/model/forms.py index 8e68a252..31b7b9dd 100644 --- a/rhodecode/model/forms.py +++ b/rhodecode/model/forms.py @@ -715,3 +715,11 @@ def UserQuotasSettingsForm(): rhodecode_global_user_repo_quotas = v.Int(strip=True, required=True) return _UserQuotasSettingsForm + + +def UserQuotasUpdateForm(): + class _UserQuotasUpdateForm(formencode.Schema): + allow_extra_fields = True + user_repo_quotas = v.Int(strip=True, required=True) + + return _UserQuotasUpdateForm diff --git a/rhodecode/model/quota_model.py b/rhodecode/model/quota_model.py index aa8a2427..723f0bbb 100644 --- a/rhodecode/model/quota_model.py +++ b/rhodecode/model/quota_model.py @@ -10,7 +10,7 @@ log = logging.getLogger(__name__) class UserQuotaModel: - UNLIMITED = float("inf") + UNLIMITED = -1 DEFAULT_REPO_QUOTA = 5 DEFAULT_QUOTAS_ENABLED = False @@ -44,18 +44,37 @@ class UserQuotaModel: max_repository_count_allowance=repo_count_allowance, ) - def user_repositories_count(self): + @property + def user_repositories_count(self) -> int: return Session().scalar( select(func.count()).select_from(Repository).where(Repository.user_id == self.user.user_id) ) + @property + def user_max_repository_count_allowance(self) -> int: + quotas = self.user.get_quotas() + return quotas.get("max_repository_count_allowance") + + @user_max_repository_count_allowance.setter + def user_max_repository_count_allowance(self, new_value: int): + self.user.update_quotas(max_repository_count_allowance=new_value) + + def formatted_user_max_repository_count_allowance(self) -> str: + max_repos = self.user_max_repository_count_allowance + if max_repos is self.UNLIMITED: + return "Unlimited" + return str(max_repos) + def is_repo_creation_allowed(self): if not self.enabled: return True if self._user_quotas_valid(): - u_quotas = self.user.get_quotas() - return u_quotas.get("max_repository_count_allowance") > self.user_repositories_count() + if self.user_max_repository_count_allowance is self.UNLIMITED: + # since DB can't save float('inf'), we need to use a special case + return True + + return self.user_max_repository_count_allowance > self.user_repositories_count log.warning("User quotas not present, re-initialize quotas.") self.init_quotas() diff --git a/rhodecode/model/user.py b/rhodecode/model/user.py index bf3a8327..d02fd051 100644 --- a/rhodecode/model/user.py +++ b/rhodecode/model/user.py @@ -412,6 +412,13 @@ class UserModel(BaseModel): self.sa.add(new_user) + # TODO: fix tests and uncomment this, because tests launching pyramid app, this part fails during + # fixture setup, in normal app this works as intended + # user quotas will be lazily initialized anyway, but this is workaround + # if new_user.is_new_user: + # qm = UserQuotaModel(new_user) + # qm.init_quotas() + if not edit and create_repo_group: RepoGroupModel().create_personal_repo_group(new_user, commit_early=False) diff --git a/rhodecode/templates/admin/settings/settings_user_quotas.mako b/rhodecode/templates/admin/settings/settings_user_quotas.mako index 93ce280b..63c17034 100644 --- a/rhodecode/templates/admin/settings/settings_user_quotas.mako +++ b/rhodecode/templates/admin/settings/settings_user_quotas.mako @@ -21,6 +21,7 @@
${h.text('rhodecode_global_user_repo_quotas', c.global_user_repo_quotas)} +

${_('If you want to set an unlimited quota, enter -1.')}

diff --git a/rhodecode/templates/admin/users/user_edit.mako b/rhodecode/templates/admin/users/user_edit.mako index 1f67bb69..1fef54cc 100644 --- a/rhodecode/templates/admin/users/user_edit.mako +++ b/rhodecode/templates/admin/users/user_edit.mako @@ -46,6 +46,9 @@
  • ${_('User Groups Management')}
  • ${_('Audit logs')}
  • ${_('Caches')}
  • + %if c.quotas_enabled: +
  • ${_('User Quotas')}
  • + %endif diff --git a/rhodecode/templates/admin/users/user_edit_user_quotas.mako b/rhodecode/templates/admin/users/user_edit_user_quotas.mako new file mode 100644 index 00000000..79ca3b81 --- /dev/null +++ b/rhodecode/templates/admin/users/user_edit_user_quotas.mako @@ -0,0 +1,34 @@ +
    +
    +

    ${_('User Quotas')}

    +
    +
    + ${h.secure_form(h.route_path('edit_user_quotas_update', user_id=c.user.user_id), id='user_quotas_form', request=request)} +
    + +
    +
    + +
    +
    + ${h.text('user_repo_count', c.user_repo_count, readonly=True, class_='no-border')} +
    +
    + +
    +
    + +
    +
    + ${h.text('user_repo_quotas', c.user_repo_quotas)} +

    ${_('If you want to set an unlimited quota, enter -1.')}

    +
    +
    + +
    + ${h.submit('save',_('Save'),class_="btn")} +
    + ${h.end_form()} +
    +
    +
    From 3a8d727e1ab168c078a4db5fbc08ba74ca1d2b2f Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Sat, 15 Nov 2025 17:23:53 +0100 Subject: [PATCH 5/6] feature: tweak if statement --- rhodecode/model/quota_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rhodecode/model/quota_model.py b/rhodecode/model/quota_model.py index 723f0bbb..1f3d0fb3 100644 --- a/rhodecode/model/quota_model.py +++ b/rhodecode/model/quota_model.py @@ -61,7 +61,7 @@ class UserQuotaModel: def formatted_user_max_repository_count_allowance(self) -> str: max_repos = self.user_max_repository_count_allowance - if max_repos is self.UNLIMITED: + if max_repos == self.UNLIMITED: return "Unlimited" return str(max_repos) From 1b193f7210fe97ad9297678340517cfced2e67a7 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Sat, 15 Nov 2025 17:24:17 +0100 Subject: [PATCH 6/6] feature: tweak if statement --- rhodecode/model/quota_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rhodecode/model/quota_model.py b/rhodecode/model/quota_model.py index 1f3d0fb3..6d2218ef 100644 --- a/rhodecode/model/quota_model.py +++ b/rhodecode/model/quota_model.py @@ -70,7 +70,7 @@ class UserQuotaModel: return True if self._user_quotas_valid(): - if self.user_max_repository_count_allowance is self.UNLIMITED: + if self.user_max_repository_count_allowance == self.UNLIMITED: # since DB can't save float('inf'), we need to use a special case return True