1
0
mirror of https://github.com/quay/quay.git synced 2025-07-30 07:43:13 +03:00

api: update the quota api so that it's more consistent with the other apis endpoints (PROJQUAY-2936) (#1221)

* api: update the quota api so that it's more consistent with the other apis (PROJQUAY-2936)

- Uodate the quota api to be more consistent with the rest of the
endpoints
- Handles some uncaught exceptions, such as division by zero
- Update some of the quota data models used by the api to take object
  references instead of names to make it easier to use
- Update table model naming conventions
- swagger operationid multiple nicknames
- Added more test cases for api
- Remove unused functions
- Update the UI for better UX, based on the api changes made

* quota: fix ui input form value

* quota: join quota type query

* Remove unused functions
This commit is contained in:
Kenny Lee Sin Cheong
2022-04-07 14:11:55 -04:00
committed by GitHub
parent a79f7b6f40
commit 896a3aab3a
27 changed files with 1339 additions and 1004 deletions

View File

@ -769,18 +769,23 @@ class RobotAccountToken(BaseModel):
fully_migrated = BooleanField(default=False)
class QuotaTypes(object):
WARNING = "Warning"
REJECT = "Reject"
class QuotaType(BaseModel):
name = CharField()
class UserOrganizationQuota(BaseModel):
namespace_id = QuayUserField(index=True, unique=True)
namespace = QuayUserField(index=True, unique=True)
limit_bytes = BigIntegerField()
class QuotaLimits(BaseModel):
quota_id = ForeignKeyField(UserOrganizationQuota)
quota_type_id = ForeignKeyField(QuotaType)
quota = ForeignKeyField(UserOrganizationQuota)
quota_type = ForeignKeyField(QuotaType)
percent_of_limit = IntegerField(default=0)

View File

@ -121,6 +121,18 @@ class QuotaExceededException(DataModelException):
pass
class InvalidNamespaceQuota(DataModelException):
pass
class InvalidNamespaceQuotaLimit(DataModelException):
pass
class InvalidNamespaceQuotaType(DataModelException):
pass
class TooManyLoginAttemptsException(Exception):
def __init__(self, message, retry_after):
super(TooManyLoginAttemptsException, self).__init__(message)

View File

