Skip to content

Soft deletes for releases, projects, and files #8237

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ initdb:
docker-compose run --rm web psql -h db -d postgres -U postgres -c "CREATE DATABASE warehouse ENCODING 'UTF8'"
xz -d -f -k dev/$(DB).sql.xz --stdout | docker-compose run --rm web psql -h db -d warehouse -U postgres -v ON_ERROR_STOP=1 -1 -f -
docker-compose run --rm web python -m warehouse db upgrade head
$(MAKE) reindex
# $(MAKE) reindex

reindex:
docker-compose run --rm web python -m warehouse search reindex
Expand Down
32 changes: 12 additions & 20 deletions tests/unit/utils/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
from warehouse.utils.project import (
confirm_project,
destroy_docs,
destroy_project,
remove_documentation,
remove_project,
)

from ...common.db.accounts import UserFactory
Expand Down Expand Up @@ -92,8 +92,7 @@ def test_confirm_incorrect_input():
]


@pytest.mark.parametrize("flash", [True, False])
def test_remove_project(db_request, flash):
def test_destroy_project(db_request):
user = UserFactory.create()
project = ProjectFactory.create(name="foo")
release = ReleaseFactory.create(project=project)
Expand All @@ -105,15 +104,11 @@ def test_remove_project(db_request, flash):
db_request.remote_addr = "192.168.1.1"
db_request.session = stub(flash=call_recorder(lambda *a, **kw: stub()))

remove_project(project, db_request, flash=flash)

if flash:
assert db_request.session.flash.calls == [
call("Deleted the project 'foo'", queue="success")
]
else:
assert db_request.session.flash.calls == []
destroy_project(project, db_request)

assert db_request.session.flash.calls == [
call("Deleted the project 'foo'", queue="success")
]
assert not (db_request.db.query(Role).filter(Role.project == project).count())
assert not (
db_request.db.query(File)
Expand Down Expand Up @@ -144,8 +139,7 @@ def test_remove_project(db_request, flash):
assert journal_entry.submitted_from == db_request.remote_addr


@pytest.mark.parametrize("flash", [True, False])
def test_destroy_docs(db_request, flash):
def test_destroy_docs(db_request):
user = UserFactory.create()
project = ProjectFactory.create(name="foo", has_docs=True)
RoleFactory.create(user=user, project=project)
Expand All @@ -156,7 +150,7 @@ def test_destroy_docs(db_request, flash):
remove_documentation_recorder = stub(delay=call_recorder(lambda *a, **kw: None))
db_request.task = call_recorder(lambda *a, **kw: remove_documentation_recorder)

destroy_docs(project, db_request, flash=flash)
destroy_docs(project, db_request)

journal_entry = (
db_request.db.query(JournalEntry)
Expand All @@ -176,13 +170,11 @@ def test_destroy_docs(db_request, flash):
)

assert remove_documentation_recorder.delay.calls == [call("foo")]
assert db_request.session.flash.calls == [
call("Deleted docs for project 'foo'", queue="success")
]


if flash:
assert db_request.session.flash.calls == [
call("Deleted docs for project 'foo'", queue="success")
]
else:
assert db_request.session.flash.calls == []


def test_remove_documentation(db_request):
Expand Down
7 changes: 7 additions & 0 deletions warehouse/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ def includeme(config):
traverse="/{project_name}",
domain=warehouse,
)
config.add_route(
"admin.project.restore",
"/admin/projects/{project_name}/restore/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
)

# Journal related Admin pages
config.add_route("admin.journals.list", "/admin/journals/", domain=warehouse)
Expand Down
6 changes: 3 additions & 3 deletions warehouse/admin/templates/admin/projects/delete.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,21 @@

<div class="box box-secondary collapsed-box">
<div class="box-header with-border">
<h3 class="box-title">Delete Project</h3>
<h3 class="box-title">Destroy Project</h3>
<div class="box-tools">
<button class="btn btn-box-tool" data-widget="collapse" data-toggle="tooltip" title="Expand"><i class="fa fa-plus"></i></button>
</div>
</div>

<div class="box-body">
<p>
Deleting will irreversibly delete this project along with
This will irreversibly remove this project from the database along with
<a href="{{ request.route_path('admin.project.releases', project_name=project_name) }}">{{ project.releases|length() }} releases</a>.
</p>

<div class="form-group col-sm-12">
<label for="confirm_project_name">
Are you sure you want to delete <strong>{{ project_name }}</strong>?
Are you sure you want to destroy <strong>{{ project_name }}</strong>?
</label>
<input name="confirm_project_name" class="form-control" type="text" placeholder="Enter project name to confirm" {{ "disabled" if not request.has_permission('admin') }} autocomplete="off" autocorrect="off" autocapitalize="off">
</div>
Expand Down
7 changes: 5 additions & 2 deletions warehouse/admin/templates/admin/projects/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,12 @@ <h3 class="box-title">Project activity</h3>
</div>
</div>

<!-- Deletion form -->
{% if project.soft_deleted %}
<!-- Restoration form -->
{% include 'restore.html' %}
{% endif %}
<!-- Destroy form -->
{% include 'delete.html' %}

