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

+ + +
+
+

Set total size limit

+
+ +
+
+
+ +
+
+ + {% if project.total_size_limit %} + {% set total_size_limit_value = project.total_size_limit // ONE_GB %} + {% else %} + {% set total_size_limit_value = '' %} + {% endif %} + +
+
+ + +
+
+ + {% 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)