@ -12,18 +12,145 @@ from data.database import (
Tag,
RepositorySize,
User,
QuotaTypes,
)
from data import model
from data.model import (
db_transaction,
organization,
user,
InvalidUsernameException,
InvalidOrganizationException,
notification,
config,
InvalidSystemQuotaConfig,
InvalidNamespaceQuota,
InvalidNamespaceQuotaLimit,
InvalidNamespaceQuotaType,
)
def get_namespace_quota_list(namespace_name):
quotas = UserOrganizationQuota.select().join(User).where(User.username == namespace_name)
return list(quotas)
def get_namespace_quota(namespace_name, quota_id):
quota = (
UserOrganizationQuota.select()
.join(User)
.where(
User.username == namespace_name,
UserOrganizationQuota.id == quota_id,
)
)
quota = quota.first()
return quota
def create_namespace_quota(namespace_user, limit_bytes):
try:
UserOrganizationQuota.get(id == namespace_user.id)
raise InvalidNamespaceQuota("Only one quota per namespace is currently supported")
except UserOrganizationQuota.DoesNotExist:
pass
if limit_bytes > 0:
try:
return UserOrganizationQuota.create(namespace=namespace_user, limit_bytes=limit_bytes)
except model.DataModelException as ex:
return None
else:
raise InvalidNamespaceQuota("Invalid quota size limit value: '%s'" % limit_bytes)
def update_namespace_quota_size(quota, limit_bytes):
if limit_bytes > 0:
quota.limit_bytes = limit_bytes
quota.save()
else:
raise InvalidNamespaceQuota("Invalid quota size limit value: '%s'" % limit_bytes)
def delete_namespace_quota(quota):
with db_transaction():
QuotaLimits.delete().where(QuotaLimits.quota == quota).execute()
quota.delete_instance()
def _quota_type(type_name):
if type_name.lower() == "warning":
return QuotaTypes.WARNING
elif type_name.lower() == "reject":
return QuotaTypes.REJECT
raise InvalidNamespaceQuotaType(
"Quota type must be one of [{}, {}]".format(QuotaTypes.WARNING, QuotaTypes.REJECT)
)
def get_namespace_quota_limit_list(quota, quota_type=None, percent_of_limit=None):
if percent_of_limit and (not percent_of_limit > 0 or not percent_of_limit <= 100):
raise InvalidNamespaceQuotaLimit("Quota limit threshold must be between 1 and 100")
query = QuotaLimits.select().join(QuotaType).where(QuotaLimits.quota == quota)
if quota_type:
quota_type_name = _quota_type(quota_type)
query = query.where(QuotaType.name == quota_type_name)
if percent_of_limit:
query = query.where(QuotaLimits.percent_of_limit == percent_of_limit)
return list(query)
def get_namespace_quota_limit(quota, limit_id):
try:
quota_limit = QuotaLimits.get(QuotaLimits.id == limit_id)
# This should never happen in theory, as limit ids should be globally unique
if quota_limit.quota != quota:
raise InvalidNamespaceQuota()
return quota_limit
except QuotaLimits.DoesNotExist:
return None
def create_namespace_quota_limit(quota, quota_type, percent_of_limit):
if not percent_of_limit > 0 or not percent_of_limit <= 100:
raise InvalidNamespaceQuotaLimit("Quota limit threshold must be between 1 and 100")
quota_type_name = _quota_type(quota_type)
return QuotaLimits.create(
quota=quota,
percent_of_limit=percent_of_limit,
quota_type=(QuotaType.get(QuotaType.name == quota_type_name)),
)
def update_namespace_quota_limit_threshold(limit, percent_of_limit):
if not percent_of_limit > 0 or not percent_of_limit <= 100:
raise InvalidNamespaceQuotaLimit("Quota limit threshold must be between 1 and 100")
limit.percent_of_limit = percent_of_limit
limit.save()
def update_namespace_quota_limit_type(limit, type_name):
quota_type_name = _quota_type(type_name)
quota_type_ref = QuotaType.get(name=quota_type_name)
limit.quota_type = quota_type_ref
limit.save()
def delete_namespace_quota_limit(limit):
limit.delete_instance()
def verify_namespace_quota(repository_ref):
model.repository.get_repository_size_and_cache(repository_ref._db_id)
namespace_size = get_namespace_size(repository_ref.namespace_name)
@ -46,14 +173,23 @@ def verify_namespace_quota_during_upload(repository_ref):
def check_limits(namespace_name, size):
limits = get_namespace_limits(namespace_name)
namespace_user = model.user.get_user_or_org(namespace_name)
quotas = get_namespace_quota_list(namespace_user.username)
if not quotas:
return {"limit_bytes": 0, "severity_level": None}
# Currently only one quota per namespace is supported
quota = quotas[0]
limits = get_namespace_quota_limit_list(quota)
limit_bytes = 0
severity_level = None
for limit in limits:
if size > limit["bytes_allowed"]:
if limit_bytes < limit["bytes_allowed"]:
limit_bytes = limit["bytes_allowed"]
severity_level = limit["name"]
bytes_allowed = int(limit.quota.limit_bytes * limit.percent_of_limit / 100)
if size > bytes_allowed:
if limit_bytes < bytes_allowed:
limit_bytes = bytes_allowed
severity_level = limit.quota_type.name
return {"limit_bytes": limit_bytes, "severity_level": severity_level}
@ -82,241 +218,6 @@ def force_cache_repo_size(repository_ref):
return model.repository.force_cache_repo_size(repository_ref._db_id)
def create_namespace_quota(name, limit_bytes):
user_object = user.get_namespace_user(name)
try:
return UserOrganizationQuota.create(namespace_id=user_object.id, limit_bytes=limit_bytes)
except model.DataModelException as ex:
return None
def create_namespace_limit(orgname, quota_type_id, percent_of_limit):
quota = get_namespace_quota(orgname)
if quota is None:
raise InvalidUsernameException("Quota Does Not Exist for : " + orgname)
new_limit = QuotaLimits.create(
quota_id=quota.id,
percent_of_limit=percent_of_limit,
quota_type_id=quota_type_id,
)
return new_limit
def get_namespace_quota(name):
try:
space = user.get_namespace_user(name)
if space is None:
raise InvalidUsernameException("This Namespace does not exist : " + name)
quota = UserOrganizationQuota.select().where(UserOrganizationQuota.namespace_id == space.id)
# TODO: I dont like this so we will need to find a better way to test if the query is empty.
return quota.get()
except UserOrganizationQuota.DoesNotExist:
return None
def check_system_quota_bytes_enabled():
if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") < 0:
raise InvalidSystemQuotaConfig(
"Invalid Configuration: Quota bytes must be greater than or equal to 0"
)
if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0:
return True
else:
return False
def get_namespace_limits(name):
query = (
UserOrganizationQuota.select(
UserOrganizationQuota.limit_bytes,
QuotaLimits.percent_of_limit,
QuotaLimits.quota_id,
QuotaLimits.id,
QuotaType.name,
QuotaType.id,
(
UserOrganizationQuota.limit_bytes.cast("decimal")
* (QuotaLimits.percent_of_limit.cast("decimal") / 100.0).cast("decimal")
).alias("bytes_allowed"),
QuotaType.id.alias("type_id"),
)
.join(User, on=(UserOrganizationQuota.namespace_id == User.id))
.join(QuotaLimits, on=(UserOrganizationQuota.id == QuotaLimits.quota_id))
.join(QuotaType, on=(QuotaLimits.quota_type_id == QuotaType.id))
.where(User.username == name)
).dicts()
# define limits if a system default is defined in config.py and no namespace specific limits are set
if check_system_quota_bytes_enabled() and len(query) == 0:
query = [
{
"limit_bytes": config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES"),
"percent_of_limit": 80,
"name": "System Warning Limit",
"bytes_allowed": config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") * 0.8,
"type_id": 1,
},
{
"limit_bytes": config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES"),
"percent_of_limit": 100,
"name": "System Reject Limit",
"bytes_allowed": config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES"),
"type_id": 2,
},
]
return query
def get_namespace_limit(name, quota_type_id, percent_of_limit):
try:
quota = get_namespace_quota(name)
if quota is None:
raise InvalidUsernameException("Quota for this namespace does not exist")
query = (
QuotaLimits.select()
.join(QuotaType)
.where(QuotaLimits.quota_id == quota.id)
.where(QuotaLimits.quota_type_id == quota_type_id)
.where(QuotaLimits.percent_of_limit == percent_of_limit)
)
return query.get()
except QuotaLimits.DoesNotExist:
return None
def get_namespace_limit_from_id(name, quota_limit_id):
try:
quota = get_namespace_quota(name)
if quota is None:
raise InvalidUsernameException("Quota for this namespace does not exist")
query = (
QuotaLimits.select()
.join(QuotaType)
.where(QuotaLimits.quota_id == quota.id)
.where(QuotaLimits.id == quota_limit_id)
)
return query.get()
except QuotaLimits.DoesNotExist:
return None
def get_namespace_reject_limit(name):
try:
quota = get_namespace_quota(name)
if quota is None:
raise InvalidUsernameException("Quota for this namespace does not exist")
# QuotaType
query = (
QuotaLimits.select()
.join(QuotaType)
.where(QuotaType.name == "Reject")
.where(QuotaLimits.quota_id == quota.id)
)
return query.get()
except QuotaLimits.DoesNotExist:
return None
def get_namespace_limit_types():
return [{"quota_type_id": qtype.id, "name": qtype.name} for qtype in QuotaType.select()]
def fetch_limit_id_from_name(name):
for i in get_namespace_limit_types():
if name == i["name"]:
return i["quota_type_id"]
return None
def is_reject_limit_type(quota_type_id):
if quota_type_id == fetch_limit_id_from_name("Reject"):
return True
return False
def get_namespace_limit_types_for_id(quota_limit_type_id):
return QuotaType.select().where(QuotaType.id == quota_limit_type_id).get()
def get_namespace_limit_types_for_name(name):
return QuotaType.select().where(QuotaType.name == name).get()
def change_namespace_quota(name, limit_bytes):
org = user.get_namespace_user(name)
quota = UserOrganizationQuota.select().where(UserOrganizationQuota.namespace_id == org.id).get()
quota.limit_bytes = limit_bytes
quota.save()
return quota
def change_namespace_quota_limit(name, percent_of_limit, quota_type_id, quota_limit_id):
quota_limit = get_namespace_limit_from_id(name, quota_limit_id)
quota_limit.percent_of_limit = percent_of_limit
quota_limit.quota_type_id = quota_type_id
quota_limit.save()
return quota_limit
def delete_namespace_quota_limit(name, quota_limit_id):
quota_limit = get_namespace_limit_from_id(name, quota_limit_id)
if quota_limit is not None:
quota_limit.delete_instance()
return 1
return 0
def delete_all_namespace_quota_limits(quota):
return QuotaLimits.delete().where(QuotaLimits.quota_id == quota.id).execute()
def delete_namespace_quota(name):
org = user.get_namespace_user(name)
try:
quota = (
UserOrganizationQuota.select().where(UserOrganizationQuota.namespace_id == org.id)
).get()
except UserOrganizationQuota.DoesNotExist:
return 0
if quota is not None:
delete_all_namespace_quota_limits(quota)
UserOrganizationQuota.delete().where(UserOrganizationQuota.namespace_id == org.id).execute()
return 1
return 0
def get_namespace_repository_sizes_and_cache(namespace_name):
return cache_namespace_repository_sizes(namespace_name)
def cache_namespace_repository_sizes(namespace_name):
namespace = user.get_user_or_org(namespace_name)
now_ms = get_epoch_timestamp_ms()
@ -352,19 +253,6 @@ def cache_namespace_repository_sizes(namespace_name):
fields=[RepositorySize.repository_id, RepositorySize.size_bytes],
).execute()
output = []
for size in namespace_repo_sizes.dicts():
output.append(
{
"repository_name": size["repository_name"],
"repository_id": size["repository_id"],
"repository_size": str(size["repository_size"]),
}
)
return json.dumps(output)
def get_namespace_size(namespace_name):
namespace = user.get_user_or_org(namespace_name)
@ -388,33 +276,51 @@ def get_namespace_size(namespace_name):
return namespace_size
def get_repo_quota_for_view(repo_id, namespace):
repo_quota = model.repository.get_repository_size_and_cache(repo_id).get("repository_size", 0)
namespace_quota = get_namespace_quota(namespace)
percent_consumed = None
if namespace_quota:
percent_consumed = str(round((repo_quota / namespace_quota.limit_bytes) * 100, 2))
def get_repo_quota_for_view(namespace_name, repo_name):
repository_ref = model.repository.get_repository(namespace_name, repo_name)
if not repository_ref:
return None
quotas = get_namespace_quota_list(repository_ref.namespace_user.username)
if not quotas:
return {
"percent_consumed": percent_consumed,
"quota_bytes": repo_quota,
"quota_bytes": None,
"configured_quota": None,
}
# Currently only one quota per namespace is supported
quota = quotas[0]
configured_namespace_quota = quota.limit_bytes
def get_org_quota_for_view(namespace):
namespace_quota_consumed = get_namespace_size(namespace) or 0
configured_namespace_quota = get_namespace_quota(namespace)
configured_namespace_quota = (
configured_namespace_quota.limit_bytes if configured_namespace_quota else None
)
percent_consumed = None
if configured_namespace_quota:
percent_consumed = str(
round((namespace_quota_consumed / configured_namespace_quota) * 100, 2)
repo_size = model.repository.get_repository_size_and_cache(repository_ref.id).get(
"repository_size", 0
)
return {
"percent_consumed": percent_consumed,
"quota_bytes": str(namespace_quota_consumed),
"quota_bytes": repo_size,
"configured_quota": configured_namespace_quota,
}
def get_quota_for_view(namespace_name):
cache_namespace_repository_sizes(namespace_name)
namespace_user = model.user.get_user_or_org(namespace_name)
quotas = get_namespace_quota_list(namespace_user.username)
if not quotas:
return {
"quota_bytes": None,
"configured_quota": None,
}
# Currently only one quota per namespace is supported
quota = quotas[0]
configured_namespace_quota = quota.limit_bytes
namespace_quota_consumed = get_namespace_size(namespace_name)
namespace_quota_consumed = int(namespace_quota_consumed) if namespace_quota_consumed else 0
return {
"quota_bytes": namespace_quota_consumed,
"configured_quota": configured_namespace_quota,
}

View File

@ -16,7 +16,8 @@ def test_create_quota(initialized_db):
limit_bytes = 2048
new_org = create_org(user_name, user_email, org_name, org_email)
new_quota = namespacequota.create_namespace_quota(org_name, limit_bytes)
new_quota = namespacequota.create_namespace_quota(new_org, limit_bytes)
assert new_quota.limit_bytes == limit_bytes
assert new_quota.namespace_id.id == new_org.id
assert new_quota.namespace == new_org
assert new_quota.namespace.id == new_org.id

View File

@ -1291,7 +1291,9 @@ def _delete_user_linked_data(user):
trigger.delete_instance(recursive=True, delete_nullable=False)
with db_transaction():
namespacequota.delete_namespace_quota(user.username)
quotas = namespacequota.get_namespace_quota_list(user.username)
for quota in quotas:
namespacequota.delete_namespace_quota(quota)
# Delete any mirrors with robots owned by this user.
with db_transaction():

View File

@ -131,7 +131,24 @@ def swagger_route_data(include_internal=False, compact=False):
logger.debug("Unable to find method for %s in class %s", method_name, view_class)
continue
operationId = method_metadata(method, "nickname")
_operationId = method_metadata(method, "nickname")
if isinstance(_operationId, list):
operationId = None
for oid in _operationId:
if oid in operationIds:
continue
else:
operationId = oid
break
if operationId is None:
raise Exception("Duplicate operation Id: %s" % operationId)
else:
operationId = _operationId
operation_swagger = {
"operationId": operationId,
"parameters": [],

View File

@ -1,7 +1,3 @@
"""
Manage organizations, members and OAuth applications.
"""
import logging
from flask import request
@ -13,7 +9,9 @@ from auth.permissions import (
OrganizationMemberPermission,
UserReadPermission,
)
from auth.auth_context import get_authenticated_user
from data import model
from data.database import QuotaTypes
from data.model import config
from endpoints.api import (
resource,
@ -22,45 +20,45 @@ from endpoints.api import (
validate_json_request,
request_error,
require_user_admin,
require_scope,
show_if,
log_action,
)
from endpoints.exception import InvalidToken, Unauthorized
from endpoints.exception import InvalidToken, Unauthorized, NotFound
from auth import scopes
logger = logging.getLogger(__name__)
def quota_view(orgname: str, quota, quota_limit_types):
def quota_view(quota):
quota_limits = list(model.namespacequota.get_namespace_quota_limit_list(quota))
return {
"orgname": orgname,
"limit_bytes": quota.limit_bytes if quota else None,
"quota_limit_types": quota_limit_types,
"id": quota.id,
"limit_bytes": quota.limit_bytes,
"limits": [limit_view(limit) for limit in quota_limits],
}
def quota_limit_view(orgname: str, quota_limit):
def limit_view(limit):
return {
"percent_of_limit": quota_limit["percent_of_limit"],
"limit_type": {
"name": quota_limit["name"],
"quota_limit_id": quota_limit["id"],
"quota_type_id": quota_limit["type_id"],
},
"id": limit.id,
"type": limit.quota_type.name,
"limit_percent": limit.percent_of_limit,
}
def namespace_size_view(orgname: str, repo):
return {
"orgname": orgname,
"repository_name": repo.name,
"repository_size": repo.repositorysize.size_bytes,
"repository_id": repo.id,
}
def get_quota(namespace_name, quota_id):
quota = model.namespacequota.get_namespace_quota(namespace_name, quota_id)
if quota is None:
raise NotFound()
return quota
@resource("/v1/namespacequota/<namespace>/quota")
@resource("/v1/organization/<orgname>/quota")
@show_if(features.QUOTA_MANAGEMENT)
class OrganizationQuota(ApiResource):
class OrganizationQuotaList(ApiResource):
schemas = {
"NewOrgQuota": {
"type": "object",
@ -75,252 +73,323 @@ class OrganizationQuota(ApiResource):
},
}
@nickname("getNamespaceQuota")
def get(self, namespace):
orgperm = OrganizationMemberPermission(namespace)
userperm = UserReadPermission(namespace)
if not orgperm.can() and not userperm.can():
@nickname("listOrganizationQuota")
def get(self, orgname):
orgperm = OrganizationMemberPermission(orgname)
if not orgperm.can():
raise Unauthorized()
quota = model.namespacequota.get_namespace_quota(namespace)
quota_limit_types = model.namespacequota.get_namespace_limit_types()
return quota_view(namespace, quota, quota_limit_types)
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
@nickname("createNamespaceQuota")
quotas = model.namespacequota.get_namespace_quota_list(orgname)
return [quota_view(quota) for quota in quotas]
@nickname("createOrganizationQuota")
@validate_json_request("NewOrgQuota")
def post(self, namespace):
def post(self, orgname):
"""
Create a new organization quota.
"""
orgperm = AdministerOrganizationPermission(namespace)
superperm = SuperUserPermission()
orgperm = AdministerOrganizationPermission(orgname)
if not superperm.can():
if orgperm.can():
if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0:
raise Unauthorized()
else:
if not features.SUPER_USERS or not SuperUserPermission().can():
if (
not orgperm.can()
or not config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0
):
raise Unauthorized()
quota_data = request.get_json()
quota = model.namespacequota.get_namespace_quota(namespace)
if quota is not None:
msg = "quota already exists"
raise request_error(message=msg)
limit_bytes = quota_data["limit_bytes"]
try:
newquota = model.namespacequota.create_namespace_quota(
name=namespace, limit_bytes=quota_data["limit_bytes"]
)
if newquota is not None:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
# Currently only supporting one quota definition per namespace
quotas = model.namespacequota.get_namespace_quota_list(orgname)
if quotas:
raise request_error(message="Organization quota for '%s' already exists" % orgname)
try:
model.namespacequota.create_namespace_quota(org, limit_bytes)
return "Created", 201
else:
raise request_error("Quota Failed to Create")
except model.DataModelException as ex:
raise request_error(exception=ex)
@resource("/v1/organization/<orgname>/quota/<quota_id>")
@show_if(features.QUOTA_MANAGEMENT)
class OrganizationQuota(ApiResource):
schemas = {
"UpdateOrgQuota": {
"type": "object",
"description": "Description of a new organization quota",
"properties": {
"limit_bytes": {
"type": "integer",
"description": "Number of bytes the organization is allowed",
},
},
},
}
@nickname("getOrganizationQuota")
def get(self, orgname, quota_id):
orgperm = OrganizationMemberPermission(orgname)
if not orgperm.can():
raise Unauthorized()
quota = get_quota(orgname, quota_id)
return quota_view(quota)
@nickname("changeOrganizationQuota")
@validate_json_request("NewOrgQuota")
def put(self, namespace):
@validate_json_request("UpdateOrgQuota")
def put(self, orgname, quota_id):
orgperm = AdministerOrganizationPermission(orgname)
superperm = SuperUserPermission()
if not superperm.can():
if not features.SUPER_USERS or not SuperUserPermission().can():
if not orgperm.can():
raise Unauthorized()
quota_data = request.get_json()
quota = model.namespacequota.get_namespace_quota(namespace)
if quota is None:
msg = "quota does not exist"
raise request_error(message=msg)
quota = get_quota(orgname, quota_id)
try:
model.namespacequota.change_namespace_quota(namespace, quota_data["limit_bytes"])
return "Updated", 201
if "limit_bytes" in quota_data:
limit_bytes = quota_data["limit_bytes"]
model.namespacequota.update_namespace_quota_size(quota, limit_bytes)
except model.DataModelException as ex:
raise request_error(exception=ex)
return quota_view(quota)
@nickname("deleteOrganizationQuota")
def delete(self, namespace):
superperm = SuperUserPermission()
def delete(self, orgname, quota_id):
orgperm = AdministerOrganizationPermission(orgname)
if not superperm.can():
if not features.SUPER_USERS or not SuperUserPermission().can():
if not orgperm.can():
raise Unauthorized()
quota = model.namespacequota.get_namespace_quota(namespace)
quota = get_quota(orgname, quota_id)
if quota is None:
msg = "quota does not exist"
raise request_error(message=msg)
# Exceptions by`delete_instance` are unexpected and raised
model.namespacequota.delete_namespace_quota(quota)
try:
success = model.namespacequota.delete_namespace_quota(namespace)
if success == 1:
return "Deleted", 201
msg = "quota failed to delete"
raise request_error(message=msg)
except model.DataModelException as ex:
raise request_error(exception=ex)
return "", 204
@resource("/v1/namespacequota/<namespace>/quotalimits")
@resource("/v1/organization/<orgname>/quota/<quota_id>/limit")
@show_if(features.QUOTA_MANAGEMENT)
class OrganizationQuotaLimits(ApiResource):
class OrganizationQuotaLimitList(ApiResource):
schemas = {
"NewOrgQuotaLimit": {
"type": "object",
"description": "Description of a new organization quota limit threshold",
"required": ["percent_of_limit", "quota_type_id"],
"description": "Description of a new organization quota limit",
"required": ["type", "threshold_percent"],
"properties": {
"percent_of_limit": {
"type": "integer",
"description": "Percentage of quota at which to do something",
"type": {
"type": "string",
"description": 'Type of quota limit: "Warning" or "Reject"',
},
"quota_type_id": {
"threshold_percent": {
"type": "integer",
"description": "Quota type Id",
"description": "Quota threshold, in percent of quota",
},
},
},
}
@nickname("listOrganizationQuotaLimit")
def get(self, orgname, quota_id):
orgperm = OrganizationMemberPermission(orgname)
if not orgperm.can():
raise Unauthorized()
quota = get_quota(orgname, quota_id)
return [
limit_view(limit)
for limit in model.namespacequota.get_namespace_quota_limit_list(quota)
]
@nickname("createOrganizationQuotaLimit")
@validate_json_request("NewOrgQuotaLimit")
def post(self, orgname, quota_id):
orgperm = AdministerOrganizationPermission(orgname)
if not features.SUPER_USERS or not SuperUserPermission().can():
if (
not orgperm.can()
or not config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0
):
raise Unauthorized()
quota_limit_data = request.get_json()
quota_type = quota_limit_data["type"]
quota_limit_threshold = quota_limit_data["threshold_percent"]
quota = get_quota(orgname, quota_id)
quota_limit = model.namespacequota.get_namespace_quota_limit_list(
quota,
quota_type=quota_type,
percent_of_limit=quota_limit_threshold,
)
if quota_limit:
msg = "Quota limit already exists"
raise request_error(message=msg)
if quota_limit_data["type"].lower() == "reject" and quota_limit:
raise request_error(message="Only one quota limit of type 'Reject' allowed.")
try:
model.namespacequota.create_namespace_quota_limit(
quota,
quota_type,
quota_limit_threshold,
)
return "Created", 201
except model.DataModelException as ex:
raise request_error(exception=ex)
@resource("/v1/organization/<orgname>/quota/<quota_id>/limit/<limit_id>")
@show_if(features.QUOTA_MANAGEMENT)
class OrganizationQuotaLimit(ApiResource):
schemas = {
"UpdateOrgQuotaLimit": {
"type": "object",
"description": "Description of changing organization quota limit",
"properties": {
"type": {
"type": "string",
"description": 'Type of quota limit: "Warning" or "Reject"',
},
"threshold_percent": {
"type": "integer",
"description": "Quota threshold, in percent of quota",
},
},
},
}
@nickname("getOrganizationQuotaLimit")
def get(self, namespace):
orgperm = OrganizationMemberPermission(namespace)
userperm = UserReadPermission(namespace)
if not orgperm.can() and not userperm.can():
def get(self, orgname, quota_id, limit_id):
orgperm = OrganizationMemberPermission(orgname)
if not orgperm.can():
raise Unauthorized()
quota_limits = list(model.namespacequota.get_namespace_limits(namespace))
quota = get_quota(orgname, quota_id)
quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id)
if quota_limit is None:
raise NotFound()
return {"quota_limits": [quota_limit_view(namespace, limit) for limit in quota_limits]}, 200
@nickname("createOrganizationQuotaLimit")
@validate_json_request("NewOrgQuotaLimit")
def post(self, namespace):
"""
Create a new organization quota.
"""
orgperm = AdministerOrganizationPermission(namespace)
superperm = SuperUserPermission()
if not superperm.can():
if orgperm.can():
if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0:
raise Unauthorized()
else:
raise Unauthorized()
quota_limit_data = request.get_json()
quota = model.namespacequota.get_namespace_limit(
namespace, quota_limit_data["quota_type_id"], quota_limit_data["percent_of_limit"]
)
if quota is not None:
msg = "quota limit already exists"
raise request_error(message=msg)
reject_quota = model.namespacequota.get_namespace_reject_limit(namespace)
if reject_quota is not None and model.namespacequota.is_reject_limit_type(
quota_limit_data["quota_type_id"]
):
msg = "You can only have one Reject type of quota limit"
raise request_error(message=msg)
try:
model.namespacequota.create_namespace_limit(
orgname=namespace,
percent_of_limit=quota_limit_data["percent_of_limit"],
quota_type_id=quota_limit_data["quota_type_id"],
)
return "Created", 201
except model.DataModelException as ex:
raise request_error(exception=ex)
return limit_view(quota_limit)
@nickname("changeOrganizationQuotaLimit")
@validate_json_request("NewOrgQuotaLimit")
def put(self, namespace):
@validate_json_request("UpdateOrgQuotaLimit")
def put(self, orgname, quota_id, limit_id):
orgperm = AdministerOrganizationPermission(orgname)
superperm = SuperUserPermission()
if not superperm.can():
if not features.SUPER_USERS or not SuperUserPermission().can():
if not orgperm.can():
raise Unauthorized()
quota_limit_data = request.get_json()
try:
quota_limit_id = quota_limit_data["quota_limit_id"]
except KeyError:
msg = "Must supply quota_limit_id for updates"
raise request_error(message=msg)
quota = get_quota(orgname, quota_id)
quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id)
if quota_limit is None:
raise NotFound()
quota = model.namespacequota.get_namespace_limit_from_id(namespace, quota_limit_id)
if "type" in quota_limit_data:
new_type = quota_limit_data["type"]
model.namespacequota.update_namespace_quota_limit_type(quota_limit, new_type)
if "threshold_percent" in quota_limit_data:
new_threshold = quota_limit_data["threshold_percent"]
model.namespacequota.update_namespace_quota_limit_threshold(quota_limit, new_threshold)
if quota is None:
msg = "quota limit does not exist"
raise request_error(message=msg)
try:
model.namespacequota.change_namespace_quota_limit(
namespace,
quota_limit_data["percent_of_limit"],
quota_limit_data["quota_type_id"],
quota_limit_data["quota_limit_id"],
)
return "Updated", 201
except model.DataModelException as ex:
raise request_error(exception=ex)
return quota_view(quota)
@nickname("deleteOrganizationQuotaLimit")
def delete(self, namespace):
def delete(self, orgname, quota_id, limit_id):
orgperm = AdministerOrganizationPermission(orgname)
superperm = SuperUserPermission()
if not superperm.can():
if not features.SUPER_USERS or not SuperUserPermission().can():
if not orgperm.can():
raise Unauthorized()
quota_limit_id = request.args.get("quota_limit_id", None)
if quota_limit_id is None:
msg = "Bad request to delete quota limit. Missing quota limit identifier."
raise request_error(message=msg)
quota = model.namespacequota.get_namespace_limit_from_id(namespace, quota_limit_id)
if quota is None:
msg = "quota does not exist"
raise request_error(message=msg)
quota = get_quota(orgname, quota_id)
quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id)
if quota_limit is None:
raise NotFound()
try:
success = model.namespacequota.delete_namespace_quota_limit(namespace, quota_limit_id)
if success == 1:
return "Deleted", 201
msg = "quota failed to delete"
raise request_error(message=msg)
# Exceptions by`delete_instance` are unexpected and raised
model.namespacequota.delete_namespace_quota_limit(quota_limit)
return "", 204
except model.DataModelException as ex:
raise request_error(exception=ex)
@resource("/v1/namespacequota/<namespace>/quotareport")
@resource("/v1/user/quota")
@show_if(features.QUOTA_MANAGEMENT)
class OrganizationQuotaReport(ApiResource):
@nickname("getOrganizationSizeReporting")
def get(self, namespace):
orgperm = OrganizationMemberPermission(namespace)
userperm = UserReadPermission(namespace)
class UserQuotaList(ApiResource):
@require_user_admin
@nickname("listUserQuota")
def get(self):
parent = get_authenticated_user()
user_quotas = model.namespacequota.get_namespace_quota_list(parent.username)
if not orgperm.can() and not userperm.can():
raise Unauthorized()
return [quota_view(quota) for quota in user_quotas]
return {
"response": model.namespacequota.get_namespace_repository_sizes_and_cache(namespace)
}, 200
@resource("/v1/user/quota/<quota_id>")
@show_if(features.QUOTA_MANAGEMENT)
class UserQuota(ApiResource):
@require_user_admin
@nickname("getUserQuota")
def get(self, quota_id):
parent = get_authenticated_user()
quota = get_quota(parent.username, quota_id)
return quota_view(quota)
@resource("/v1/user/quota/<quota_id>/limit")
@show_if(features.QUOTA_MANAGEMENT)
class UserQuotaLimitList(ApiResource):
@require_user_admin
@nickname("listUserQuotaLimit")
def get(self, quota_id):
parent = get_authenticated_user()
quota = get_quota(parent.username, quota_id)
return [
limit_view(limit)
for limit in model.namespacequota.get_namespace_quota_limit_list(quota)
]
@resource("/v1/user/quota/<quota_id>/limit/<limit_id>")
@show_if(features.QUOTA_MANAGEMENT)
class UserQuotaLimit(ApiResource):
@require_user_admin
@nickname("getUserQuotaLimit")
def get(self, quota_id, limit_id):
parent = get_authenticated_user()
quota = get_quota(parent.username, quota_id)
quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id)
if quota_limit is None:
raise NotFound()
return quota_view(quota)

View File

@ -53,6 +53,24 @@ from proxy import Proxy, UpstreamRegistryError
logger = logging.getLogger(__name__)
def quota_view(quota):
quota_limits = list(model.namespacequota.get_namespace_quota_limit_list(quota))
return {
"id": quota.id, # Generate uuid instead?
"limit_bytes": quota.limit_bytes,
"limits": [limit_view(limit) for limit in quota_limits],
}
def limit_view(limit):
return {
"id": limit.id,
"type": limit.quota_type.name,
"limit_percent": limit.percent_of_limit,
}
def team_view(orgname, team):
return {
"name": team.name,
@ -66,7 +84,7 @@ def team_view(orgname, team):
}
def org_view(o, teams, quota=None):
def org_view(o, teams):
is_admin = AdministerOrganizationPermission(o.username).can()
is_member = OrganizationMemberPermission(o.username).can()
@ -76,7 +94,6 @@ def org_view(o, teams, quota=None):
"avatar": avatar.get_data_for_user(o),
"is_admin": is_admin,
"is_member": is_member,
"quota": quota,
}
if teams is not None:
@ -90,6 +107,11 @@ def org_view(o, teams, quota=None):
view["tag_expiration_s"] = o.removed_tag_expiration_s
view["is_free_account"] = o.stripe_id is None
if features.QUOTA_MANAGEMENT:
quotas = model.namespacequota.get_namespace_quota_list(o.username)
view["quotas"] = [quota_view(quota) for quota in quotas] if quotas else []
view["quota_report"] = model.namespacequota.get_quota_for_view(o.username)
return view
@ -218,15 +240,11 @@ class Organization(ApiResource):
raise NotFound()
teams = None
quota = None
if OrganizationMemberPermission(orgname).can():
has_syncing = features.TEAM_SYNCING and bool(authentication.federated_service)
teams = model.team.get_teams_within_org(org, has_syncing)
if features.QUOTA_MANAGEMENT:
quota = model.namespacequota.get_org_quota_for_view(org.username)
return org_view(org, teams, quota)
return org_view(org, teams)
@require_scope(scopes.ORG_ADMIN)
@nickname("changeOrganizationDetails")

