Repository: pypi/warehouse

Issue:
Enable pages to delete, hide, unhide a project/release/file
We need UI that will allow users to modify the things they register and upload to Warehouse.

Add ability to delete release
The current UI allows users to do this via the releases page:

![screenshot from 2018-01-17 06-57-12](https://user-images.githubusercontent.com/3323703/35085250-bf27ed26-fc1f-11e7-925d-0cff71a19356.png)

![screenshot from 2018-01-17 06-57-18](https://user-images.githubusercontent.com/3323703/35085258-c5be6660-fc1f-11e7-871a-8426133f0eec.png)

This code is currently commented out and should be enabled.

In addition, users should be able to delete a release via the release detail page (see  https://github.com/pypa/warehouse/issues/2807)



Discussion:
current layout:

![d6a69508-40f8-11e6-9cf7-375457770c1d](https://cloud.githubusercontent.com/assets/3323703/16544324/8a174aa6-40f9-11e6-97b2-5f95dbcf270f.png)

How is this not implemented yet, when the legacy API no longer allows editing releases?

> Gone (This API has been deprecated and removed from legacy PyPI in favor of using the APIs available in the new PyPI.org implementation of PyPI (located at https://pypi.org/). For more information about migrating your use of this API to PyPI.org, please see https://packaging.python.org/guides/migrating-to-pypi-org/#uploading. For more information about the sunsetting of this API, please see https://mail.python.org/pipermail/distutils-sig/2017-June/030766.html)

There currently seems to be no way to edit a project/release/file on PyPi.org. How was the old API allowed to be shut-down when the new one lacks significant functionality?
In our [meeting today](https://wiki.python.org/psf/PackagingWG/2017-12-19-Warehouse) we discussed this issue and the larger question of release metadata immutability. Per @dstufft's [email to `distutils-sig`](https://mail.python.org/pipermail/distutils-sig/2017-December/031826.html) (reproduced in https://github.com/pypa/warehouse/issues/2170#issuecomment-352866597 ), we decided that we're going to narrow this issue to focus on the features we do want to implement:

* giving package managers a way to delete their own files, releases, and projects
* giving package maintainers a way to manage hidden vs unhidden releases

As I understand it, that set of features does belong in [the first milestone, the MVP for package maintainers](https://github.com/pypa/warehouse/milestone/8). @dstufft, @ewdurbin, @di and others, please correct me if I'm wrong.
As per @dstufft's comment here - https://github.com/pypa/warehouse/issues/2734#issuecomment-355788559 - we can remove "giving package maintainers a way to manage hidden vs unhidden releases" from this specification.
  
A sub ticket of #424

Resolving patch:
diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py
--- 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
--- 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
--- 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/",


Test patch:
diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py
--- 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
--- 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
--- 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/",