diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py
index 3cdbab6326b6..578b39e318f5 100644
--- a/tests/unit/admin/test_routes.py
+++ b/tests/unit/admin/test_routes.py
@@ -111,4 +111,6 @@ def test_includeme():
),
pretend.call("admin.flags", "/admin/flags/", domain=warehouse),
pretend.call("admin.flags.edit", "/admin/flags/edit/", domain=warehouse),
+ pretend.call("admin.squats", "/admin/squats/", domain=warehouse),
+ pretend.call("admin.squats.review", "/admin/squats/review/", domain=warehouse),
]
diff --git a/tests/unit/admin/views/test_projects.py b/tests/unit/admin/views/test_projects.py
index 4676b5d2be94..d3617f685a59 100644
--- a/tests/unit/admin/views/test_projects.py
+++ b/tests/unit/admin/views/test_projects.py
@@ -10,6 +10,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import datetime
+
import pretend
import pytest
import uuid
@@ -88,12 +90,28 @@ def test_gets_project(self, db_request):
[RoleFactory(project=project) for _ in range(5)],
key=lambda x: (x.role_name, x.user.username),
)
+ delta = datetime.timedelta(days=1)
+ squatter = ProjectFactory(
+ name=project.name[:-1], created=project.created + delta
+ )
+ squattee = ProjectFactory(
+ name=project.name[1:], created=project.created - delta
+ )
+ db_request.db.add(squatter)
+ db_request.db.add(squattee)
db_request.matchdict["project_name"] = str(project.normalized_name)
result = views.project_detail(project, db_request)
- assert result["project"] == project
- assert result["maintainers"] == roles
- assert result["journal"] == journals[:30]
+ assert result == {
+ "project": project,
+ "releases": [],
+ "maintainers": roles,
+ "journal": journals[:30],
+ "squatters": [squatter],
+ "squattees": [squattee],
+ "ONE_MB": views.ONE_MB,
+ "MAX_FILESIZE": views.MAX_FILESIZE,
+ }
def test_non_normalized_name(self, db_request):
project = ProjectFactory.create()
diff --git a/tests/unit/admin/views/test_squats.py b/tests/unit/admin/views/test_squats.py
new file mode 100644
index 000000000000..aceb9733980b
--- /dev/null
+++ b/tests/unit/admin/views/test_squats.py
@@ -0,0 +1,47 @@
+# 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.
+
+from warehouse.admin.squats import Squat
+from warehouse.admin.views import squats as views
+
+from ....common.db.packaging import ProjectFactory
+
+
+class TestGetSquats:
+ def test_get_squats(self, db_request):
+ project_a = ProjectFactory()
+ project_b = ProjectFactory()
+ project_c = ProjectFactory()
+ squat = Squat(squattee=project_a, squatter=project_b)
+ reviewed_squat = Squat(squattee=project_a, squatter=project_c, reviewed=True)
+ db_request.db.add(squat)
+ db_request.db.add(reviewed_squat)
+
+ assert views.get_squats(db_request) == {"squats": [squat]}
+
+
+class TestReviewSquat:
+ def test_review_squat(self, db_request):
+ squat = Squat(squattee=ProjectFactory(), squatter=ProjectFactory())
+ db_request.db.add(squat)
+
+ db_request.db.flush()
+
+ db_request.POST = {"id": squat.id}
+ db_request.route_path = lambda *a: "/the/redirect"
+ db_request.flash = lambda *a: None
+
+ views.review_squat(db_request)
+
+ db_request.db.flush()
+
+ assert squat.reviewed is True
diff --git a/tests/unit/forklift/test_legacy.py b/tests/unit/forklift/test_legacy.py
index cc6e6eefa542..95d3dfc146db 100644
--- a/tests/unit/forklift/test_legacy.py
+++ b/tests/unit/forklift/test_legacy.py
@@ -29,6 +29,7 @@
from wtforms.form import Form
from wtforms.validators import ValidationError
+from warehouse.admin.squats import Squat
from warehouse.classifiers.models import Classifier
from warehouse.forklift import legacy
from warehouse.packaging.interfaces import IFileStorage
@@ -2779,6 +2780,50 @@ def test_upload_succeeds_creates_project(self, pyramid_config, db_request):
),
]
+ def test_upload_succeeds_creates_squats(self, pyramid_config, db_request):
+ pyramid_config.testing_securitypolicy(userid=1)
+
+ squattee = ProjectFactory(name="example")
+ user = UserFactory.create()
+ EmailFactory.create(user=user)
+
+ filename = "{}-{}.tar.gz".format("exmaple", "1.0")
+
+ db_request.user = user
+ db_request.POST = MultiDict(
+ {
+ "metadata_version": "1.2",
+ "name": "exmaple",
+ "version": "1.0",
+ "filetype": "sdist",
+ "md5_digest": "335c476dc930b959dda9ec82bd65ef19",
+ "content": pretend.stub(
+ filename=filename,
+ file=io.BytesIO(b"A fake file."),
+ type="application/tar",
+ ),
+ }
+ )
+
+ storage_service = pretend.stub(store=lambda path, filepath, meta: None)
+ db_request.find_service = lambda svc, name=None: storage_service
+ db_request.remote_addr = "10.10.10.10"
+ db_request.user_agent = "warehouse-tests/6.6.6"
+
+ resp = legacy.file_upload(db_request)
+
+ assert resp.status_code == 200
+
+ # Ensure that a Project object has been created.
+ squatter = db_request.db.query(Project).filter(Project.name == "exmaple").one()
+
+ # Ensure that a Squat object has been created.
+ squat = db_request.db.query(Squat).one()
+
+ assert squat.squattee == squattee
+ assert squat.squatter == squatter
+ assert squat.reviewed is False
+
@pytest.mark.parametrize(
("emails_verified", "expected_success"),
[
diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py
index 1d748f50f681..c2dfd5d9a286 100644
--- a/warehouse/admin/routes.py
+++ b/warehouse/admin/routes.py
@@ -116,3 +116,7 @@ def includeme(config):
# Flags
config.add_route("admin.flags", "/admin/flags/", domain=warehouse)
config.add_route("admin.flags.edit", "/admin/flags/edit/", domain=warehouse)
+
+ # Squats
+ config.add_route("admin.squats", "/admin/squats/", domain=warehouse)
+ config.add_route("admin.squats.review", "/admin/squats/review/", domain=warehouse)
diff --git a/warehouse/admin/squats.py b/warehouse/admin/squats.py
new file mode 100644
index 000000000000..6df274a5d32f
--- /dev/null
+++ b/warehouse/admin/squats.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.
+
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, Text
+from sqlalchemy import orm, sql
+
+from warehouse import db
+
+
+class Squat(db.ModelBase):
+
+ __tablename__ = "warehouse_admin_squat"
+
+ id = Column(Integer, primary_key=True, nullable=False)
+ created = Column(
+ DateTime(timezone=False), nullable=False, server_default=sql.func.now()
+ )
+ squatter_name = Column(
+ Text, ForeignKey("packages.name", onupdate="CASCADE", ondelete="CASCADE")
+ )
+ squattee_name = Column(
+ Text, ForeignKey("packages.name", onupdate="CASCADE", ondelete="CASCADE")
+ )
+ squatter = orm.relationship("Project", foreign_keys=[squatter_name], lazy=False)
+ squattee = orm.relationship("Project", foreign_keys=[squattee_name], lazy=False)
+ reviewed = Column(Boolean, nullable=False, server_default=sql.false())
diff --git a/warehouse/admin/templates/admin/base.html b/warehouse/admin/templates/admin/base.html
index 12171f49303f..410d70fe1e60 100644
--- a/warehouse/admin/templates/admin/base.html
+++ b/warehouse/admin/templates/admin/base.html
@@ -120,6 +120,11 @@
Flags
+
+
+ Squats
+
+
diff --git a/warehouse/admin/templates/admin/projects/detail.html b/warehouse/admin/templates/admin/projects/detail.html
index 79ad83cb048a..cb765714b6aa 100644
--- a/warehouse/admin/templates/admin/projects/detail.html
+++ b/warehouse/admin/templates/admin/projects/detail.html
@@ -46,6 +46,7 @@
{{ project.name }}
+
Attributes:
@@ -64,6 +65,7 @@ {{ project.name }}
+
Maintainers:
@@ -135,6 +137,7 @@ Remove role for {{ role.user_name
+
Releases:
Release version |
@@ -152,6 +155,7 @@ Remove role for {{ role.user_name
All releases
+
Journals:
@@ -175,6 +179,49 @@ Remove role for {{ role.user_name
All journals
+ {% if squatters %}
+
Projects that might be squatting on this project:
+
+ {% endif %}
+
+ {% if squattees %}
+
Projects that this project might be squatting on:
+
+ {% endif %}
diff --git a/warehouse/admin/templates/admin/squats/index.html b/warehouse/admin/templates/admin/squats/index.html
new file mode 100644
index 000000000000..da76b6afed80
--- /dev/null
+++ b/warehouse/admin/templates/admin/squats/index.html
@@ -0,0 +1,80 @@
+{#
+ # 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 "admin/base.html" %}
+
+{% import "admin/utils/pagination.html" as pagination %}
+
+{% block title %}Squats{% endblock %}
+
+{% block breadcrumb %}
+ Squats
+{% endblock %}
+
+{% set csrf_token = request.session.get_csrf_token() %}
+
+{% block content %}
+
+{% endblock content %}
diff --git a/warehouse/admin/views/projects.py b/warehouse/admin/views/projects.py
index 1b566d388c4b..9bbd3c482aa0 100644
--- a/warehouse/admin/views/projects.py
+++ b/warehouse/admin/views/projects.py
@@ -15,7 +15,7 @@
from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage
from pyramid.httpexceptions import HTTPBadRequest, HTTPMovedPermanently, HTTPSeeOther
from pyramid.view import view_config
-from sqlalchemy import or_
+from sqlalchemy import or_, func
from sqlalchemy.orm.exc import NoResultFound
from warehouse.accounts.models import User
@@ -107,11 +107,27 @@ def project_detail(project, request):
)
]
+ squattees = (
+ request.db.query(Project)
+ .filter(Project.created < project.created)
+ .filter(func.levenshtein(Project.normalized_name, project.normalized_name) <= 2)
+ .all()
+ )
+
+ squatters = (
+ request.db.query(Project)
+ .filter(Project.created > project.created)
+ .filter(func.levenshtein(Project.normalized_name, project.normalized_name) <= 2)
+ .all()
+ )
+
return {
"project": project,
"releases": releases,
"maintainers": maintainers,
"journal": journal,
+ "squatters": squatters,
+ "squattees": squattees,
"ONE_MB": ONE_MB,
"MAX_FILESIZE": MAX_FILESIZE,
}
diff --git a/warehouse/admin/views/squats.py b/warehouse/admin/views/squats.py
new file mode 100644
index 000000000000..62b9c2d0258b
--- /dev/null
+++ b/warehouse/admin/views/squats.py
@@ -0,0 +1,50 @@
+# 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.
+
+from pyramid.httpexceptions import HTTPSeeOther
+from pyramid.view import view_config
+
+from warehouse.admin.squats import Squat
+
+
+@view_config(
+ route_name="admin.squats",
+ renderer="admin/squats/index.html",
+ permission="admin",
+ uses_session=True,
+)
+def get_squats(request):
+ return {
+ "squats": (
+ request.db.query(Squat)
+ .filter(Squat.reviewed.is_(False))
+ .order_by(Squat.created.desc())
+ .all()
+ )
+ }
+
+
+@view_config(
+ route_name="admin.squats.review",
+ permission="admin",
+ request_method="POST",
+ uses_session=True,
+ require_methods=False,
+ require_csrf=True,
+)
+def review_squat(request):
+ squat = request.db.query(Squat).get(request.POST["id"])
+ squat.reviewed = True
+
+ request.session.flash("Squat marked as reviewed")
+
+ return HTTPSeeOther(request.route_path("admin.squats"))
diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py
index 2411b62c1b06..ace40eb6d268 100644
--- a/warehouse/forklift/legacy.py
+++ b/warehouse/forklift/legacy.py
@@ -39,6 +39,7 @@
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
from warehouse import forms
+from warehouse.admin.squats import Squat
from warehouse.classifiers.models import Classifier
from warehouse.packaging.interfaces import IFileStorage
from warehouse.packaging.models import (
@@ -863,10 +864,29 @@ def file_upload(request):
),
) from None
- # The project doesn't exist in our database, so we'll add it along with
- # a role setting the current user as the "Owner" of the project.
+ # The project doesn't exist in our database, so first we'll check for
+ # projects with a similar name
+ squattees = (
+ request.db.query(Project)
+ .filter(
+ func.levenshtein(
+ Project.normalized_name, func.normalize_pep426_name(form.name.data)
+ )
+ <= 2
+ )
+ .all()
+ )
+
+ # Next we'll create the project
project = Project(name=form.name.data)
request.db.add(project)
+
+ # Now that the project exists, add any squats which it is the squatter for
+ for squattee in squattees:
+ request.db.add(Squat(squatter=project, squattee=squattee))
+
+ # Then we'll add a role setting the current user as the "Owner" of the
+ # project.
request.db.add(Role(user=request.user, project=project, role_name="Owner"))
# TODO: This should be handled by some sort of database trigger or a
# SQLAlchemy hook or the like instead of doing it inline in this
diff --git a/warehouse/migrations/versions/56e9e630c748_add_fuzzystrmatch.py b/warehouse/migrations/versions/56e9e630c748_add_fuzzystrmatch.py
new file mode 100644
index 000000000000..5382ff9f6fc7
--- /dev/null
+++ b/warehouse/migrations/versions/56e9e630c748_add_fuzzystrmatch.py
@@ -0,0 +1,32 @@
+# 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.
+"""
+Add fuzzystrmatch
+
+Revision ID: 56e9e630c748
+Revises: e82c3a017d60
+Create Date: 2018-08-28 19:00:47.606523
+"""
+
+from alembic import op
+
+
+revision = "56e9e630c748"
+down_revision = "e82c3a017d60"
+
+
+def upgrade():
+ op.execute("CREATE EXTENSION IF NOT EXISTS fuzzystrmatch")
+
+
+def downgrade():
+ pass
diff --git a/warehouse/migrations/versions/eeb23d9b4d00_add_squats_table.py b/warehouse/migrations/versions/eeb23d9b4d00_add_squats_table.py
new file mode 100644
index 000000000000..0870b4240b35
--- /dev/null
+++ b/warehouse/migrations/versions/eeb23d9b4d00_add_squats_table.py
@@ -0,0 +1,51 @@
+# 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.
+"""
+add squats table
+
+Revision ID: eeb23d9b4d00
+Revises: 56e9e630c748
+Create Date: 2018-11-03 06:05:42.158355
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "eeb23d9b4d00"
+down_revision = "56e9e630c748"
+
+
+def upgrade():
+ op.create_table(
+ "warehouse_admin_squat",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column(
+ "created", sa.DateTime(), server_default=sa.text("now()"), nullable=False
+ ),
+ sa.Column("squatter_name", sa.Text(), nullable=True),
+ sa.Column("squattee_name", sa.Text(), nullable=True),
+ sa.Column(
+ "reviewed", sa.Boolean(), server_default=sa.text("false"), nullable=False
+ ),
+ sa.ForeignKeyConstraint(
+ ["squattee_name"], ["packages.name"], onupdate="CASCADE", ondelete="CASCADE"
+ ),
+ sa.ForeignKeyConstraint(
+ ["squatter_name"], ["packages.name"], onupdate="CASCADE", ondelete="CASCADE"
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+
+def downgrade():
+ op.drop_table("warehouse_admin_squat")