View File

@ -211,12 +211,6 @@ class RepositoryList(ApiResource):
type=truthy_bool,
default=False,
)
@query_param(
"quota",
"Whether to include the repository's consumed quota.",
type=truthy_bool,
default=False,
)
@query_param("repo_kind", "The kind of repositories to return", type=str, default="image")
@page_support()
def get(self, page_token, parsed_args):
@ -237,7 +231,6 @@ class RepositoryList(ApiResource):
username = user.username if user else None
last_modified = parsed_args["last_modified"]
popularity = parsed_args["popularity"]
quota = parsed_args["quota"]
if parsed_args["starred"] and not username:
# No repositories should be returned, as there is no user.
@ -253,7 +246,6 @@ class RepositoryList(ApiResource):
page_token,
last_modified,
popularity,
quota,
)
return {"repositories": [repo.to_dict() for repo in repos]}, next_page_token

View File

@ -6,6 +6,7 @@ from six import add_metaclass
import features
from data.database import RepositoryState
from data import model
from endpoints.api import format_date
@ -28,7 +29,6 @@ class RepositoryBaseElement(
"should_is_starred",
"is_free_account",
"state",
"quota",
],
)
):
@ -45,7 +45,6 @@ class RepositoryBaseElement(
:type should_last_modified: boolean
:type should_popularity: boolean
:type should_is_starred: boolean
:type: quota: dictionary
"""
def to_dict(self):
@ -56,9 +55,13 @@ class RepositoryBaseElement(
"is_public": self.is_public,
"kind": self.kind_name,
"state": self.state.name if self.state is not None else None,
"quota": self.quota if features.QUOTA_MANAGEMENT else None,
}
if features.QUOTA_MANAGEMENT:
repo["quota_report"] = model.namespacequota.get_repo_quota_for_view(
self.namespace_name, self.repository_name
)
if self.should_last_modified:
repo["last_modified"] = self.last_modified

View File

@ -90,7 +90,6 @@ class PreOCIModel(RepositoryDataInterface):
page_token,
last_modified,
popularity,
quota,
):
next_page_token = None
@ -134,7 +133,6 @@ class PreOCIModel(RepositoryDataInterface):
# and/or last modified.
last_modified_map = {}
action_sum_map = {}
quota_map = {}
if last_modified or popularity:
repository_refs = [RepositoryReference.for_id(repo.rid) for repo in repos]
repository_ids = [repo.rid for repo in repos]
@ -151,12 +149,6 @@ class PreOCIModel(RepositoryDataInterface):
if popularity:
action_sum_map = model.log.get_repositories_action_sums(repository_ids)
if features.QUOTA_MANAGEMENT and quota:
for repo_id in repository_ids:
quota_map[repo_id] = model.namespacequota.get_repo_quota_for_view(
repo_id, namespace
)
# Collect the IDs of the repositories that are starred for the user, so we can mark them
# in the returned results.
star_set = set()
@ -182,7 +174,6 @@ class PreOCIModel(RepositoryDataInterface):
username,
None,
repo.state,
quota_map.get(repo.rid),
)
for repo in repos
],
@ -242,7 +233,6 @@ class PreOCIModel(RepositoryDataInterface):
False,
repo.namespace_user.stripe_id is None,
repo.state,
features.QUOTA_MANAGEMENT is True,
)
if base.kind_name == "application":

View File

@ -21,7 +21,7 @@ from auth.auth_context import get_authenticated_user
from auth.permissions import SuperUserPermission
from data.database import ServiceKeyApprovalType
from data.logs_model import logs_model
from data.model import namespacequota
from data.model import user, namespacequota, InvalidNamespaceQuota, DataModelException
from endpoints.api import (
ApiResource,
nickname,
@ -43,7 +43,9 @@ from endpoints.api import (
Unauthorized,
InvalidResponse,
)
from endpoints.api import request_error
from endpoints.api.build import get_logs_or_log_url
from endpoints.api.namespacequota import quota_view, limit_view, get_quota
from endpoints.api.superuser_models_pre_oci import (
pre_oci_model,
ServiceKeyDoesNotExist,
@ -214,34 +216,109 @@ class SuperUserOrganizationList(ApiResource):
raise Unauthorized()
@resource("/v1/superuser/quota/")
@resource(
"/v1/superuser/users/<namespace>/quota/",
"/v1/superuser/organization/<namespace>/quota/",
)
@internal_only
@show_if(features.SUPER_USERS)
@show_if(features.QUOTA_MANAGEMENT)
class SuperUserOrganizationQuotaReport(ApiResource):
"""
Resource for listing organizations in the system.
"""
class SuperUserUserQuotaList(ApiResource):
schemas = {
"NewNamespaceQuota": {
"type": "object",
"description": "Description of a new organization quota",
"required": ["limit_bytes"],
"properties": {
"limit_bytes": {
"type": "integer",
"description": "Number of bytes the organization is allowed",
},
},
},
}
@require_fresh_login
@verify_not_prod
@nickname("allOrganizationQuotaReport")
@nickname(["createUserQuotaSuperUser", "createOrganizationQuotaSuperUser"])
@require_scope(scopes.SUPERUSER)
def get(self):
"""
Returns a list of all organizations in the system.
"""
@validate_json_request("NewNamespaceQuota")
def post(self, namespace):
if SuperUserPermission().can():
return {
"organizations": [
{
"organization": org.username,
"size": namespacequota.get_namespace_size(org.username),
}
for org in pre_oci_model.get_organizations()
]
quota_data = request.get_json()
limit_bytes = quota_data["limit_bytes"]
namespace_user = user.get_user_or_org(namespace)
quotas = namespacequota.get_namespace_quota_list(namespace_user.username)
if quotas:
raise request_error(message="Quota for '%s' already exists" % namespace)
try:
newquota = namespacequota.create_namespace_quota(namespace_user, limit_bytes)
return "Created", 201
except DataModelException as ex:
raise request_error(exception=ex)
raise Unauthorized()
@resource(
"/v1/superuser/users/<namespace>/quota/<quota_id>",
"/v1/superuser/organization/<namespace>/quota/<quota_id>",
)
@internal_only
@show_if(features.SUPER_USERS)
@show_if(features.QUOTA_MANAGEMENT)
class SuperUserUserQuota(ApiResource):
schemas = {
"UpdateNamespaceQuota": {
"type": "object",
"description": "Description of a new organization quota",
"properties": {
"limit_bytes": {
"type": "integer",
"description": "Number of bytes the organization is allowed",
},
},
},
}
@require_fresh_login
@verify_not_prod
@nickname(["changeUserQuotaSuperUser", "changeOrganizationQuotaSuperUser"])
@require_scope(scopes.SUPERUSER)
@validate_json_request("UpdateNamespaceQuota")
def put(self, namespace, quota_id):
if SuperUserPermission().can():
quota_data = request.get_json()
namespace_user = user.get_user_or_org(namespace)
quota = get_quota(namespace_user.username, quota_id)
try:
if "limit_bytes" in quota_data:
limit_bytes = quota_data["limit_bytes"]
model.namespacequota.update_namespace_quota_size(quota, limit_bytes)
except model.DataModelException as ex:
raise request_error(exception=ex)
return quota_view(quota)
raise Unauthorized()
@nickname(["deleteUserQuotaSuperUser", "deleteOrganizationQuotaSuperUser"])
@require_scope(scopes.SUPERUSER)
def delete(self, namespace, quota_id):
if SuperUserPermission().can():
namespace_user = user.get_user_or_org(namespace)
quota = get_quota(namespace_user.username, quota_id)
namespacequota.delete_namespace_quota(quota)
return "", 204
raise Unauthorized()

View File

@ -23,6 +23,24 @@ def user_view(user):
}
def quota_view(quota):
quota_limits = list(model.namespacequota.get_namespace_quota_limit_list(quota))
return {
"id": quota.id, # Generate uuid instead?
"limit_bytes": quota.limit_bytes,
"limits": [limit_view(limit) for limit in quota_limits],
}
def limit_view(limit):
return {
"id": limit.id,
"type": limit.quota_type.name,
"limit_percent": limit.percent_of_limit,
}
class BuildTrigger(
namedtuple("BuildTrigger", ["trigger", "pull_robot", "can_read", "can_admin", "for_build"])
):
@ -205,7 +223,7 @@ class ServiceKey(
}
class User(namedtuple("User", ["username", "email", "verified", "enabled", "robot"])):
class User(namedtuple("User", ["username", "email", "verified", "enabled", "robot", "quotas"])):
"""
User represents a single user.
@ -227,28 +245,37 @@ class User(namedtuple("User", ["username", "email", "verified", "enabled", "robo
"super_user": superusers.is_superuser(self.username),
"enabled": self.enabled,
}
if features.QUOTA_MANAGEMENT and self.quotas is not None:
user_data["quotas"] = (
[quota_view(quota) for quota in self.quotas] if self.quotas else []
)
user_data["quota_report"] = model.namespacequota.get_quota_for_view(self.username)
return user_data
class Organization(namedtuple("Organization", ["username", "email"])):
class Organization(namedtuple("Organization", ["username", "email", "quotas"])):
"""
Organization represents a single org.
:type username: string
:type email: string
:type quotas: [UserOrganizationQuota] | None
"""
def to_dict(self):
return {
d = {
"name": self.username,
"email": self.email,
"avatar": avatar.get_data_for_org(self),
"quota": model.namespacequota.get_org_quota_for_view(self.username)
if features.QUOTA_MANAGEMENT
else None,
}
if features.QUOTA_MANAGEMENT and self.quotas is not None:
d["quotas"] = [quota_view(quota) for quota in self.quotas] if self.quotas else []
d["quota_report"] = model.namespacequota.get_quota_for_view(self.username)
return d
@add_metaclass(ABCMeta)
class SuperuserDataInterface(object):

View File

@ -22,10 +22,20 @@ from endpoints.api.superuser_models_interface import (
from util.request import get_request_ip
def _get_namespace_quotas(namespace_user):
if not features.QUOTA_MANAGEMENT:
return None
return model.namespacequota.get_namespace_quota_list(namespace_user.username)
def _create_user(user):
if user is None:
return None
return User(user.username, user.email, user.verified, user.enabled, user.robot)
quotas = _get_namespace_quotas(user)
return User(user.username, user.email, user.verified, user.enabled, user.robot, quotas)
def _create_key(key):
@ -163,7 +173,9 @@ class PreOCIModel(SuperuserDataInterface):
if new_org_name is not None:
org = model.user.change_username(org.id, new_org_name)
return Organization(org.username, org.email)
quotas = _get_namespace_quotas(org)
return Organization(org.username, org.email, quotas)
def mark_organization_for_deletion(self, name):
org = model.organization.get_organization(name)
@ -235,7 +247,8 @@ class PreOCIModel(SuperuserDataInterface):
def get_organizations(self):
return [
Organization(org.username, org.email) for org in model.organization.get_organizations()
Organization(org.username, org.email, _get_namespace_quotas(org))
for org in model.organization.get_organizations()
]

View File

@ -5522,51 +5522,6 @@ SECURITY_TESTS: List[
(RepositoryStateResource, "PUT", {"repository": "devtable/simple"}, None, "devtable", 400),
(RepositoryStateResource, "PUT", {"repository": "devtable/simple"}, None, "freshuser", 403),
(RepositoryStateResource, "PUT", {"repository": "devtable/simple"}, None, "reader", 403),
(
OrganizationQuota,
"POST",
{"namespace": "buynlarge"},
{"limit_bytes": 1, "bytes_unit": "GB"},
None,
401,
),
(OrganizationQuota, "GET", {"namespace": "buynlarge"}, None, None, 401),
(
OrganizationQuota,
"PUT",
{"namespace": "buynlarge"},
{"limit_bytes": 1, "bytes_unit": "GB"},
None,
401,
),
(
OrganizationQuotaLimits,
"POST",
{"namespace": "buynlarge"},
{"percent_of_limit": 10, "quota_type_id": 2},
None,
401,
),
(OrganizationQuotaLimits, "GET", {"namespace": "buynlarge"}, None, None, 401),
(
OrganizationQuotaLimits,
"PUT",
{"namespace": "buynlarge"},
{"percent_of_limit": 10, "quota_type_id": 2},
None,
401,
),
(
OrganizationQuotaLimits,
"DELETE",
{"namespace": "buynlarge"},
{"quota_limit_id": 1},
None,
401,
),
(OrganizationQuota, "DELETE", {"namespace": "buynlarge"}, {}, None, 401),
(OrganizationQuotaReport, "GET", {"namespace": "buynlarge"}, {}, None, 401),
(SuperUserOrganizationQuotaReport, "GET", {"namespace": "buynlarge"}, {}, None, 401),
(
OrganizationProxyCacheConfig,
"GET",
@ -5671,6 +5626,252 @@ SECURITY_TESTS: List[
"devtable",
202,
),
(OrganizationQuotaList, "GET", {"orgname": "buynlarge"}, None, "devtable", 200),
(OrganizationQuotaList, "GET", {"orgname": "buynlarge"}, None, "randomuser", 403),
(OrganizationQuotaList, "GET", {"orgname": "buynlarge"}, None, None, 401),
(OrganizationQuotaList, "POST", {"orgname": "buynlarge"}, {"limit_bytes": 200000}, None, 401),
(
OrganizationQuotaList,
"POST",
{"orgname": "buynlarge"},
{"limit_bytes": 200000},
"devtable",
400,
), # Quota already exists in test db
(
OrganizationQuotaList,
"POST",
{"orgname": "buynlarge"},
{"limit_bytes": 200000},
"randomuser",
403,
),
(
OrganizationQuotaList,
"POST",
{"orgname": "library"},
{"limit_bytes": 200000},
"devtable",
201,
),
(OrganizationQuota, "GET", {"orgname": "buynlarge", "quota_id": 1}, None, None, 401),
(OrganizationQuota, "GET", {"orgname": "buynlarge", "quota_id": 1}, None, "randomuser", 403),
(OrganizationQuota, "GET", {"orgname": "buynlarge", "quota_id": 1}, None, "devtable", 200),
(OrganizationQuota, "GET", {"orgname": "buynlarge", "quota_id": 2}, None, "devtable", 404),
(OrganizationQuota, "PUT", {"orgname": "buynlarge", "quota_id": 1}, {}, None, 401),
(OrganizationQuota, "PUT", {"orgname": "buynlarge", "quota_id": 1}, {}, "randomuser", 403),
(OrganizationQuota, "PUT", {"orgname": "buynlarge", "quota_id": 1}, {}, "devtable", 200),
(OrganizationQuota, "DELETE", {"orgname": "buynlarge", "quota_id": 1}, None, None, 401),
(OrganizationQuota, "DELETE", {"orgname": "buynlarge", "quota_id": 1}, None, "randomuser", 403),
(OrganizationQuota, "DELETE", {"orgname": "buynlarge", "quota_id": 1}, None, "devtable", 204),
(OrganizationQuotaLimitList, "GET", {"orgname": "buynlarge", "quota_id": 1}, None, None, 401),
(
OrganizationQuotaLimitList,
"GET",
{"orgname": "buynlarge", "quota_id": 1},
None,
"randomuser",
403,
),
(
OrganizationQuotaLimitList,
"GET",
{"orgname": "buynlarge", "quota_id": 1},
None,
"devtable",
200,
),
(
OrganizationQuotaLimitList,
"POST",
{"orgname": "buynlarge", "quota_id": 1},
{"type": "warning", "threshold_percent": 50},
None,
401,
),
(
OrganizationQuotaLimitList,
"POST",
{"orgname": "buynlarge", "quota_id": 1},
{"type": "warning", "threshold_percent": 50},
"randomuser",
403,
),
(
OrganizationQuotaLimitList,
"POST",
{"orgname": "buynlarge", "quota_id": 1},
{"type": "warning", "threshold_percent": 50},
"devtable",
400,
), # Exact same configuration already exists
(
OrganizationQuotaLimitList,
"POST",
{"orgname": "buynlarge", "quota_id": 1},
{"type": "undfinedtype", "threshold_percent": 60},
"devtable",
400,
),
(
OrganizationQuotaLimitList,
"POST",
{"orgname": "buynlarge", "quota_id": 1},
{"type": "warning", "threshold_percent": 60},
"devtable",
201,
),
(
OrganizationQuotaLimitList,
"POST",
{"orgname": "buynlarge", "quota_id": 1},
{"type": "reject", "threshold_percent": 60},
"devtable",
201,
),
(
OrganizationQuotaLimit,
"GET",
{"orgname": "buynlarge", "quota_id": 1, "limit_id": 1},
None,
None,
401,
),
(
OrganizationQuotaLimit,
"GET",
{"orgname": "buynlarge", "quota_id": 1, "limit_id": 1},
None,
"randomuser",
403,
),
(
OrganizationQuotaLimit,
"GET",
{"orgname": "buynlarge", "quota_id": 1, "limit_id": 1},
None,
"devtable",
200,
),
(
OrganizationQuotaLimit,
"PUT",
{"orgname": "buynlarge", "quota_id": 1, "limit_id": 1},
{"type": "reject", "threshold_percent": 60},
None,
401,
),
(
OrganizationQuotaLimit,
"PUT",
{"orgname": "buynlarge", "quota_id": 1, "limit_id": 1},
{"type": "reject", "threshold_percent": 60},
"randomuser",
403,
),
(
OrganizationQuotaLimit,
"PUT",
{"orgname": "buynlarge", "quota_id": 1, "limit_id": 1},
{"type": "reject", "threshold_percent": 60},
"devtable",
200,
),
(
OrganizationQuotaLimit,
"PUT",
{"orgname": "buynlarge", "quota_id": 1, "limit_id": 1},
{"type": "undefinedtype", "threshold_percent": 60},
"devtable",
400,
),
(
OrganizationQuotaLimit,
"DELETE",
{"orgname": "buynlarge", "quota_id": 1, "limit_id": 1},
None,
None,
401,
),
(
OrganizationQuotaLimit,
"DELETE",
{"orgname": "buynlarge", "quota_id": 1, "limit_id": 1},
None,
"randomuser",
403,
),
(
OrganizationQuotaLimit,
"DELETE",
{"orgname": "buynlarge", "quota_id": 1, "limit_id": 1},
None,
"devtable",
204,
),
(UserQuotaList, "GET", {}, None, None, 401),
(UserQuotaList, "GET", {}, None, "freshuser", 200),
(UserQuotaList, "GET", {}, None, "devtable", 200),
(UserQuota, "GET", {"quota_id": 2}, None, "freshuser", 404),
(UserQuota, "GET", {"quota_id": 2}, None, "devtable", 404),
(UserQuota, "GET", {"quota_id": 2}, None, "randomuser", 200),
(UserQuotaLimitList, "GET", {"quota_id": 2}, None, "devtable", 404),
(UserQuotaLimitList, "GET", {"quota_id": 2}, None, "randomuser", 200),
(UserQuotaLimit, "GET", {"quota_id": 2, "limit_id": 2}, None, "devtable", 404),
(UserQuotaLimit, "GET", {"quota_id": 2, "limit_id": 2}, None, "randomuser", 200),
(SuperUserUserQuotaList, "POST", {"namespace": "randomuser"}, {"limit_bytes": 5000}, None, 401),
(SuperUserUserQuotaList, "POST", {"namespace": "randomuser"}, None, None, 401),
(
SuperUserUserQuotaList,
"POST",
{"namespace": "randomuser"},
{"limit_bytes": 5000},
"freshuser",
403,
),
(
SuperUserUserQuotaList,
"POST",
{"namespace": "randomuser"},
{"limit_bytes": 5000},
"devtable",
400,
), # Quota for this user already exists
(
SuperUserUserQuotaList,
"POST",
{"namespace": "freshuser"},
{"limit_bytes": 5000},
"devtable",
201,
),
(SuperUserUserQuotaList, "POST", {"namespace": "randomuser"}, None, "devtable", 400),
(SuperUserUserQuota, "PUT", {"namespace": "randomuser", "quota_id": 2}, {}, "randomuser", 403),
(SuperUserUserQuota, "PUT", {"namespace": "randomuser", "quota_id": 2}, {}, "devtable", 200),
(
SuperUserUserQuota,
"DELETE",
{"namespace": "randomuser", "quota_id": 2},
None,
"freshuser",
403,
),
(
SuperUserUserQuota,
"DELETE",
{"namespace": "randomuser", "quota_id": 2},
None,
"devtable",
204,
),
(
SuperUserUserQuota,
"DELETE",
{"namespace": "randomuser", "quota_id": 1},
None,
"devtable",
404,
),
]

View File

@ -184,6 +184,11 @@ def user_view(user, previous_username=None):
}
)
if features.QUOTA_MANAGEMENT:
quotas = model.namespacequota.get_namespace_quota_list(user.username)
user_response["quotas"] = [quota_view(quota) for quota in quotas] if quotas else []
user_response["quota_report"] = model.namespacequota.get_quota_for_view(user.username)
user_view_perm = UserReadPermission(user.username)
if user_view_perm.can():
user_response.update(
@ -217,6 +222,24 @@ def notification_view(note):
}
def quota_view(quota):
quota_limits = list(model.namespacequota.get_namespace_quota_limit_list(quota))
return {
"id": quota.id, # Generate uuid instead?
"limit_bytes": quota.limit_bytes,
"limits": [limit_view(limit) for limit in quota_limits],
}
def limit_view(limit):
return {
"id": limit.id,
"type": limit.quota_type.name,
"limit_percent": limit.percent_of_limit,
}
@resource("/v1/user/")
class User(ApiResource):
"""

View File

@ -866,10 +866,12 @@ def populate_database(minimal=False):
QuotaType.create(name="Warning")
QuotaType.create(name="Reject")
model.namespacequota.create_namespace_quota(org.username, 3050)
quota1 = model.namespacequota.create_namespace_quota(org, 3000)
model.namespacequota.create_namespace_quota_limit(quota1, "warning", 50)
model.repository.force_cache_repo_size(publicrepo.id)
model.namespacequota.create_namespace_limit(org.username, 1, 50)
quota2 = model.namespacequota.create_namespace_quota(new_user_4, 6000)
model.namespacequota.create_namespace_quota_limit(quota2, "reject", 90)
liborg = model.organization.create_organization(
"library", "quay+library@devtable.com", new_user_1

View File

@ -58,7 +58,7 @@
<span class="cor-options-menu"
ng-if="user.username != current_user.username && !current_user.super_user && !inReadOnlyMode">
<span class="cor-option" option-click="showChangeEmail(current_user)"
quay-show="Config.AUTHENTICATION_TYPE == 'Database' || Config>.AUTHENTICATION_TYPE == 'AppToken'">
quay-show="Config.AUTHENTICATION_TYPE == 'Database' || Config.AUTHENTICATION_TYPE == 'AppToken'">
<i class="fa fa-envelope-o"></i> Change E-mail Address
</span>
<span class="cor-option" option-click="showChangePassword(current_user)"

View File

@ -1,28 +1,36 @@
<div class="quota-management-view-element">
<form>
<table class="co-list-table">
<tr>
<td>
Quota Management:
</td>
<td>
<fieldset ng-disabled="disabled">
<div class="row-alignment">
<label class="margin-2 width-100">Set Quota: </label>
<input class="margin-2 form-control width-440" type="number" name="quota-limit" ng-model="currentQuotaConfig['quota']">
<table class="co-table">
<td>
<input class="margin-2 form-control width-440" type="number" name="quota-limit" min="1" ng-model="currentQuotaConfig['quota']">
</td>
<td>
<select class="form-control"
ng-model="currentQuotaConfig['bytes_unit']"
ng-model="currentQuotaConfig['byte_unit']"
ng-options="val for val in quotaUnits">
</select>
</td>
<td>
<button class="margin-2 btn btn-primary save-quota-details margin-3"
ng-disabled="disableSaveQuota()" ng-click="updateQuotaConfig()">Save Quota Details</button>
</td>
</table>
</td>
</tr>
<button class="margin-2 add-quota-limit btn btn-success hidden-xs" ng-click="addQuotaLimit($event)">
<i class="fa fa-plus margin-right-4"></i>
Add Quota Limit
</button>
</div>
<tr>
<td>
Limits:
</td>
<td>
<table class="co-table" ng-if="currentQuotaConfig">
<table class="co-table" ng-if="limitCounter > 0">
<thead>
<td class="hidden-xs">
<span>Action</span>
@ -35,35 +43,60 @@
</td>
</thead>
<tbody ng-model="limitCounter" ng-repeat="x in [].constructor(limitCounter) track by $index" id="limit-id-limitCounter">
<tr>
<!-- Update limits -->
<tr ng-repeat="limit in currentQuotaConfig['limits']">
<td>
<select class="form-control" name="quotaLimitType" id="quotaLimitType"
ng-options="obj as obj.name for obj in quotaLimitTypes track by obj.name"
ng-model="currentQuotaConfig['limits'][$index]['limit_type']">
ng-options="type for type in quotaLimitTypes"
ng-model="limit['type']">
</select>
</td>
<td>
<input class="form-control margin-2" type="number" name="limit-percent" min="1" max="100" placeholder="Limit Percent" ng-model="currentQuotaConfig['limits'][$index]['percent_of_limit']"/>
<input class="form-control margin-2" type="number" name="limit-percent"
min="1" max="100" placeholder="Limit Percent" ng-model="limit['limit_percent']"/>
</td>
<td>
<span class="margin-2" ng-hide="limitCounter < 1">
<button class="margin-2 btn btn-danger" ng-click="removeQuotaLimit($index)" ng-model="$index">
<span class="margin-2">
<button class="margin-2 btn btn-primary" ng-click="updateQuotaLimit(limit.id)" ng-disabled="disableUpdateQuota(limit.id)">
<i class="fa fa-trash"></i>
Update
</button>
</span>
<span class="margin-2">
<button class="margin-2 btn btn-danger" ng-click="deleteQuotaLimit(limit.id)">
<i class="fa fa-trash"></i>
Remove
</button>
</span>
</td>
</tr>
</tbody>
</table>
<button class="margin-2 btn btn-primary save-quota-details" ng-disabled="disableSave()" ng-click="updateQuotaDetailsOnSave()">Save Quota Details</button>
</fieldset>
<!-- Add limit -->
<tr>
<td>
<select class="form-control" name="quotaLimitType" id="quotaLimitType"
ng-options="type for type in quotaLimitTypes" ng-model="newLimitConfig['type']">
</select>
</td>
<td>
<input class="form-control margin-2" type="number" name="limit-percent"
min="1" max="100" placeholder="Percent threshold" ng-model="newLimitConfig['limit_percent']"/>
</td>
<td>
<span class="margin-2">
<button class="add-quota-limit btn btn-success hidden-xs" ng-click="addQuotaLimit()" ng-disabled="!prevQuotaConfig['quota']">
<i class="fa fa-plus margin-right-4"></i>
Add Quota Limit
</button>
</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
<button class="btn btn-default" data-dismiss="modal">Close</button>
</form>
</div>

View File

@ -38,10 +38,10 @@
</td>
<td class="hidden-xs"
ng-class="tablePredicateClass('quota', options.predicate, options.reverse)"
ng-class="tablePredicateClass('quota_report', options.predicate, options.reverse)"
style="min-width: 20px;"
ng-if="quotaManagementEnabled">
<a ng-click="orderBy('quota.quota_bytes')">Quota Consumed</a>
<a ng-click="orderBy('quota_report.quota_bytes')">Quota Consumed</a>
</td>
<td class="hidden-xs"
ng-class="tablePredicateClass('popularity', options.predicate, options.reverse)"
@ -81,11 +81,12 @@
</span>
</td>
<td class="repo-quota hidden-xs" ng-show="quotaManagementEnabled">
<span ng-if="::repository.quota.percent_consumed != null">
<span ng-bind="::bytesToHumanReadableString(repository.quota.quota_bytes)"></span> ({{::repository.quota.percent_consumed}}%)
<span ng-if="::repository.quota_report.configured_quota != null">
<span ng-bind="::bytesToHumanReadableString(repository.quota_report.quota_bytes)"></span>
{{ ::quotaPercentConsumed(repository) }}%
</span>
<span ng-if="::repository.quota.percent_consumed == null">
<span ng-bind="::bytesToHumanReadableString(repository.quota.quota_bytes)"></span>
<span ng-if="::repository.quota_report.configured_quota == null">
--
</span>
</td>
<td class="popularity hidden-xs">

View File

@ -6,203 +6,102 @@ angular.module('quay').directive('quotaManagementView', function () {
templateUrl: '/static/directives/quota-management-view.html',
restrict: 'AEC',
scope: {
'isEnabled': '=isEnabled',
'organization': '=organization',
'disabled': '=disabled'
},
controller: function ($scope, $timeout, $location, $element, ApiService, UserService,
TableService, Features, StateService, $q) {
$scope.prevquotaEnabled = false;
$scope.updating = false;
$scope.limitCounter = 0;
$scope.quotaLimitTypes = [];
$scope.prevQuotaConfig = {'limit_bytes': null, 'quota': null, 'limits': [], 'bytes_unit': null};
$scope.currentQuotaConfig = {'limit_bytes': null, 'quota': null, 'limits': [], 'bytes_unit': null};
$scope.quotaLimitTypes = [
"Reject", "Warning"
];
$scope.prevQuotaConfig = {'quota': null, 'limits': {}};
$scope.currentQuotaConfig = {'quota': null, 'limits': {}};
$scope.newLimitConfig = {'type': null, 'limit_percent': null}
$scope.defer = null;
$scope.disk_size_units = {
'KB': 1024,
'MB': 1024**2,
'GB': 1024**3,
'TB': 1024**4,
};
$scope.quotaUnits = Object.keys($scope.disk_size_units);
$scope.rejectLimitType = 'Reject';
$scope.isUpdateable = false;
var loadOrgQuota = function (fresh) {
$scope.nameSpaceResource = ApiService.getNamespaceQuota(null,
{'namespace': $scope.organization.name}).then((resp) => {
$scope.prevQuotaConfig['limit_bytes'] = $scope.currentQuotaConfig['limit_bytes'] = resp["limit_bytes"];
let { result, byte_unit } = bytes_to_human_readable_string(resp["limit_bytes"]);
$scope.prevQuotaConfig['quota'] = $scope.currentQuotaConfig['quota'] = result
$scope.prevQuotaConfig['bytes_unit'] = $scope.currentQuotaConfig['bytes_unit'] = byte_unit;
var loadOrgQuota = function () {
$scope.nameSpaceResource = ApiService.listOrganizationQuota(
null, {'orgname': $scope.organization.name}
).then((resp) => {
if (resp.length > 0) {
quota = resp[0];
$scope.prevQuotaConfig['id'] = quota["id"];
$scope.currentQuotaConfig['id'] = quota["id"];
if (fresh) {
for (let i = 0; i < resp["quota_limit_types"].length; i++) {
let temp = resp["quota_limit_types"][i];
temp["quota_limit_id"] = null;
$scope.quotaLimitTypes.push(temp);
}
for (i in quota['limits']) {
limitId = quota['limits'][i]['id'];
$scope.prevQuotaConfig['limits'][limitId] = $.extend({}, quota["limits"][i]);
$scope.currentQuotaConfig['limits'][limitId] = $.extend({}, quota["limits"][i]);
}
if (resp["limit_bytes"] != null) {
let { result, byte_unit } = normalizeLimitBytes(quota["limit_bytes"]);
$scope.prevQuotaConfig['quota'] = result;
$scope.currentQuotaConfig['quota'] = result;
$scope.prevQuotaConfig['byte_unit'] = byte_unit;
$scope.currentQuotaConfig['byte_unit'] = byte_unit;
if (quota["limit_bytes"] != null) {
$scope.prevquotaEnabled = true;
}
});
}
var human_readable_string_to_bytes = function(quota, bytes_unit) {
$scope.organization.quota_report.configured_quota = quota["limit_bytes"];
$scope.organization.quota_report.percent_consumed = (parseInt($scope.organization.quota_report.quota_bytes) / $scope.organization.quota_report.configured_quota * 100).toFixed(2);
}
});
};
var humanReadableStringToBytes = function(quota, bytes_unit) {
return Number(quota*$scope.disk_size_units[bytes_unit]);
};
var bytes_to_human_readable_string = function (bytes) {
var normalizeLimitBytes = function (bytes) {
let units = Object.keys($scope.disk_size_units).reverse();
let result = null;
let byte_unit = null;
for (const key in units) {
byte_unit = units[key];
if (bytes >= $scope.disk_size_units[byte_unit]) {
result = bytes / $scope.disk_size_units[byte_unit];
if (bytes >= $scope.disk_size_units[byte_unit]) {
return { result, byte_unit };
}
}
return { result, byte_unit };
};
var loadQuotaLimits = function (fresh) {
$scope.nameSpaceQuotaLimitsResource = ApiService.getOrganizationQuotaLimit(null,
{'namespace': $scope.organization.name}).then((resp) => {
$scope.prevQuotaConfig['limits'] = [];
$scope.currentQuotaConfig['limits'] = [];
for (let i = 0; i < resp['quota_limits'].length; i ++) {
$scope.prevQuotaConfig['limits'].push({...resp['quota_limits'][i]});
$scope.currentQuotaConfig['limits'].push({...resp['quota_limits'][i]});
}
if (fresh) {
if ($scope.currentQuotaConfig['limits']) {
for (let i = 0; i < $scope.currentQuotaConfig['limits'].length; i++) {
populateQuotaLimit();
}
}
}
});
}
var updateOrganizationQuota = function(params) {
let limit_bytes = humanReadableStringToBytes($scope.currentQuotaConfig['quota'], $scope.currentQuotaConfig['byte_unit']);
let data = {'limit_bytes': limit_bytes};
let quotaMethod = null;
if (!$scope.prevquotaEnabled || $scope.prevQuotaConfig['quota'] != $scope.currentQuotaConfig['quota']
|| $scope.prevQuotaConfig['bytes_unit'] != $scope.currentQuotaConfig['bytes_unit'] ) {
let quotaMethod = ApiService.createNamespaceQuota;
let m1 = "createNamespaceQuota";
let limit_bytes = human_readable_string_to_bytes($scope.currentQuotaConfig['quota'], $scope.currentQuotaConfig['bytes_unit']);
let data = {
'limit_bytes': limit_bytes,
};
if (!$scope.prevquotaEnabled ||
$scope.prevQuotaConfig['quota'] != $scope.currentQuotaConfig['quota'] ||
$scope.prevQuotaConfig['byte_unit'] != $scope.currentQuotaConfig['byte_unit']) {
if ($scope.prevquotaEnabled) {
quotaMethod = ApiService.changeOrganizationQuota;
m1 = "changeOrganizationQuota";
} else {
quotaMethod = ApiService.createOrganizationQuota;
}
quotaMethod(data, params).then((resp) => {
$scope.updating = false;
loadOrgQuota(false);
loadOrgQuota();
}, displayError());
}
}
var createOrgQuotaLimit = function(data, params) {
for (let i = 0; i < data.length; i++) {
let to_send = {
'percent_of_limit': data[i]['percent_of_limit'],
'quota_type_id': data[i]['limit_type']['quota_type_id']
};
ApiService.createOrganizationQuotaLimit(to_send, params).then((resp) => {
$scope.prevquotaEnabled = true;
}, displayError());
}
}
var updateOrgQuotaLimit = function(data, params) {
if (!data) {
return;
}
for (let i = 0; i < data.length; i++) {
let to_send = {
'percent_of_limit': data[i]['percent_of_limit'],
'quota_type_id': data[i]['limit_type']['quota_type_id'],
'quota_limit_id': data[i]['limit_type']['quota_limit_id']
};
ApiService.changeOrganizationQuotaLimit(to_send, params).then((resp) => {
$scope.prevquotaEnabled = true;
}, displayError());
}
}
var deleteOrgQuotaLimit = function(data, params) {
if (!data) {
return;
}
for (let i = 0; i < data.length; i++) {
params['quota_limit_id'] = data[i]['limit_type']['quota_limit_id'];
ApiService.deleteOrganizationQuotaLimit(null, params).then((resp) => {
$scope.prevquotaEnabled = true;
}, displayError());
}
}
var similarLimits =function() {
return JSON.stringify($scope.prevQuotaConfig['limits']) === JSON.stringify($scope.currentQuotaConfig['limits']);
}
var fetchLimitsToDelete = function() {
// In prev but not in current => to be deleted
let currentQuotaConfig = $scope.currentQuotaConfig['limits'];
let prevQuotaConfig = $scope.prevQuotaConfig['limits'];
return prevQuotaConfig.filter(function(obj1) {
return obj1.limit_type.quota_limit_id != null && !currentQuotaConfig.some(function(obj2) {
return obj1.limit_type.quota_limit_id === obj2.limit_type.quota_limit_id;
});
});
}
var fetchLimitsToAdd = function() {
// In current but not in prev => to add
let currentQuotaConfig = $scope.currentQuotaConfig['limits'];
let prevQuotaConfig = $scope.prevQuotaConfig['limits'];
return currentQuotaConfig.filter(function(obj1) {
return obj1.limit_type.quota_limit_id == null && !prevQuotaConfig.some(function(obj2) {
return obj1.limit_type.name === obj2.limit_type.name && obj1.percent_of_limit === obj2.percent_of_limit;
});
});
}
var fetchLimitsToUpdate = function() {
// In current and prev but different values
let currentQuotaConfig = $scope.currentQuotaConfig['limits'];
let prevQuotaConfig = $scope.prevQuotaConfig['limits'];
return currentQuotaConfig.filter(function(obj1) {
return prevQuotaConfig.some(function(obj2) {
return obj1.limit_type.quota_limit_id == obj2.limit_type.quota_limit_id &&
(obj1.percent_of_limit != obj2.percent_of_limit || obj1.limit_type.name != obj2.limit_type.name);
});
});
}
var updateQuotaLimits = function(params) {
if (similarLimits()) {
return;
}
let toDelete = fetchLimitsToDelete();
let toAdd = fetchLimitsToAdd();
let toUpdate = fetchLimitsToUpdate();
createOrgQuotaLimit(toAdd, params);
updateOrgQuotaLimit(toUpdate, params);
deleteOrgQuotaLimit(toDelete, params);
}
var displayError = function(message = 'Could not update quota details') {
$scope.updating = true;
let errorDisplay = ApiService.errorDisplay(message, () => {
@ -215,7 +114,7 @@ angular.module('quay').directive('quotaManagementView', function () {
let valid = true;
let rejectCount = 0;
for (let i = 0; i < $scope.currentQuotaConfig['limits'].length; i++) {
if ($scope.currentQuotaConfig['limits'][i]['limit_type']['name'] === $scope.rejectLimitType) {
if ($scope.currentQuotaConfig['limits'][i]['type'] === $scope.rejectLimitType) {
rejectCount++;
if (rejectCount > 1) {
@ -230,13 +129,7 @@ angular.module('quay').directive('quotaManagementView', function () {
return valid;
}
$scope.disableSave = function() {
return $scope.prevQuotaConfig['quota'] === $scope.currentQuotaConfig['quota'] &&
$scope.prevQuotaConfig['bytes_unit'] === $scope.currentQuotaConfig['bytes_unit'] &&
similarLimits();
}
var updateQuotaDetails = function() {
$scope.updateQuotaConfig = function() {
// Validate correctness
if (!validLimits()) {
$scope.defer.resolve();
@ -244,45 +137,76 @@ angular.module('quay').directive('quotaManagementView', function () {
}
let params = {
'namespace': $scope.organization.name
'orgname': $scope.organization.name,
'quota_id': $scope.currentQuotaConfig.id,
};
updateOrganizationQuota(params);
updateQuotaLimits(params);
$scope.defer.resolve();
$scope.isUpdateable = true;
}
$scope.updateQuotaDetailsOnSave = function() {
$scope.defer = $q.defer();
updateQuotaDetails();
if ($scope.isUpdateable) {
$scope.defer.promise.then(function() {
loadOrgQuota(false);
loadQuotaLimits(false);
$scope.addQuotaLimit = function() {
var params = {
'orgname': $scope.organization.name,
'quota_id': $scope.currentQuotaConfig.id,
};
var data = {
'type': $scope.newLimitConfig['type'],
'threshold_percent': $scope.newLimitConfig['limit_percent'],
};
ApiService.createOrganizationQuotaLimit(data, params).then((resp) => {
$scope.newLimitConfig['type'] = null;
$scope.newLimitConfig['limit_percent'] = null;
loadOrgQuota();
});
}
$scope.isUpdateable = false;
$scope.updateQuotaLimit = function(limitId) {
var params = {
'orgname': $scope.organization.name,
'quota_id': $scope.currentQuotaConfig.id,
'limit_id': limitId,
};
var data = {
'type': $scope.currentQuotaConfig['limits'][limitId]['type'],
'threshold_percent': $scope.currentQuotaConfig['limits'][limitId]['limit_percent'],
};
ApiService.changeOrganizationQuotaLimit(data, params).then((resp) => {
$scope.prevQuotaConfig['limits'][limitId]['type'] = $scope.currentQuotaConfig['limits'][limitId]['type'];
$scope.prevQuotaConfig['limits'][limitId]['limit_percent'] = $scope.currentQuotaConfig['limits'][limitId]['limit_percent'];
});
}
$scope.addQuotaLimit = function($event) {
$scope.limitCounter++;
let temp = {'percent_of_limit': '', 'limit_type': $scope.quotaLimitTypes[0]};
$scope.currentQuotaConfig['limits'].push(temp);
$event.preventDefault();
$scope.deleteQuotaLimit = function(limitId) {
params = {
'orgname': $scope.organization.name,
'quota_id': $scope.currentQuotaConfig.id,
'limit_id': limitId,
}
var populateQuotaLimit = function() {
$scope.limitCounter++;
ApiService.deleteOrganizationQuotaLimit(null, params).then((resp) => {
delete $scope.currentQuotaConfig['limits'][limitId];
delete $scope.prevQuotaConfig['limits'][limitId];
});
}
$scope.removeQuotaLimit = function(index) {
$scope.currentQuotaConfig['limits'].splice(index, 1);
$scope.limitCounter--;
$scope.disableSaveQuota = function() {
return $scope.prevQuotaConfig['quota'] === $scope.currentQuotaConfig['quota'] &&
$scope.prevQuotaConfig['byte_unit'] === $scope.currentQuotaConfig['byte_unit'];
}
loadOrgQuota(true);
loadQuotaLimits(true);
$scope.disableUpdateQuota = function(limitId) {
return $scope.prevQuotaConfig['limits'][limitId]['type'] === $scope.currentQuotaConfig['limits'][limitId]['type'] &&
$scope.prevQuotaConfig['limits'][limitId]['limit_percent'] === $scope.currentQuotaConfig['limits'][limitId]['limit_percent'];
}
loadOrgQuota();
/* loadQuotaLimits(true); */
$scope.$watch('isEnabled', loadOrgQuota);
$scope.$watch('organization', loadOrgQuota);
}
}

View File

@ -30,6 +30,7 @@ angular.module('quay').directive('repoListTable', function () {
'page': 0
};
$scope.disk_size_units = {
'KB': 1024,
'MB': 1024**2,
'GB': 1024**3,
'TB': 1024**4,
@ -41,7 +42,7 @@ angular.module('quay').directive('repoListTable', function () {
$scope.orderedRepositories = TableService.buildOrderedItems($scope.repositories,
$scope.options,
['namespace', 'name', 'state'], ['last_modified_datetime', 'popularity', 'quota'])
['namespace', 'name', 'state'], ['last_modified_datetime', 'popularity', 'quota_report'])
};
$scope.tablePredicateClass = function(name, predicate, reverse) {
@ -68,12 +69,20 @@ angular.module('quay').directive('repoListTable', function () {
let byte_unit = null;
for (const key in units) {
byte_unit = units[key];
if (bytes >= $scope.disk_size_units[byte_unit]) {
result = (bytes / $scope.disk_size_units[byte_unit]).toFixed(2);
if (bytes >= $scope.disk_size_units[byte_unit]) {
return result.toString() + " " + byte_unit;
}
}
return null
return result.toString() + " " + byte_unit;
};
$scope.quotaPercentConsumed = function(repository) {
if (repository.quota_report) {
return (repository.quota_report.quota_bytes / repository.quota_report.configured_quota * 100).toFixed(2);
}
return 0;
};
$scope.getAvatarData = function(namespace) {

View File

@ -33,8 +33,7 @@
'organizationEmail': ''
};
$scope.disk_size_units = {
'Bytes': 1,
'KB': 1024**1,
'KB': 1024,
'MB': 1024**2,
'GB': 1024**3,
'TB': 1024**4,
@ -50,14 +49,23 @@
let units = Object.keys($scope.disk_size_units).reverse();
let result = null;
let byte_unit = null;
for (const key in units) {
byte_unit = units[key];
if (bytes >= $scope.disk_size_units[byte_unit]) {
result = (bytes / $scope.disk_size_units[byte_unit]).toFixed(2);
if (bytes >= $scope.disk_size_units[byte_unit]) {
return result.toString() + " " + byte_unit;
}
}
return null
return result.toString() + " " + byte_unit;
};
$scope.quotaPercentConsumed = function(organization) {
if (organization.quota_report) {
return (organization.quota_report.quota_bytes / organization.quota_report.configured_quota * 100).toFixed(2);
}
return 0;
};
var loadRepositories = function() {

View File

@ -42,6 +42,7 @@
'page': 0,
}
$scope.disk_size_units = {
'KB': 1024,
'MB': 1024**2,
'GB': 1024**3,
'TB': 1024**4,
@ -67,7 +68,7 @@
return result.toString() + " " + byte_unit;
}
}
return null
return (bytes / $scope.disk_size_units["MB"]).toFixed(2).toString() + " MB";
};
$scope.loadMessageOfTheDay = function () {

View File

@ -58,16 +58,13 @@
<div class="repo-list-view" namespaces="[organization]" in-read-only-mode="inReadOnlyMode"
quota-management-enabled="quotaManagementEnabled">
<h3>Repositories</h3>
<span ng-show="Features.QUOTA_MANAGEMENT && organization.quota.configured_quota">
<span ng-show="Features.QUOTA_MANAGEMENT && organization.quotas.length > 0">
<h4>
Total Quota Consumed:
<span ng-if="organization.quota.quota_bytes != '0'">
{{ organization.quota.percent_consumed ?
(bytesToHumanReadableString(organization.quota.quota_bytes) + "(" + organization.quota.percent_consumed + "%)") :
bytesToHumanReadableString(organization.quota.quota_bytes) }}
<span ng-if="organization.quota_report.quota_bytes">
{{ bytesToHumanReadableString(organization.quota_report.quota_bytes) + "(" + quotaPercentConsumed(organization) + "%)" }}
</span>
<span ng-if="organization.quota.quota_bytes == '0'">--</span>
{{ " of " + bytesToHumanReadableString(organization.quota.configured_quota) }}
<span ng-if="organization.quota_report.quota_bytes == '0'">--</span>
</h4>
</span>
</div>

View File

@ -93,7 +93,7 @@
</td>
<td ng-class="tablePredicateClass('quota', options.predicate, options.reverse)"
ng-if="Features.QUOTA_MANAGEMENT">
<a ng-click="orderBy('quota.quota_bytes')">Quota Consumed</a>
<a ng-click="orderBy('quota_report.quota_bytes')">Quota Consumed</a>
</td>
<td style="width: 24px;"></td>
</thead>
@ -112,16 +112,16 @@
<a href="mailto:{{ current_org.email }}">{{ current_org.email }}</a>
</td>
<td ng-if="Features.QUOTA_MANAGEMENT">
<span ng-if="current_org.quota.quota_bytes != '0'">
<span ng-if="current_org.quota_report.quota_bytes != '0'">
{{
current_org.quota.percent_consumed ?
( bytesToHumanReadableString(current_org.quota.quota_bytes) + "(" + current_org.quota.percent_consumed + "%)") :
bytesToHumanReadableString(current_org.quota.quota_bytes)
current_org.quota_report.percent_consumed ?
( bytesToHumanReadableString(current_org.quota_report.quota_bytes) + "(" + current_org.quota_report.percent_consumed + "%)") :
bytesToHumanReadableString(current_org.quota_report.quota_bytes)
}}
</span>
<span ng-if="current_org.quota.quota_bytes == '0'">--</span>
<span ng-if="current_org.quota.configured_quota">
{{ " of " + bytesToHumanReadableString(current_org.quota.configured_quota) }}
<span ng-if="current_org.quota_report.quota_bytes == '0'">--</span>
<span ng-if="current_org.quota_report.configured_quota">
{{ " of " + bytesToHumanReadableString(current_org.quota_report.configured_quota) }}
</span>
</td>
<td style="text-align: center;">

View File

@ -2107,6 +2107,8 @@ class TestListRepos(ApiTestCase):
def test_listrepos_asguest(self):
# Queries: Base + the list query
# TODO: Add quota queries
with patch("features.QUOTA_MANAGEMENT", False):
with assert_query_count(BASE_QUERY_COUNT + 1):
json = self.getJsonResponse(RepositoryList, params=dict(public=True))
self.assertEqual(len(json["repositories"]), 1)
@ -2173,6 +2175,8 @@ class TestListRepos(ApiTestCase):
self.login(ADMIN_ACCESS_USER)
# Queries: Base + the list query + the popularity and last modified queries + full perms load
# TODO: Add quota queries
with patch("features.QUOTA_MANAGEMENT", False):
with assert_query_count(BASE_LOGGEDIN_QUERY_COUNT + 5):
json = self.getJsonResponse(
RepositoryList,