diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 7757dbd637ec..5f90a5854dab 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -208,6 +208,297 @@ def test_manage_project_releases(self): } +class TestManageProjectRelease: + + def test_manage_project_release(self): + files = pretend.stub() + project = pretend.stub() + release = pretend.stub( + project=project, + files=pretend.stub(all=lambda: files), + ) + request = pretend.stub() + view = views.ManageProjectRelease(release, request) + + assert view.manage_project_release() == { + 'project': project, + 'release': release, + 'files': files, + } + + def test_delete_project_release(self, monkeypatch): + release = pretend.stub( + version='1.2.3', + project=pretend.stub(name='foobar'), + ) + request = pretend.stub( + POST={'confirm_version': release.version}, + method="POST", + db=pretend.stub( + delete=pretend.call_recorder(lambda a: None), + add=pretend.call_recorder(lambda a: None), + ), + route_path=pretend.call_recorder(lambda *a, **kw: '/the-redirect'), + session=pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ), + user=pretend.stub(), + remote_addr=pretend.stub(), + ) + journal_obj = pretend.stub() + journal_cls = pretend.call_recorder(lambda **kw: journal_obj) + monkeypatch.setattr(views, 'JournalEntry', journal_cls) + + view = views.ManageProjectRelease(release, request) + + result = view.delete_project_release() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + + assert request.db.delete.calls == [pretend.call(release)] + assert request.db.add.calls == [pretend.call(journal_obj)] + assert journal_cls.calls == [ + pretend.call( + name=release.project.name, + action="remove", + version=release.version, + submitted_by=request.user, + submitted_from=request.remote_addr, + ), + ] + assert request.session.flash.calls == [ + pretend.call( + f"Successfully deleted release {release.version!r}.", + queue="success", + ) + ] + assert request.route_path.calls == [ + pretend.call( + 'manage.project.releases', + project_name=release.project.name, + ) + ] + + def test_delete_project_release_no_confirm(self): + release = pretend.stub( + version='1.2.3', + project=pretend.stub(name='foobar'), + ) + request = pretend.stub( + POST={'confirm_version': ''}, + method="POST", + db=pretend.stub(delete=pretend.call_recorder(lambda a: None)), + route_path=pretend.call_recorder(lambda *a, **kw: '/the-redirect'), + session=pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ), + ) + view = views.ManageProjectRelease(release, request) + + result = view.delete_project_release() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + + assert request.db.delete.calls == [] + assert request.session.flash.calls == [ + pretend.call( + "Must confirm the request.", queue='error' + ) + ] + assert request.route_path.calls == [ + pretend.call( + 'manage.project.release', + project_name=release.project.name, + version=release.version, + ) + ] + + def test_delete_project_release_bad_confirm(self): + release = pretend.stub( + version='1.2.3', + project=pretend.stub(name='foobar'), + ) + request = pretend.stub( + POST={'confirm_version': 'invalid'}, + method="POST", + db=pretend.stub(delete=pretend.call_recorder(lambda a: None)), + route_path=pretend.call_recorder(lambda *a, **kw: '/the-redirect'), + session=pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ), + ) + view = views.ManageProjectRelease(release, request) + + result = view.delete_project_release() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + + assert request.db.delete.calls == [] + assert request.session.flash.calls == [ + pretend.call( + "Could not delete release - " + + f"'invalid' is not the same as {release.version!r}", + queue="error", + ) + ] + assert request.route_path.calls == [ + pretend.call( + 'manage.project.release', + project_name=release.project.name, + version=release.version, + ) + ] + + def test_delete_project_release_file(self, monkeypatch): + release_file = pretend.stub( + filename='foo-bar.tar.gz', + id=str(uuid.uuid4()), + ) + release = pretend.stub( + version='1.2.3', + project=pretend.stub(name='foobar'), + ) + request = pretend.stub( + POST={ + 'confirm_filename': release_file.filename, + 'file_id': release_file.id, + }, + method="POST", + db=pretend.stub( + delete=pretend.call_recorder(lambda a: None), + add=pretend.call_recorder(lambda a: None), + query=lambda a: pretend.stub( + filter=lambda *a: pretend.stub(one=lambda: release_file), + ), + ), + route_path=pretend.call_recorder(lambda *a, **kw: '/the-redirect'), + session=pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ), + user=pretend.stub(), + remote_addr=pretend.stub(), + ) + journal_obj = pretend.stub() + journal_cls = pretend.call_recorder(lambda **kw: journal_obj) + monkeypatch.setattr(views, 'JournalEntry', journal_cls) + + view = views.ManageProjectRelease(release, request) + + result = view.delete_project_release_file() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + + assert request.session.flash.calls == [ + pretend.call( + f"Successfully deleted file {release_file.filename!r}.", + queue="success", + ) + ] + assert request.db.delete.calls == [pretend.call(release_file)] + assert request.db.add.calls == [pretend.call(journal_obj)] + assert journal_cls.calls == [ + pretend.call( + name=release.project.name, + action=f"remove file {release_file.filename}", + version=release.version, + submitted_by=request.user, + submitted_from=request.remote_addr, + ), + ] + assert request.route_path.calls == [ + pretend.call( + 'manage.project.release', + project_name=release.project.name, + version=release.version, + ) + ] + + def test_delete_project_release_file_no_confirm(self): + release = pretend.stub( + version='1.2.3', + project=pretend.stub(name='foobar'), + ) + request = pretend.stub( + POST={'confirm_filename': ''}, + method="POST", + db=pretend.stub(delete=pretend.call_recorder(lambda a: None)), + route_path=pretend.call_recorder(lambda *a, **kw: '/the-redirect'), + session=pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ), + ) + view = views.ManageProjectRelease(release, request) + + result = view.delete_project_release_file() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + + assert request.db.delete.calls == [] + assert request.session.flash.calls == [ + pretend.call( + "Must confirm the request.", queue='error' + ) + ] + assert request.route_path.calls == [ + pretend.call( + 'manage.project.release', + project_name=release.project.name, + version=release.version, + ) + ] + + def test_delete_project_release_file_bad_confirm(self): + release_file = pretend.stub( + filename='foo-bar.tar.gz', + id=str(uuid.uuid4()), + ) + release = pretend.stub( + version='1.2.3', + project=pretend.stub(name='foobar'), + ) + request = pretend.stub( + POST={'confirm_filename': 'invalid'}, + method="POST", + db=pretend.stub( + delete=pretend.call_recorder(lambda a: None), + query=lambda a: pretend.stub( + filter=lambda *a: pretend.stub(one=lambda: release_file), + ), + ), + route_path=pretend.call_recorder(lambda *a, **kw: '/the-redirect'), + session=pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ), + ) + view = views.ManageProjectRelease(release, request) + + result = view.delete_project_release_file() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + + assert request.db.delete.calls == [] + assert request.session.flash.calls == [ + pretend.call( + "Could not delete file - " + + f"'invalid' is not the same as {release_file.filename!r}", + queue="error", + ) + ] + assert request.route_path.calls == [ + pretend.call( + 'manage.project.release', + project_name=release.project.name, + version=release.version, + ) + ] + + class TestManageProjectRoles: def test_get_manage_project_roles(self, db_request): diff --git a/tests/unit/packaging/test_models.py b/tests/unit/packaging/test_models.py index 74c2eae3b679..184f8c06764a 100644 --- a/tests/unit/packaging/test_models.py +++ b/tests/unit/packaging/test_models.py @@ -254,6 +254,28 @@ def test_urls(self, db_session, home_page, download_url, project_urls, # TODO: It'd be nice to test for the actual ordering here. assert dict(release.urls) == dict(expected) + def test_acl(self, db_session): + project = DBProjectFactory.create() + owner1 = DBRoleFactory.create(project=project) + owner2 = DBRoleFactory.create(project=project) + maintainer1 = DBRoleFactory.create( + project=project, + role_name="Maintainer", + ) + maintainer2 = DBRoleFactory.create( + project=project, + role_name="Maintainer", + ) + release = DBReleaseFactory.create(project=project) + + assert release.__acl__() == [ + (Allow, "group:admins", "admin"), + (Allow, str(owner1.user.id), ["manage", "upload"]), + (Allow, str(owner2.user.id), ["manage", "upload"]), + (Allow, str(maintainer1.user.id), ["upload"]), + (Allow, str(maintainer2.user.id), ["upload"]), + ] + class TestFile: diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index edc112400048..d84cfc78ceca 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -168,6 +168,13 @@ def add_policy(name, filename): traverse="/{project_name}", domain=warehouse, ), + pretend.call( + "manage.project.release", + "/manage/project/{project_name}/release/{version}/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{project_name}/{version}", + domain=warehouse, + ), pretend.call( "manage.project.roles", "/manage/project/{project_name}/collaboration/", diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py index 9a6c77d834f2..4ff58d42b3b0 100644 --- a/warehouse/manage/views.py +++ b/warehouse/manage/views.py @@ -23,7 +23,7 @@ from warehouse.manage.forms import ( CreateRoleForm, ChangeRoleForm, SaveProfileForm ) -from warehouse.packaging.models import JournalEntry, Role +from warehouse.packaging.models import JournalEntry, Role, File from warehouse.utils.project import confirm_project, remove_project @@ -109,6 +109,151 @@ def manage_project_releases(project, request): return {"project": project} +@view_defaults( + route_name="manage.project.release", + renderer="manage/release.html", + uses_session=True, + require_csrf=True, + require_methods=False, + permission="manage", + effective_principals=Authenticated, +) +class ManageProjectRelease: + def __init__(self, release, request): + self.release = release + self.request = request + + @view_config(request_method="GET") + def manage_project_release(self): + return { + "project": self.release.project, + "release": self.release, + "files": self.release.files.all(), + } + + @view_config( + request_method="POST", + request_param=["confirm_version"] + ) + def delete_project_release(self): + version = self.request.POST.get('confirm_version') + if not version: + self.request.session.flash( + "Must confirm the request.", queue='error' + ) + return HTTPSeeOther( + self.request.route_path( + 'manage.project.release', + project_name=self.release.project.name, + version=self.release.version, + ) + ) + + if version != self.release.version: + self.request.session.flash( + "Could not delete release - " + + f"{version!r} is not the same as {self.release.version!r}", + queue="error", + ) + return HTTPSeeOther( + self.request.route_path( + 'manage.project.release', + project_name=self.release.project.name, + version=self.release.version, + ) + ) + + self.request.db.add( + JournalEntry( + name=self.release.project.name, + action="remove", + version=self.release.version, + submitted_by=self.request.user, + submitted_from=self.request.remote_addr, + ), + ) + + self.request.db.delete(self.release) + + self.request.session.flash( + f"Successfully deleted release {self.release.version!r}.", + queue="success", + ) + + return HTTPSeeOther( + self.request.route_path( + 'manage.project.releases', + project_name=self.release.project.name, + ) + ) + + @view_config( + request_method="POST", + request_param=["confirm_filename", "file_id"] + ) + def delete_project_release_file(self): + filename = self.request.POST.get('confirm_filename') + if not filename: + self.request.session.flash( + "Must confirm the request.", queue='error' + ) + return HTTPSeeOther( + self.request.route_path( + 'manage.project.release', + project_name=self.release.project.name, + version=self.release.version, + ) + ) + + release_file = ( + self.request.db.query(File) + .filter( + File.name == self.release.project.name, + File.id == self.request.POST.get('file_id'), + ) + .one() + ) + + if filename != release_file.filename: + self.request.session.flash( + "Could not delete file - " + + f"{filename!r} is not the same as {release_file.filename!r}", + queue="error", + ) + return HTTPSeeOther( + self.request.route_path( + 'manage.project.release', + project_name=self.release.project.name, + version=self.release.version, + ) + ) + + self.request.db.add( + JournalEntry( + name=self.release.project.name, + action=f"remove file {release_file.filename}", + version=self.release.version, + submitted_by=self.request.user, + submitted_from=self.request.remote_addr, + ), + ) + + self.request.db.delete(release_file) + + self.request.session.flash( + f"Successfully deleted file {release_file.filename!r}.", + queue="success", + ) + + return HTTPSeeOther( + self.request.route_path( + 'manage.project.release', + project_name=self.release.project.name, + version=self.release.version, + ) + ) + + @view_config( route_name="manage.project.roles", renderer="manage/roles.html", diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index b67670c60e43..ad7bf0bfc43f 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -342,6 +342,25 @@ def __table_args__(cls): # noqa viewonly=True, ) + def __acl__(self): + session = orm.object_session(self) + acls = [ + (Allow, "group:admins", "admin"), + ] + + # Get all of the users for this project. + query = session.query(Role).filter(Role.project == self) + query = query.options(orm.lazyload("project")) + query = query.options(orm.joinedload("user").lazyload("emails")) + for role in sorted( + query.all(), + key=lambda x: ["Owner", "Maintainer"].index(x.role_name)): + if role.role_name == "Owner": + acls.append((Allow, str(role.user.id), ["manage", "upload"])) + else: + acls.append((Allow, str(role.user.id), ["upload"])) + return acls + @property def urls(self): _urls = OrderedDict() diff --git a/warehouse/routes.py b/warehouse/routes.py index 6366d707921a..487e072481dc 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -134,6 +134,13 @@ def includeme(config): traverse="/{project_name}", domain=warehouse, ) + config.add_route( + "manage.project.release", + "/manage/project/{project_name}/release/{version}/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{project_name}/{version}", + domain=warehouse, + ) config.add_route( "manage.project.roles", "/manage/project/{project_name}/collaboration/", diff --git a/warehouse/static/js/warehouse/index.js b/warehouse/static/js/warehouse/index.js index 5fb6e61599c8..919d8d973d94 100644 --- a/warehouse/static/js/warehouse/index.js +++ b/warehouse/static/js/warehouse/index.js @@ -65,7 +65,10 @@ docReady(formUtils.submitTriggers); docReady(Statuspage); -// Copy handler for the pip command on package detail page +// Copy handler for +// - the pip command on package detail page +// - the copy hash on package detail page +// - the copy hash on release maintainers page docReady(() => { let setCopiedTooltip = (e) => { e.trigger.setAttribute("aria-label", "Copied!"); @@ -73,7 +76,7 @@ docReady(() => { }; new Clipboard(".-js-copy-pip-command").on("success", setCopiedTooltip); - new Clipboard(".-js-copy-sha256-link").on("success", setCopiedTooltip); + new Clipboard(".-js-copy-hash").on("success", setCopiedTooltip); // Get all elements with class "tooltipped" and bind to focousout and // mouseout events. Change the "aria-label" to "original-label" attribute diff --git a/warehouse/static/sass/blocks/_breadcrumbs.scss b/warehouse/static/sass/blocks/_breadcrumbs.scss new file mode 100644 index 000000000000..8dbd02371783 --- /dev/null +++ b/warehouse/static/sass/blocks/_breadcrumbs.scss @@ -0,0 +1,19 @@ +.breadcrumbs { + margin: 0; + padding: 0; + @include clearfix; + + &__breadcrumb { + list-style-type: none; + display: inline-block; + float: left; + + &:not(:last-child):after { + content: "\f054"; + font-family: "FontAwesome"; + margin: 0 5px; + color: $light-grey; + font-size: 12px; + } + } +} diff --git a/warehouse/static/sass/blocks/_dropdown.scss b/warehouse/static/sass/blocks/_dropdown.scss index 303599d76228..6913ff93ff37 100644 --- a/warehouse/static/sass/blocks/_dropdown.scss +++ b/warehouse/static/sass/blocks/_dropdown.scss @@ -79,7 +79,7 @@ &:hover { background-color: $base-grey; color: $text-color; - text-decoration: underline; + text-decoration: none; } .fa { diff --git a/warehouse/static/sass/blocks/_heading-wsubtitle.scss b/warehouse/static/sass/blocks/_heading-wsubtitle.scss index 71832d9cb240..eccc660fc20b 100644 --- a/warehouse/static/sass/blocks/_heading-wsubtitle.scss +++ b/warehouse/static/sass/blocks/_heading-wsubtitle.scss @@ -19,6 +19,10 @@

Heading

Subtitle

+ + Modifiers: + - in-content: restore heading spacing for instances where heading is not + at the top of the page */ .heading-wsubtitle { @@ -30,4 +34,10 @@ font-style: italic; margin-bottom: 15px; } + + &--in-content { + .heading-wsubtitle__heading { + padding-top: 30px; + } + } } diff --git a/warehouse/static/sass/blocks/_modal.scss b/warehouse/static/sass/blocks/_modal.scss index a3fa620c8415..edb8c6c81f65 100644 --- a/warehouse/static/sass/blocks/_modal.scss +++ b/warehouse/static/sass/blocks/_modal.scss @@ -71,6 +71,7 @@ &__title { font-size: 1.5rem; + padding-right: 20px; // Avoid overlap with close button } &__footer { @@ -121,4 +122,10 @@ margin: 0; } } + + &--wide { + .modal__content { + width: 850px; + } + } } diff --git a/warehouse/static/sass/blocks/_table.scss b/warehouse/static/sass/blocks/_table.scss index 97ed9031ce5a..754c01791e6c 100644 --- a/warehouse/static/sass/blocks/_table.scss +++ b/warehouse/static/sass/blocks/_table.scss @@ -191,10 +191,6 @@ width: 130px; } - .table__version { - font-weight: bold; - } - .table__options { width: 110px; text-align: right; @@ -254,6 +250,167 @@ } } + &--files { + .table__name, + .table__type, + .table__version, + .table__upload { + padding-right: 20px; + } + + .table__type { + width: 90px; + } + + .table__version { + width: 110px; + } + + .table__upload { + width: 130px; + } + + .table__options { + width: 100px; + } + + @media only screen and (max-width: $large-desktop) { + .table__version { + display: none; + } + } + + @media only screen and (max-width: $desktop) { + .table__type { + display: none; + } + } + + @media only screen and (max-width: $small-tablet) { + thead { + display: none; + } + + tbody tr, + tbody tr:nth-child(2n) { + display: block; + padding: 20px 0; + border-bottom: 1px solid $base-grey; + } + + .table__name, + .table__upload, + .table__options { + display: block; + width: 100%; + text-align: left; + border-bottom: 0; + padding: 4px 0; + } + + .table__name a { + font-weight: bold; + } + + .table__options { + .dropdown { + display: block; + float: none; + + .button { + display: block; + width: 100%; + } + + .dropdown__content { + width: 100%; + } + } + } + } + } + + &--hashes { + margin-top: $spacing-unit / 2; + + .table__algorithm, + .table__hash { + padding-right: 20px; + } + + td.table__algorithm { + font-weight: 600; + } + + td.table__hash { + word-break: break-all; + } + + .table__copy { + width: 75px; + text-align: right; + } + + @media only screen and (max-width: $mobile) { + margin-top: 0; + + thead { + display: none; + } + + tbody tr, + tbody tr:nth-child(2n) { + display: block; + padding: 20px 0; + border-bottom: 1px solid $base-grey; + } + + tbody tr:last-of-type { + border-bottom: 0; + } + + .table__algorithm, + .table__copy, + .table__hash { + display: block; + text-align: left; + border-bottom: 0; + padding: 4px 0; + } + + .table__copy { + width: 100%; + + .button { + display: block; + text-align: center; + + // Change direction of the copy tooltip on mobile + $tooltip-background-color: transparentize($black, 0.3) !default; + + &::after { + top: 100%; + right: 50%; + margin-top: 5px; + margin-right: auto; + bottom: auto; + transform: translate(50%, 0); + } + + &::before { + top: auto; + left: auto; + right: 50%; + bottom: -5px; + margin-right: -5px; + border-color: transparent; + border-bottom-color: $tooltip-background-color; + } + } + } + } + } + &--collaborators { .table__user { padding-right: 20px; @@ -297,7 +454,7 @@ tbody tr, tbody tr:nth-child(2n) { display: block; - padding: 20px 10px; + padding: 20px 0; border-bottom: 1px solid $base-grey; } @@ -313,6 +470,10 @@ padding: 4px 0; } + .table__user a { + font-weight: bold; + } + input[type="text"], select, .table__action, diff --git a/warehouse/static/sass/warehouse.scss b/warehouse/static/sass/warehouse.scss index 625058911a67..bc3d50177a19 100644 --- a/warehouse/static/sass/warehouse.scss +++ b/warehouse/static/sass/warehouse.scss @@ -65,6 +65,7 @@ @import "blocks/applied-filters"; @import "blocks/author-profile"; @import "blocks/badge"; +@import "blocks/breadcrumbs"; @import "blocks/button"; @import "blocks/button-group"; @import "blocks/callout-block"; diff --git a/warehouse/templates/manage/release.html b/warehouse/templates/manage/release.html new file mode 100644 index 000000000000..b7f5f738dd7a --- /dev/null +++ b/warehouse/templates/manage/release.html @@ -0,0 +1,226 @@ +{# + # 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. +-#} +{% extends "manage_project_base.html" %} + +{% set active_tab = 'releases' %} + +{% block title %}Manage '{{ project.name }}' release{% endblock %} + +{% block main %} +
+

Release Version {{ release.version }}

+ + +
+ + {% if files %} + + + + + + + + + + + + {% for file in files %} + + + + + + + + {% endfor %} + +
Filename, SizeTypePy VersionUpload Date
+ + {{ file.filename }} + + {% if file.size %}({{ file.size|filesizeformat() }}){% endif %}
+
{{ file.packagetype|format_package_type }}{{ file.requires_python }}{{ file.upload_time|format_date() }} + +
+ {% endif %} + +
+ {% if files %} +

Uploading New Files

+ {% else %} +

No Files Found

+ {% endif %} +

Learn how to upload files on the Python Packaging User Guide

+
+ +

Release Settings

+ +
+

Delete Release

+

+ {% if files %} + Deleting will irreversibly delete this release along with {{ files|length() }} + {% trans count=files|length %} + file. + {% pluralize %} + files. + {% endtrans %} + {% else %} + Deleting will irreversibly delete this release. + {% endif %} +

+ Delete +
+ + + + {% if files %} + {% for file in files %} + + + + {% endfor %} + {% endif %} +{% endblock %} diff --git a/warehouse/templates/manage/releases.html b/warehouse/templates/manage/releases.html index 8b76863bde7c..3ec6c05dc40a 100644 --- a/warehouse/templates/manage/releases.html +++ b/warehouse/templates/manage/releases.html @@ -31,8 +31,9 @@

Releases ({{ project.releases|length }})

{% for release in project.releases %} - {# TODO: https://github.com/pypa/warehouse/issues/2807 {{ release.version }} #} - {{ release.version }} + + {{ release.version }} + @@ -52,12 +53,10 @@

Releases ({{ project.releases|length }})