Skip to content
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

WIP: New deprecation feature #1462

Closed
wants to merge 1 commit 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
88 changes: 86 additions & 2 deletions tests/unit/packaging/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
7 changes: 7 additions & 0 deletions tests/unit/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}/",
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
32 changes: 32 additions & 0 deletions warehouse/packaging/forms.py
Original file line number Diff line number Diff line change
@@ -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(),
],
)
14 changes: 14 additions & 0 deletions warehouse/packaging/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
69 changes: 67 additions & 2 deletions warehouse/packaging/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
)
Expand Down Expand Up @@ -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
}
7 changes: 7 additions & 0 deletions warehouse/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}/",
Expand Down
7 changes: 7 additions & 0 deletions warehouse/static/sass/blocks/_badge.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
15 changes: 15 additions & 0 deletions warehouse/static/sass/blocks/_horizontal-section.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading