refactoring: implements setting up global repo quotas UI for super admin

This commit is contained in:
ievgenii vdovenko 2025-11-13 16:02:18 +01:00
parent c4db0d33e5
commit a960c9f99c
11 changed files with 205 additions and 55 deletions

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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,

View file

@ -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(

View file

@ -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()

View file

@ -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")

View file

@ -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

View file

@ -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(

View file

@ -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:

View file

@ -0,0 +1,33 @@
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">${_('Global User Quotas')}</h3>
</div>
<div class="panel-body">
${h.secure_form(h.route_path('admin_settings_user_quotas_update'), id='user_quotas_form', request=request)}
<div class="fields">
<div class="field" id="user-quotas-toggle">
<div class="label label-checkbox">
<label for="rhodecode_user_quotas_enabled">${_('Enable User Quotas')}:</label>
</div>
<div class="checkboxes">
${h.checkbox('rhodecode_user_quotas_enabled',value=True, checked=c.user_quotas_enabled==True)}
</div>
</div>
<div class="field" id="rhodecode_global_user_repo_quotas">
<div class="label label">
<label for="rhodecode_global_user_repo_quotas">${_('Maximum repositories per user')}</label>
</div>
<div class="input">
${h.text('rhodecode_global_user_repo_quotas', c.global_user_repo_quotas)}
</div>
</div>
<div class="buttons">
${h.submit('save',_('Save'),class_="btn")}
</div>
${h.end_form()}
</div>
</div>
</div>