<!-- ProhibitedProjectName form -->
<div class="box box-secondary collapsed-box">
<div class="box-header with-border">
Expand Down
49 changes: 49 additions & 0 deletions warehouse/admin/templates/admin/projects/restore.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{#
# 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.
-#}

{% set project_name = project.normalized_name %}

<form action="{{ request.route_path('admin.project.restore', project_name=project_name) }}" method="POST">
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">

<div class="box box-secondary collapsed-box">
<div class="box-header with-border">
<h3 class="box-title">Restore Project</h3>
<div class="box-tools">
<button class="btn btn-box-tool" data-widget="collapse" data-toggle="tooltip" title="Expand"><i class="fa fa-plus"></i></button>
</div>
</div>

<div class="box-body">
<p>
This will restore the soft deleted project along with
<a href="{{ request.route_path('admin.project.releases', project_name=project_name) }}">{{ project.releases|length() }} releases</a>.
</p>

<div class="form-group col-sm-12">
<label for="confirm_project_name">
Are you sure you want to restore <strong>{{ project_name }}</strong>?
</label>
<input name="confirm_project_name" class="form-control" type="text" placeholder="Enter project name to confirm" {{ "disabled" if not request.has_permission('admin') }} autocomplete="off" autocorrect="off" autocapitalize="off">
</div>

</div>
<div class="box-footer">
<div class="pull-right">
<button type="submit" class="btn btn-primary" title="{{ "Restoring requires superuser privileges" if not request.has_permission('admin') }}" {{ "disabled" if not request.has_permission('admin') }}>Confirm</button>
</div>
</div>
</div>
</form>

4 changes: 2 additions & 2 deletions warehouse/admin/views/prohibited_project_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
)
from warehouse.utils.http import is_safe_url
from warehouse.utils.paginate import paginate_url_factory
from warehouse.utils.project import remove_project
from warehouse.utils.project import destroy_project


@view_config(
Expand Down Expand Up @@ -192,7 +192,7 @@ def add_prohibited_project_names(request):
.first()
)
if project is not None:
remove_project(project, request)
destroy_project(project, request)

request.session.flash(f"Prohibited Project Name {project_name!r}", queue="success")

Expand Down
20 changes: 18 additions & 2 deletions warehouse/admin/views/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
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
from warehouse.utils.project import confirm_project, destroy_project, soft_restore_project

ONE_MB = 1024 * 1024 # bytes
ONE_GB = 1024 * 1024 * 1024 # bytes
Expand Down Expand Up @@ -469,6 +469,22 @@ def delete_role(project, request):
)



@view_config(
route_name="admin.project.restore",
permission="admin",
request_method="POST",
uses_session=True,
require_methods=False,
)
def restore_project(project, request):
confirm_project(project, request, fail_route="admin.project.detail")
soft_restore_project(project, request)

return HTTPSeeOther(request.route_path("admin.project.list"))



@view_config(
route_name="admin.project.delete",
permission="admin",
Expand All @@ -478,6 +494,6 @@ def delete_role(project, request):
)
def delete_project(project, request):
confirm_project(project, request, fail_route="admin.project.detail")
remove_project(project, request)
destroy_project(project, request)

return HTTPSeeOther(request.route_path("admin.project.list"))
16 changes: 16 additions & 0 deletions warehouse/email/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,22 @@ def send_removed_project_email(
"recipient_role_descr": recipient_role_descr,
}

@_email("restored-project")
def send_restored_project_email(
request, user, *, project_name, submitter_name, submitter_role, recipient_role
):
recipient_role_descr = "an owner"
if recipient_role == "Maintainer":
recipient_role_descr = "a maintainer"
#TODO 4440 : How do we customize the email? Who do we send the email to?
return {
"project_name": project_name,
"submitter_name": submitter_name,
"submitter_role": submitter_role.lower(),
"recipient_role_descr": recipient_role_descr,
}



@_email("yanked-project-release")
def send_yanked_project_release_email(
Expand Down
40 changes: 35 additions & 5 deletions warehouse/manage/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
)
from warehouse.utils.http import is_safe_url
from warehouse.utils.paginate import paginate_url_factory
from warehouse.utils.project import confirm_project, destroy_docs, remove_project
from warehouse.utils.project import confirm_project, destroy_docs


def user_projects(request):
Expand Down Expand Up @@ -98,11 +98,29 @@ def user_projects(request):
.all()
),
"projects_sole_owned": (
request.db.query(Project).join(with_sole_owner).order_by(Project.name).all()
request.db.query(Project)
.join(with_sole_owner)
.order_by(Project.name)
.all()
),
}


def soft_delete_file(file_):
file_.soft_deleted = True


def soft_delete_release(release):
for file_ in release.files:
soft_delete_file(file_)
release.soft_deleted = True


def soft_delete_project(project):
for release in project.releases:
soft_delete_release(release)
project.soft_deleted = True

@view_defaults(
route_name="manage.account",
renderer="manage/account.html",
Expand Down Expand Up @@ -964,7 +982,18 @@ def delete_project(project, request):
recipient_role=contributor_role,
)

remove_project(project, request)
request.db.add(
JournalEntry(
name=project.name,
action="delete project",
submitted_by=request.user,
submitted_from=request.remote_addr,
)
)

soft_delete_project(project)

request.session.flash(f"Deleted the project {project.name!r}", queue="success")

return HTTPSeeOther(request.route_path("manage.projects"))

Expand Down Expand Up @@ -1006,6 +1035,7 @@ def manage_project_releases(project, request):
.group_by(Release.id)
.group_by(File.packagetype)
.filter(Release.project == project)
.execution_options(include_deleted=True)
.all()
)

Expand Down Expand Up @@ -1276,7 +1306,7 @@ def delete_project_release(self):
},
)

self.request.db.delete(self.release)
soft_delete_release(self.release)

self.request.session.flash(
f"Deleted release {self.release.version!r}", queue="success"
Expand Down Expand Up @@ -1385,7 +1415,7 @@ def _error(message):
recipient_role=contributor_role,
)

self.request.db.delete(release_file)
soft_delete_release(self.release)

self.request.session.flash(
f"Deleted file {release_file.filename!r}", queue="success"
Expand Down
Loading