diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py
index b7c2c0a3a463..119200e5db32 100644
--- a/tests/unit/packaging/test_views.py
+++ b/tests/unit/packaging/test_views.py
@@ -12,7 +12,10 @@
import pretend
-from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound
+from pyramid.httpexceptions import (
+ HTTPMovedPermanently, HTTPNotFound, HTTPSeeOther, HTTPForbidden,
+ HTTPFound
+)
from warehouse.packaging import views
@@ -129,7 +132,7 @@ def test_detail_renders(self, db_request):
"release": releases[1],
"files": [files[1]],
"all_releases": [
- (r.version, r.created) for r in reversed(releases)
+ (r.version, r.created, None) for r in reversed(releases)
],
"maintainers": sorted(users, key=lambda u: u.username.lower()),
"license": None
@@ -185,3 +188,84 @@ def test_multiple_licenses_from_classifiers(self, db_request):
result = views.release_detail(release, db_request)
assert result["license"] == "BSD License, MIT License"
+
+ def test_release_insecure(self, db_request):
+ release = ReleaseFactory.create(deprecated_reason="insecure")
+
+ result = views.release_detail(release, db_request)
+
+ assert result["release"].deprecated_reason == "insecure"
+
+ def test_release_eol(self, db_request):
+ release = ReleaseFactory.create(deprecated_reason="eol")
+
+ result = views.release_detail(release, db_request)
+
+ assert result["release"].deprecated_reason == "eol"
+
+
+class TestDeprecate:
+
+ def test_user_not_authenticated(self, db_request):
+ project = ProjectFactory.create()
+ # stub the route_url call ¯\_(ツ)_/¯
+ # https://github.com/Pylons/pyramid/issues/1202
+ db_request.route_url = pretend.call_recorder(
+ lambda *args, **kw: "/accounts/login/"
+ )
+
+ resp = views.deprecate(project, db_request)
+
+ assert isinstance(resp, HTTPSeeOther)
+ assert resp.headers["Location"] == "/accounts/login/"
+
+ def test_user_is_not_maintainer(self, db_request):
+ project = ProjectFactory.create()
+ user = UserFactory.create()
+ db_request.set_property(
+ lambda r: str(user.id),
+ name="authenticated_userid",
+ )
+
+ resp = views.deprecate(project, db_request)
+
+ assert isinstance(resp, HTTPForbidden)
+
+ def test_authenticated_get(self, db_request):
+ project = ProjectFactory.create()
+ user = UserFactory.create()
+ project.users.append(user)
+ db_request.set_property(
+ lambda r: str(user.id),
+ name="authenticated_userid",
+ )
+
+ resp = views.deprecate(project, db_request)
+
+ assert "deprecated_releases" in resp
+ assert "form" in resp
+ assert "project" in resp
+ assert resp["deprecated_releases"] == []
+
+ def test_valid_authenticated_post(self, db_request):
+ project = ProjectFactory.create()
+ user = UserFactory.create()
+ project.users.append(user)
+ db_request.set_property(
+ lambda r: str(user.id),
+ name="authenticated_userid",
+ )
+ ReleaseFactory.create(project=project, version="0.1")
+ db_request.POST = {"release": "0.1", "reason": "insecure"}
+ db_request.method = "POST"
+ # stub the route_url call ¯\_(ツ)_/¯
+ # https://github.com/Pylons/pyramid/issues/1202
+ db_request.route_url = pretend.call_recorder(
+ lambda *args, **kw: "/project/name/deprecate/"
+ )
+
+ resp = views.deprecate(project, db_request)
+
+ assert isinstance(resp, HTTPFound)
+ assert resp.headers["Location"] == "/project/name/deprecate/"
+ assert project.releases[0].deprecated_reason == "insecure"
diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py
index 5108671344eb..f25e0482e00e 100644
--- a/tests/unit/test_routes.py
+++ b/tests/unit/test_routes.py
@@ -104,6 +104,13 @@ def add_xmlrpc_endpoint(endpoint, pattern, header, domain=None):
traverse="/{name}",
domain=warehouse,
),
+ pretend.call(
+ "packaging.deprecate",
+ "/project/{name}/deprecate/",
+ factory="warehouse.packaging.models:ProjectFactory",
+ traverse="/{name}",
+ domain=warehouse,
+ ),
pretend.call(
"packaging.release",
"/project/{name}/{version}/",
diff --git a/warehouse/migrations/versions/63caa2edd396_create_deprecated_fields.py b/warehouse/migrations/versions/63caa2edd396_create_deprecated_fields.py
new file mode 100644
index 000000000000..e656ad067b59
--- /dev/null
+++ b/warehouse/migrations/versions/63caa2edd396_create_deprecated_fields.py
@@ -0,0 +1,53 @@
+# 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.
+"""
+create deprecated fields
+
+Revision ID: 63caa2edd396
+Revises: 3d2b8a42219a
+Create Date: 2016-09-22 10:38:27.188455
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects.postgresql import ENUM
+
+
+revision = '63caa2edd396'
+down_revision = '3d2b8a42219a'
+
+
+def upgrade():
+ enum = ENUM("eol", "insecure", name="deprecated_type", create_type=False)
+ enum.create(op.get_bind(), checkfirst=False)
+ op.add_column(
+ 'releases',
+ sa.Column('deprecated_at', sa.DateTime(), nullable=True)
+ )
+ op.add_column(
+ 'releases',
+ sa.Column(
+ 'deprecated_reason',
+ sa.Enum('eol', 'insecure', name='deprecated_type'),
+ nullable=True
+ )
+ )
+ op.add_column(
+ 'releases',
+ sa.Column('deprecated_url', sa.Text(), nullable=True)
+ )
+
+
+def downgrade():
+ op.drop_column('releases', 'deprecated_url')
+ op.drop_column('releases', 'deprecated_reason')
+ op.drop_column('releases', 'deprecated_at')
diff --git a/warehouse/packaging/forms.py b/warehouse/packaging/forms.py
new file mode 100644
index 000000000000..db6b401f0ee4
--- /dev/null
+++ b/warehouse/packaging/forms.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.
+
+import wtforms
+import wtforms.fields.html5
+
+from warehouse import forms
+from warehouse.packaging.models import Release
+
+
+class DeprecationForm(forms.Form):
+
+ reason = wtforms.fields.SelectField(
+ choices=Release.DEPRECATED_REASONS
+ )
+ release = wtforms.fields.SelectField()
+
+ url = wtforms.fields.html5.URLField(
+ validators=[
+ wtforms.validators.Optional(),
+ wtforms.validators.URL(),
+ ],
+ )
diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py
index 21d5f53ba430..85dbaae79f69 100644
--- a/warehouse/packaging/models.py
+++ b/warehouse/packaging/models.py
@@ -324,6 +324,20 @@ def __table_args__(cls): # noqa
viewonly=True,
)
+ deprecated_at = Column(
+ DateTime(timezone=False),
+ nullable=True,
+ )
+ DEPRECATED_REASONS = (
+ ("eol", "End of Life"),
+ ("insecure", "Insecure"),
+ )
+ deprecated_reason = Column(
+ Enum(*[r for r, _ in DEPRECATED_REASONS], name="deprecated_type"),
+ nullable=True,
+ )
+ deprecated_url = Column(Text, nullable=True)
+
@property
def urls(self):
_urls = OrderedDict()
diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py
index 9e63cc3a4030..b215eabf5357 100644
--- a/warehouse/packaging/views.py
+++ b/warehouse/packaging/views.py
@@ -10,13 +10,19 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound
+from datetime import datetime
+from pyramid.httpexceptions import (
+ HTTPFound, HTTPMovedPermanently, HTTPNotFound, HTTPSeeOther, HTTPForbidden
+)
from pyramid.view import view_config
from sqlalchemy.orm.exc import NoResultFound
from warehouse.accounts.models import User
+from warehouse.accounts import REDIRECT_FIELD_NAME
+
from warehouse.cache.origin import origin_cache
from warehouse.packaging.models import Release, Role
+from warehouse.packaging.forms import DeprecationForm
@view_config(
@@ -74,7 +80,8 @@ def release_detail(release, request):
all_releases = (
request.db.query(Release)
.filter(Release.project == project)
- .with_entities(Release.version, Release.created)
+ .with_entities(Release.version, Release.created,
+ Release.deprecated_reason)
.order_by(Release._pypi_ordering.desc())
.all()
)
@@ -111,3 +118,61 @@ def release_detail(release, request):
"maintainers": maintainers,
"license": license,
}
+
+
+@view_config(
+ route_name="packaging.deprecate",
+ renderer="packaging/deprecate.html",
+ uses_session=True,
+ require_csrf=True,
+ require_methods=False,
+)
+def deprecate(project, request, _form_class=DeprecationForm):
+
+ # if the user is not logged in, return a redirect to the login page with
+ # the REDIRECT_URL pointing as parameter that points back to this view.
+ if request.authenticated_userid is None:
+ url = request.route_url(
+ "accounts.login",
+ _query={REDIRECT_FIELD_NAME: request.path_qs},
+ )
+ return HTTPSeeOther(url)
+
+ # check that the currently logged in user belongs to the project. If this
+ # isn't the case, return early with a 403
+ project_userids = [str(p.id) for p in project.users]
+ if request.authenticated_userid not in project_userids:
+ return HTTPForbidden()
+
+ deprecated_releases = [
+ r for r in project.releases if r.deprecated_at is not None
+ ]
+ # instantiate and populate the form data with all available releases
+ # that have not yet been deprecated
+ form = _form_class(data=request.POST)
+ form.release.choices = [
+ (r.version, r.version) for r in project.releases
+ if r not in deprecated_releases
+ ]
+
+ if request.method == "POST" and form.validate():
+ # update the release
+ release = next(
+ filter(lambda r: r.version == form.release.data, project.releases)
+ )
+ release.deprecated_at = datetime.now()
+ release.deprecated_reason = form.reason.data
+ release.deprecated_url = form.url.data
+
+ # redirect to back to this view. This saves us some code because we
+ # don't have to re-populate the form and the context for the updated
+ # release
+ return HTTPFound(
+ request.route_url("packaging.deprecate", name=project.name)
+ )
+
+ return {
+ "project": project,
+ "deprecated_releases": deprecated_releases,
+ "form": form
+ }
diff --git a/warehouse/routes.py b/warehouse/routes.py
index 056b7afbf563..be0f9dfdc6ab 100644
--- a/warehouse/routes.py
+++ b/warehouse/routes.py
@@ -77,6 +77,13 @@ def includeme(config):
traverse="/{name}",
domain=warehouse,
)
+ config.add_route(
+ "packaging.deprecate",
+ "/project/{name}/deprecate/",
+ factory="warehouse.packaging.models:ProjectFactory",
+ traverse="/{name}",
+ domain=warehouse
+ )
config.add_route(
"packaging.release",
"/project/{name}/{version}/",
diff --git a/warehouse/static/sass/blocks/_badge.scss b/warehouse/static/sass/blocks/_badge.scss
index 804cba6dea99..3e4d3e4e9cab 100644
--- a/warehouse/static/sass/blocks/_badge.scss
+++ b/warehouse/static/sass/blocks/_badge.scss
@@ -12,4 +12,11 @@
background-color: $highlight-color;
padding: 2px 7px;
border-radius: 3px;
+
+ &--bad {
+ background-color: $danger-color;
+ border: 1px solid darken($danger-color, 7);
+ color: $white;
+ }
+
}
diff --git a/warehouse/static/sass/blocks/_horizontal-section.scss b/warehouse/static/sass/blocks/_horizontal-section.scss
index d857614b91bf..b4e1a9a1e470 100644
--- a/warehouse/static/sass/blocks/_horizontal-section.scss
+++ b/warehouse/static/sass/blocks/_horizontal-section.scss
@@ -30,6 +30,21 @@
border-top: 1px solid darken($base-grey, 10);
}
+ &--bad {
+ background-color: $danger-color;
+ color: $white;
+ }
+
+ &--highlight {
+ color: darken($highlight-color, 50);
+ background-color: $highlight-color;
+ }
+
+ &--bad a{
+ color: $white;
+ text-decoration: underline;
+ }
+
&--medium {
padding: 40px 0;
}
diff --git a/warehouse/templates/packaging/deprecate.html b/warehouse/templates/packaging/deprecate.html
new file mode 100644
index 000000000000..35f9721cdb78
--- /dev/null
+++ b/warehouse/templates/packaging/deprecate.html
@@ -0,0 +1,112 @@
+{#
+ # 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 "base.html" %}
+
+
+{% block title %}Deprecate{% endblock %}
+
+{% block content %}
+
+ Todo: Short explanation what it means to deprecate a release. Why it's useful, and so on..
+ Deprecated releases
+
+
+
+ {% endif %}
+
+
+
+
+
+ {% for release in deprecated_releases %}
+ Release
+ Deprecated at
+ Reason
+ URL
+
+
+ {% endfor %}
+
+ {{ release.version }}
+ {{ release.deprecated_at }}
+ {{ release.deprecated_reason }}
+ {{ release.deprecated_url }}
+ Deprecate a release
+