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 %} +
+
+ + {% if deprecated_releases %} +

Deprecated releases

+ + + + + + + + + + + {% for release in deprecated_releases %} + + + + + + + {% endfor %} + +
ReleaseDeprecated atReasonURL
{{ release.version }}{{ release.deprecated_at }}{{ release.deprecated_reason }}{{ release.deprecated_url }}
+ {% endif %} + +

Deprecate a release

+

+ Todo: Short explanation what it means to deprecate a release. Why it's useful, and so on.. +

+
+ + + +
+ {% if form.errors.__all__ %} +
    + {% for error in form.errors.__all__ %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+
+ +
+ {% if form.reason.errors %} +
    + {% for error in form.reason.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + {{ form.reason() }} +

Todo: Help text describing what EOL and insecure mean.

+
+ +
+
+ +
+ {% if form.release.errors %} +
    + {% for error in form.release.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + {{ form.release() }} +
+ +
+
+ +
+ {% if form.url.errors %} +
    + {% for error in form.url.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + {{ form.url() }} +

Todo: Help text what to fill in here

+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/warehouse/templates/packaging/detail.html b/warehouse/templates/packaging/detail.html index c9d886679ab4..4d7f7887e031 100644 --- a/warehouse/templates/packaging/detail.html +++ b/warehouse/templates/packaging/detail.html @@ -85,6 +85,26 @@

{{ release.project.name }} {{ release.version } +{% if release.deprecated_reason == "insecure" %} +
+
+ This release is insecure and should'nt be used. + {% if release.deprecated_url %} + Read more about the vulnerabilities. + {% endif %} +
+
+{% elif release.deprecated_reason == "eol" %} +
+
+ This release is deprecated, please consider upgrading. + {% if release.deprecated_url %} + Read more here. + {% endif %} +
+
+{% endif %} +
{% if release.version != all_releases[0].version %} @@ -249,7 +269,11 @@

Release History

{% else %}

{{ hrelease.version }}

{% endif %} - + {% if hrelease.deprecated_reason == "insecure" %} + Insecure + {% elif hrelease.deprecated_reason == "eol" %} + EOL + {% endif %}