diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py
index 39cc150b1b39..976e3d89b00d 100644
--- a/tests/unit/admin/test_routes.py
+++ b/tests/unit/admin/test_routes.py
@@ -79,6 +79,13 @@ def test_includeme():
traverse="/{project_name}",
domain=warehouse,
),
+ pretend.call(
+ "admin.project.set_total_size_limit",
+ "/admin/projects/{project_name}/set_total_size_limit/",
+ factory="warehouse.packaging.models:ProjectFactory",
+ traverse="/{project_name}",
+ domain=warehouse,
+ ),
pretend.call(
"admin.project.add_role",
"/admin/projects/{project_name}/add_role/",
diff --git a/tests/unit/admin/views/test_projects.py b/tests/unit/admin/views/test_projects.py
index 94136b484852..8a1839d68831 100644
--- a/tests/unit/admin/views/test_projects.py
+++ b/tests/unit/admin/views/test_projects.py
@@ -111,6 +111,8 @@ def test_gets_project(self, db_request):
"squattees": [squattee],
"ONE_MB": views.ONE_MB,
"MAX_FILESIZE": views.MAX_FILESIZE,
+ "MAX_PROJECT_SIZE": views.MAX_PROJECT_SIZE,
+ "ONE_GB": views.ONE_GB,
}
def test_non_normalized_name(self, db_request):
@@ -332,6 +334,66 @@ def test_non_normalized_name(self, db_request):
views.journals_list(project, db_request)
+class TestProjectSetTotalSizeLimit:
+ def test_sets_total_size_limitwith_integer(self, db_request):
+ project = ProjectFactory.create(name="foo")
+
+ db_request.route_path = pretend.call_recorder(
+ lambda *a, **kw: "/admin/projects/"
+ )
+ db_request.session = pretend.stub(
+ flash=pretend.call_recorder(lambda *a, **kw: None)
+ )
+ db_request.matchdict["project_name"] = project.normalized_name
+ db_request.POST["total_size_limit"] = "150"
+
+ views.set_total_size_limit(project, db_request)
+
+ assert db_request.session.flash.calls == [
+ pretend.call("Set the total size limit on 'foo'", queue="success")
+ ]
+
+ assert project.total_size_limit == 150 * views.ONE_GB
+
+ def test_sets_total_size_limitwith_none(self, db_request):
+ project = ProjectFactory.create(name="foo")
+ project.total_size_limit = 150 * views.ONE_GB
+
+ db_request.route_path = pretend.call_recorder(
+ lambda *a, **kw: "/admin/projects/"
+ )
+ db_request.session = pretend.stub(
+ flash=pretend.call_recorder(lambda *a, **kw: None)
+ )
+ db_request.matchdict["project_name"] = project.normalized_name
+
+ views.set_total_size_limit(project, db_request)
+
+ assert db_request.session.flash.calls == [
+ pretend.call("Set the total size limit on 'foo'", queue="success")
+ ]
+
+ assert project.total_size_limit is None
+
+ def test_sets_total_size_limitwith_non_integer(self, db_request):
+ project = ProjectFactory.create(name="foo")
+
+ db_request.matchdict["project_name"] = project.normalized_name
+ db_request.POST["total_size_limit"] = "meep"
+
+ with pytest.raises(HTTPBadRequest):
+ views.set_total_size_limit(project, db_request)
+
+ def test_sets_total_size_limit_with_less_than_minimum(self, db_request):
+ project = ProjectFactory.create(name="foo")
+
+ db_request.matchdict["project_name"] = project.normalized_name
+ db_request.POST["total_size_limit"] = "9"
+
+ with pytest.raises(HTTPBadRequest):
+ views.set_total_size_limit(project, db_request)
+
+
class TestProjectSetLimit:
def test_sets_limitwith_integer(self, db_request):
project = ProjectFactory.create(name="foo")
diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py
index 21c998673c82..ffda496c8467 100644
--- a/warehouse/admin/routes.py
+++ b/warehouse/admin/routes.py
@@ -74,6 +74,13 @@ def includeme(config):
traverse="/{project_name}",
domain=warehouse,
)
+ config.add_route(
+ "admin.project.set_total_size_limit",
+ "/admin/projects/{project_name}/set_total_size_limit/",
+ factory="warehouse.packaging.models:ProjectFactory",
+ traverse="/{project_name}",
+ domain=warehouse,
+ )
config.add_route(
"admin.project.add_role",
"/admin/projects/{project_name}/add_role/",
diff --git a/warehouse/admin/templates/admin/projects/detail.html b/warehouse/admin/templates/admin/projects/detail.html
index ad91c033c9b1..ec94536dbe7b 100644
--- a/warehouse/admin/templates/admin/projects/detail.html
+++ b/warehouse/admin/templates/admin/projects/detail.html
@@ -63,6 +63,16 @@
Attributes:
{% endif %}
+
+ Total size limit |
+
+ {% if project.total_size_limit %}
+ {{ project.total_size_limit|filesizeformat(binary=True) }}
+ {% else %}
+ Default ({{(MAX_PROJECT_SIZE)|filesizeformat(binary=True) }})
+ {% endif %}
+ |
+
Maintainers:
@@ -315,4 +325,36 @@ Set upload limit
+
+
+
+
+
{% endblock %}
diff --git a/warehouse/admin/views/projects.py b/warehouse/admin/views/projects.py
index 8019b3a935f7..bc2d716a10fb 100644
--- a/warehouse/admin/views/projects.py
+++ b/warehouse/admin/views/projects.py
@@ -20,12 +20,13 @@
from sqlalchemy.orm.exc import NoResultFound
from warehouse.accounts.models import User
-from warehouse.forklift.legacy import MAX_FILESIZE
+from warehouse.forklift.legacy import MAX_FILESIZE, MAX_PROJECT_SIZE
from warehouse.packaging.models import JournalEntry, Project, Release, Role
from warehouse.utils.paginate import paginate_url_factory
from warehouse.utils.project import confirm_project, remove_project
ONE_MB = 1024 * 1024 # bytes
+ONE_GB = 1024 * 1024 * 1024 # bytes
@view_config(
@@ -143,6 +144,8 @@ def project_detail(project, request):
"squattees": squattees,
"ONE_MB": ONE_MB,
"MAX_FILESIZE": MAX_FILESIZE,
+ "ONE_GB": ONE_GB,
+ "MAX_PROJECT_SIZE": MAX_PROJECT_SIZE,
}
@@ -306,6 +309,47 @@ def set_upload_limit(project, request):
)
+@view_config(
+ route_name="admin.project.set_total_size_limit",
+ permission="moderator",
+ request_method="POST",
+ uses_session=True,
+ require_methods=False,
+)
+def set_total_size_limit(project, request):
+ total_size_limit = request.POST.get("total_size_limit", "")
+
+ if not total_size_limit:
+ total_size_limit = None
+ else:
+ try:
+ total_size_limit = int(total_size_limit)
+ except ValueError:
+ raise HTTPBadRequest(
+ f"Invalid value for total size limit: {total_size_limit}, "
+ f"must be integer or empty string."
+ )
+
+ # The form is in GB, but the database field is in bytes.
+ total_size_limit *= ONE_GB
+
+ if total_size_limit < MAX_PROJECT_SIZE:
+ raise HTTPBadRequest(
+ f"Total project size can not be less than the default limit of "
+ f"{MAX_PROJECT_SIZE / ONE_GB}GB."
+ )
+
+ project.total_size_limit = total_size_limit
+
+ request.session.flash(
+ f"Set the total size limit on {project.name!r}", queue="success"
+ )
+
+ return HTTPSeeOther(
+ request.route_path("admin.project.detail", project_name=project.normalized_name)
+ )
+
+
@view_config(
route_name="admin.project.add_role",
permission="admin",
diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py
index ddc9e83300d7..4665d6497e91 100644
--- a/warehouse/forklift/legacy.py
+++ b/warehouse/forklift/legacy.py
@@ -61,7 +61,7 @@
MAX_FILESIZE = 60 * 1024 * 1024 # 60M
MAX_SIGSIZE = 8 * 1024 # 8K
-
+MAX_PROJECT_SIZE = 10 * 1024 * 1024 * 1024 # 10GB
PATH_HASHER = "blake2_256"
diff --git a/warehouse/migrations/versions/bc8f7b526961_text.py b/warehouse/migrations/versions/bc8f7b526961_text.py
new file mode 100644
index 000000000000..b8e8c9c4fe61
--- /dev/null
+++ b/warehouse/migrations/versions/bc8f7b526961_text.py
@@ -0,0 +1,35 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+text
+
+Revision ID: bc8f7b526961
+Revises: 19ca1c78e613
+Create Date: 2020-06-16 21:14:53.343466
+"""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "bc8f7b526961"
+down_revision = "19ca1c78e613"
+
+
+def upgrade():
+ op.add_column(
+ "projects", sa.Column("total_size_limit", sa.BigInteger(), nullable=True)
+ )
+
+
+def downgrade():
+ op.drop_column("projects", "total_size_limit")
diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py
index e8984da882fa..b96ba2b414eb 100644
--- a/warehouse/packaging/models.py
+++ b/warehouse/packaging/models.py
@@ -114,6 +114,7 @@ class Project(SitemapMixin, db.Model):
)
has_docs = Column(Boolean)
upload_limit = Column(Integer, nullable=True)
+ total_size_limit = Column(BigInteger, nullable=True)
last_serial = Column(Integer, nullable=False, server_default=sql.text("0"))
zscore = Column(Float, nullable=True)