diff --git a/.gitignore b/.gitignore index 853305af63ff..8a4be15f614a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .coverage .state .idea +.envrc docs/_build/ build/ diff --git a/Makefile b/Makefile index 77887918e1ec..727d340361c1 100644 --- a/Makefile +++ b/Makefile @@ -147,6 +147,7 @@ initdb: 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 + docker-compose run web python -m warehouse sponsors populate-db reindex: docker-compose run --rm web python -m warehouse search reindex diff --git a/docs/application.rst b/docs/application.rst index 53f492118c55..46934dc731c6 100644 --- a/docs/application.rst +++ b/docs/application.rst @@ -110,6 +110,7 @@ Directories within the repository: - `rss/ `_ - RSS feeds: :doc:`api-reference/feeds` - `search/ `_ - utilities for building and querying the search index - `sitemap/ `_ - site maps + - `sponsors/ `_ - sponsors management - `templates/ `_ - Jinja templates for web pages, emails, etc. - `utils/ `_ - various utilities Warehouse uses diff --git a/requirements/main.in b/requirements/main.in index c18c1ba4af98..73d4335327ef 100644 --- a/requirements/main.in +++ b/requirements/main.in @@ -50,6 +50,7 @@ sentry-sdk setuptools sqlalchemy>=0.9,<1.4.0 # https://github.com/pypa/warehouse/pull/9228 sqlalchemy-citext +sqlalchemy-utils stdlib-list structlog transaction diff --git a/requirements/main.txt b/requirements/main.txt index 9ed80c567bf4..62c5ce346db1 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -875,11 +875,16 @@ six==1.15.0 \ # pyopenssl # python-dateutil # readme-renderer + # sqlalchemy-utils # tenacity # webauthn sqlalchemy-citext==1.8.0 \ --hash=sha256:a1740e693a9a334e7c8f60ae731083fe75ce6c1605bb9ca6644a6f1f63b15b77 - # via -r requirements/main.in + # via -r main.in +sqlalchemy-utils==0.37.2 \ + --hash=sha256:042e08454ee7b822b1e2f2b7c20f76fe7b8255de10354718a11e68ced1a64643 \ + --hash=sha256:c60b8b43c9ef809d147b0bc571cfbfe0992f854ec242bc01ab7a562f76113743 + # via -r main.in sqlalchemy==1.3.23 \ --hash=sha256:040bdfc1d76a9074717a3f43455685f781c581f94472b010cd6c4754754e1862 \ --hash=sha256:1fe5d8d39118c2b018c215c37b73fd6893c3e1d4895be745ca8ff6eb83333ed3 \ @@ -924,6 +929,7 @@ sqlalchemy==1.3.23 \ # alembic # paginate-sqlalchemy # sqlalchemy-citext + # sqlalchemy-utils # zope.sqlalchemy stdlib-list==0.8.0 \ --hash=sha256:2ae0712a55b68f3fbbc9e58d6fa1b646a062188f49745b495f94d3310a9fdd3e \ diff --git a/tests/common/db/accounts.py b/tests/common/db/accounts.py index 5f91160cbae3..357b0ad7152d 100644 --- a/tests/common/db/accounts.py +++ b/tests/common/db/accounts.py @@ -30,6 +30,7 @@ class Meta: is_active = True is_superuser = False is_moderator = False + is_psf_staff = False date_joined = factory.fuzzy.FuzzyNaiveDateTime( datetime.datetime(2005, 1, 1), datetime.datetime(2010, 1, 1) ) diff --git a/tests/common/db/base.py b/tests/common/db/base.py index 507d8a945c40..479184bd9e5b 100644 --- a/tests/common/db/base.py +++ b/tests/common/db/base.py @@ -46,3 +46,26 @@ def fuzz(self): chars = string.ascii_letters + string.digits username = "".join(random.choice(chars) for i in range(12)) return "@".join([username, self.domain]) + + +class FuzzyList(fuzzy.BaseFuzzyAttribute): + def __init__(self, item_factory, item_kwargs=None, size=1, *args, **kwargs): + super().__init__(*args, **kwargs) + self.item_factory = item_factory + self.item_kwargs = item_kwargs or {} + self.size = size + + def fuzz(self): + return [self.item_factory(**self.item_kwargs).fuzz() for i in range(self.size)] + + +class FuzzyUrl(fuzzy.BaseFuzzyAttribute): + def __init__(self, domain="example.com", is_secure=False, *args, **kwargs): + super().__init__(*args, **kwargs) + self.domain = domain + self.protocol = "https" if is_secure else "http" + + def fuzz(self): + chars = string.ascii_letters + path = "".join(random.choice(chars) for i in range(12)) + return f"{self.protocol}://{self.domain}/{path}" diff --git a/tests/common/db/sponsors.py b/tests/common/db/sponsors.py new file mode 100644 index 000000000000..9cdcd545e759 --- /dev/null +++ b/tests/common/db/sponsors.py @@ -0,0 +1,37 @@ +# 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 factory import fuzzy + +from warehouse.sponsors.models import Sponsor + +from .base import FuzzyUrl, WarehouseFactory + + +class SponsorFactory(WarehouseFactory): + class Meta: + model = Sponsor + + name = fuzzy.FuzzyText(length=12) + service = fuzzy.FuzzyText(length=12) + activity_markdown = fuzzy.FuzzyText(length=12) + + link_url = FuzzyUrl() + color_logo_url = FuzzyUrl() + white_logo_url = FuzzyUrl() + + is_active = True + footer = True + psf_sponsor = True + infra_sponsor = False + one_time = False + sidebar = True diff --git a/tests/unit/accounts/test_core.py b/tests/unit/accounts/test_core.py index 3d8f6fec219d..0f732a9c856d 100644 --- a/tests/unit/accounts/test_core.py +++ b/tests/unit/accounts/test_core.py @@ -227,16 +227,61 @@ def test_via_basic_auth_compromised( class TestAuthenticate: @pytest.mark.parametrize( - ("is_superuser", "is_moderator", "expected"), + ("is_superuser", "is_moderator", "is_psf_staff", "expected"), [ - (False, False, []), - (True, False, ["group:admins", "group:moderators"]), - (False, True, ["group:moderators"]), - (True, True, ["group:admins", "group:moderators"]), + (False, False, False, []), + ( + True, + False, + False, + [ + "group:admins", + "group:moderators", + "group:psf_staff", + "group:with_admin_dashboard_access", + ], + ), + ( + False, + True, + False, + ["group:moderators", "group:with_admin_dashboard_access"], + ), + ( + True, + True, + False, + [ + "group:admins", + "group:moderators", + "group:psf_staff", + "group:with_admin_dashboard_access", + ], + ), + ( + False, + False, + True, + ["group:psf_staff", "group:with_admin_dashboard_access"], + ), + ( + False, + True, + True, + [ + "group:moderators", + "group:psf_staff", + "group:with_admin_dashboard_access", + ], + ), ], ) - def test_with_user(self, is_superuser, is_moderator, expected): - user = pretend.stub(is_superuser=is_superuser, is_moderator=is_moderator) + def test_with_user(self, is_superuser, is_moderator, is_psf_staff, expected): + user = pretend.stub( + is_superuser=is_superuser, + is_moderator=is_moderator, + is_psf_staff=is_psf_staff, + ) service = pretend.stub(get_user=pretend.call_recorder(lambda userid: user)) request = pretend.stub(find_service=lambda iface, context: service) diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py index d064370a751c..e785a7914ec8 100644 --- a/tests/unit/admin/test_routes.py +++ b/tests/unit/admin/test_routes.py @@ -160,4 +160,24 @@ def test_includeme(): "/admin/verdicts/{verdict_id}/review", domain=warehouse, ), + pretend.call( + "admin.sponsor.list", + "/admin/sponsors/", + domain=warehouse, + ), + pretend.call( + "admin.sponsor.create", + "/admin/sponsors/create/", + domain=warehouse, + ), + pretend.call( + "admin.sponsor.delete", + "/admin/sponsors/{sponsor_id}/delete/", + domain=warehouse, + ), + pretend.call( + "admin.sponsor.edit", + "/admin/sponsors/{sponsor_id}/", + domain=warehouse, + ), ] diff --git a/tests/unit/admin/views/test_sponsors.py b/tests/unit/admin/views/test_sponsors.py new file mode 100644 index 000000000000..3197321c2f9f --- /dev/null +++ b/tests/unit/admin/views/test_sponsors.py @@ -0,0 +1,233 @@ +# 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 uuid + +from unittest import TestCase + +import pretend +import pytest + +from pyramid.httpexceptions import HTTPNotFound +from sqlalchemy.orm.exc import NoResultFound +from webob.multidict import MultiDict + +from warehouse.admin.views import sponsors as views +from warehouse.sponsors.models import Sponsor + +from ....common.db.sponsors import SponsorFactory + + +class TestSponsorList: + def test_list_all_sponsors(self, db_request): + [SponsorFactory.create() for _ in range(5)] + sponsors = db_request.db.query(Sponsor).order_by(Sponsor.name).all() + + result = views.sponsor_list(db_request) + + assert result == {"sponsors": sponsors} + + +class TestCreateSponsor: + def test_serialize_form_to_create_sponsor(self, db_request): + result = views.create_sponsor(db_request) + + assert len(result) == 1 + assert isinstance(result["form"], views.SponsorForm) + + def test_serialize_form_errors_if_invalid_post(self, db_request): + db_request.method = "POST" + db_request.POST["name"] = "" + db_request.POST["link_url"] = "" + db_request.POST = MultiDict(db_request.POST) + + result = views.create_sponsor(db_request) + + assert len(result) == 1 + assert isinstance(result["form"], views.SponsorForm) + assert result["form"].errors + + def test_create_sponsor(self, db_request): + db_request.method = "POST" + db_request.POST["name"] = "Sponsor" + db_request.POST["link_url"] = "https://newsponsor.com" + db_request.POST["color_logo_url"] = "https://newsponsor.com/logo.jpg" + db_request.POST = MultiDict(db_request.POST) + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.route_url = pretend.call_recorder(lambda r: "/admin/sponsors/") + + resp = views.create_sponsor(db_request) + + assert resp.status_code == 303 + assert resp.location == "/admin/sponsors/" + assert db_request.session.flash.calls == [ + pretend.call("Added new sponsor 'Sponsor'", queue="success") + ] + assert db_request.route_url.calls == [pretend.call("admin.sponsor.list")] + + +class TestEditSponsor: + def test_serialize_form_and_sponsor(self, db_request): + sponsor = SponsorFactory.create() + db_request.matchdict["sponsor_id"] = sponsor.id + + result = views.edit_sponsor(db_request) + + assert len(result) == 2 + assert isinstance(result["form"], views.SponsorForm) + assert result["form"].data["name"] == sponsor.name + assert result["sponsor"] == sponsor + + def test_404_if_sponsor_does_not_exist(self, db_request): + db_request.matchdict["sponsor_id"] = str(uuid.uuid4()) + + with pytest.raises(HTTPNotFound): + views.edit_sponsor(db_request) + + def test_update_sponsor(self, db_request): + sponsor = SponsorFactory.create() + form = views.SponsorForm(MultiDict({}), sponsor) + data = form.data.copy() + data["name"] = "New Name" + db_request.matchdict["sponsor_id"] = sponsor.id + db_request.method = "POST" + db_request.POST = MultiDict(data) + db_request.current_route_path = pretend.call_recorder( + lambda: f"/admin/sponsors/{sponsor.id}/" + ) + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + + resp = views.edit_sponsor(db_request) + db_sponsor = db_request.db.query(Sponsor).filter(Sponsor.id == sponsor.id).one() + + assert resp.status_code == 303 + assert resp.location == f"/admin/sponsors/{sponsor.id}/" + assert db_sponsor.name == "New Name" + assert db_request.session.flash.calls == [ + pretend.call("Sponsor updated", queue="success") + ] + + def test_form_errors_if_invalid_post_data(self, db_request): + sponsor = SponsorFactory.create() + form = views.SponsorForm(MultiDict({}), sponsor) + data = form.data.copy() + data["name"] = "" # name is required + db_request.matchdict["sponsor_id"] = sponsor.id + db_request.method = "POST" + db_request.POST = MultiDict(data) + + result = views.edit_sponsor(db_request) + + assert "name" in result["form"].errors + + +class TestDeleteSponsor: + def test_404_if_sponsor_does_not_exist(self, db_request): + db_request.matchdict["sponsor_id"] = str(uuid.uuid4()) + + with pytest.raises(HTTPNotFound): + views.delete_sponsor(db_request) + + def test_delete_sponsor(self, db_request): + sponsor = SponsorFactory.create() + db_request.matchdict["sponsor_id"] = sponsor.id + db_request.params = {"sponsor": sponsor.name} + db_request.method = "POST" + db_request.route_url = pretend.call_recorder(lambda s: "/admin/sponsors/") + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + + resp = views.delete_sponsor(db_request) + with pytest.raises(NoResultFound): + db_request.db.query(Sponsor).filter(Sponsor.id == sponsor.id).one() + + assert resp.status_code == 303 + assert resp.location == "/admin/sponsors/" + assert db_request.session.flash.calls == [ + pretend.call(f"Deleted sponsor {sponsor.name}", queue="success") + ] + assert db_request.route_url.calls == [pretend.call("admin.sponsor.list")] + + def test_do_not_delete_sponsor_if_invalid_confirmation_param(self, db_request): + sponsor = SponsorFactory.create() + db_request.matchdict["sponsor_id"] = sponsor.id + db_request.params = {"sponsor": "not the sponsor name"} + db_request.method = "POST" + db_request.route_url = pretend.call_recorder( + lambda s, sponsor_id: f"/admin/sponsors/{sponsor_id}" + ) + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + + resp = views.delete_sponsor(db_request) + sponsor = db_request.db.query(Sponsor).filter(Sponsor.id == sponsor.id).one() + + assert resp.status_code == 303 + assert resp.location == f"/admin/sponsors/{sponsor.id}" + assert db_request.session.flash.calls == [ + pretend.call("Wrong confirmation input", queue="error") + ] + assert db_request.route_url.calls == [ + pretend.call("admin.sponsor.edit", sponsor_id=sponsor.id) + ] + + +class TestSponsorForm(TestCase): + def setUp(self): + self.data = { + "name": "Sponsor", + "link_url": "https://newsponsor.com", + "color_logo_url": "http://domain.com/image.jpg", + } + + def test_required_fields(self): + required_fields = ["name", "link_url", "color_logo_url"] + + form = views.SponsorForm(data={"color_logo_url": ""}) + + assert form.validate() is False + assert len(form.errors) == len(required_fields) + for field in required_fields: + assert field in form.errors + + def test_valid_data(self): + form = views.SponsorForm(data=self.data) + assert form.validate() is True + + def test_white_logo_is_required_for_footer_display(self): + self.data["footer"] = True + + # don't validate without logo + form = views.SponsorForm(data=self.data) + assert form.validate() is False + assert "white_logo_url" in form.errors + + self.data["white_logo_url"] = "http://domain.com/white-logo.jpg" + form = views.SponsorForm(data=self.data) + assert form.validate() is True + + def test_white_logo_is_required_for_infra_display(self): + self.data["infra_sponsor"] = True + + # don't validate without logo + form = views.SponsorForm(data=self.data) + assert form.validate() is False + assert "white_logo_url" in form.errors + + self.data["white_logo_url"] = "http://domain.com/white-logo.jpg" + form = views.SponsorForm(data=self.data) + assert form.validate() is True diff --git a/tests/unit/cli/test_sponsors.py b/tests/unit/cli/test_sponsors.py new file mode 100644 index 000000000000..b189f2255ebc --- /dev/null +++ b/tests/unit/cli/test_sponsors.py @@ -0,0 +1,98 @@ +# 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 pretend + +from warehouse import db +from warehouse.cli import sponsors +from warehouse.sponsors.models import Sponsor + + +def raise_(ex): + """ + Used by lambda functions to raise exception + """ + raise ex + + +def test_populate_sponsors_from_sponsors_dict(db_request, monkeypatch, cli): + engine = pretend.stub() + config = pretend.stub(registry={"sqlalchemy.engine": engine}) + session_cls = pretend.call_recorder(lambda bind: db_request.db) + monkeypatch.setattr(db, "Session", session_cls) + + assert 0 == db_request.db.query(Sponsor).count() + cli.invoke(sponsors.populate_db, obj=config) + assert len(sponsors.SPONSORS_DICTS) == db_request.db.query(Sponsor).count() + assert session_cls.calls == [pretend.call(bind=engine)] + + # assert sponsors have the correct data + for sponsor_dict in sponsors.SPONSORS_DICTS: + db_sponsor = ( + db_request.db.query(Sponsor) + .filter(Sponsor.name == sponsor_dict["name"]) + .one() + ) + assert db_sponsor.is_active + assert sponsor_dict["url"] == db_sponsor.link_url + assert sponsor_dict.get("service") == db_sponsor.service + assert sponsor_dict["footer"] == db_sponsor.footer + assert sponsor_dict["psf_sponsor"] == db_sponsor.psf_sponsor + assert sponsor_dict["infra_sponsor"] == db_sponsor.infra_sponsor + assert sponsor_dict["one_time"] == db_sponsor.one_time + assert sponsor_dict["sidebar"] == db_sponsor.sidebar + assert ( + sponsors.BLACK_BASE_URL + sponsor_dict["image"] == db_sponsor.color_logo_url + ) + # infra or footer sponsors must have white logo url + if db_sponsor.footer or db_sponsor.infra_sponsor: + assert ( + sponsors.WHITE_BASE_URL + sponsor_dict["image"] + == db_sponsor.white_logo_url + ) + else: + assert db_sponsor.white_logo_url is None + + +def test_do_not_duplicate_existing_sponsors(db_request, monkeypatch, cli): + engine = pretend.stub() + config = pretend.stub(registry={"sqlalchemy.engine": engine}) + session_cls = pretend.call_recorder(lambda bind: db_request.db) + monkeypatch.setattr(db, "Session", session_cls) + + # command line called several times + cli.invoke(sponsors.populate_db, obj=config) + cli.invoke(sponsors.populate_db, obj=config) + cli.invoke(sponsors.populate_db, obj=config) + + # still with the same amount of sponsors in the DB + assert len(sponsors.SPONSORS_DICTS) == db_request.db.query(Sponsor).count() + + +def test_capture_exception_if_error_and_rollback(db_request, monkeypatch, cli): + engine = pretend.stub() + config = pretend.stub(registry={"sqlalchemy.engine": engine}) + session = pretend.stub() + session.add = pretend.call_recorder(lambda obj: raise_(Exception("SQL exception"))) + session.rollback = pretend.call_recorder(lambda: True) + session.commit = pretend.call_recorder(lambda: True) + session.query = db_request.db.query + session_cls = pretend.call_recorder(lambda bind: session) + monkeypatch.setattr(db, "Session", session_cls) + + cli.invoke(sponsors.populate_db, obj=config) + + # no new data at db and no exception being raised + assert 0 == db_request.db.query(Sponsor).count() + assert len(session.add.calls) == len(sponsors.SPONSORS_DICTS) + assert len(session.rollback.calls) == len(sponsors.SPONSORS_DICTS) + assert len(session.commit.calls) == 0 diff --git a/tests/unit/sponsors/__init__.py b/tests/unit/sponsors/__init__.py new file mode 100644 index 000000000000..164f68b09175 --- /dev/null +++ b/tests/unit/sponsors/__init__.py @@ -0,0 +1,11 @@ +# 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. diff --git a/tests/unit/sponsors/test_init.py b/tests/unit/sponsors/test_init.py new file mode 100644 index 000000000000..993b1d69578a --- /dev/null +++ b/tests/unit/sponsors/test_init.py @@ -0,0 +1,43 @@ +# 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 pretend + +from sqlalchemy import true + +from warehouse import sponsors +from warehouse.sponsors.models import Sponsor + +from ...common.db.sponsors import SponsorFactory + + +def test_includeme(): + config = pretend.stub( + add_request_method=pretend.call_recorder(lambda f, name, reify: None) + ) + + sponsors.includeme(config) + + assert config.add_request_method.calls == [ + pretend.call(sponsors._sponsors, name="sponsors", reify=True), + ] + + +def test_list_sponsors(db_request): + [SponsorFactory.create() for i in range(5)] + [SponsorFactory.create(is_active=False) for i in range(3)] + + result = sponsors._sponsors(db_request) + expected = db_request.db.query(Sponsor).filter(Sponsor.is_active == true()).all() + + assert result == expected + assert len(result) == 5 diff --git a/tests/unit/sponsors/test_models.py b/tests/unit/sponsors/test_models.py new file mode 100644 index 000000000000..abd700c34351 --- /dev/null +++ b/tests/unit/sponsors/test_models.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. + +from ...common.db.sponsors import SponsorFactory + + +def test_sponsor_color_logo_img_tag(db_request): + sponsor = SponsorFactory.create() + expected = f'{ sponsor.name }' + assert sponsor.color_logo_img == expected + + +def test_sponsor_white_logo_img_tag(db_request): + sponsor = SponsorFactory.create() + expected = f'{ sponsor.name }' + assert sponsor.white_logo_img == expected + + # should return empty string if no white logo + sponsor.white_logo_url = None + assert sponsor.white_logo_img == "" + + +def test_activity_property_render_markdown_content(db_request): + sponsor = SponsorFactory.create() + sponsor.activity_markdown = "Paragraph1\n\nParagraph2" + expected = "

Paragraph1

\n

Paragraph2

" + assert sponsor.activity.strip() == expected.strip() + # empty string if no data + sponsor.activity_markdown = None + assert sponsor.activity == "" + + +# sanitization is implemented internally in readme library +# ref: https://github.com/pypa/readme_renderer/blob/main/readme_renderer/clean.py +# this test is just so we can be more secure about it +def test_ensure_activity_markdown_is_safe_against_xss(db_request): + sponsor = SponsorFactory.create() + sponsor.activity_markdown = r"[XSS](javascript://www.google.com%0Aprompt(1))" + expected = "

XSS

" + assert sponsor.activity.strip() == expected.strip() + # empty string if no data + sponsor.activity_markdown = None + assert sponsor.activity == "" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index a758d34bdd35..655b69931f41 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -16,6 +16,7 @@ import pytest from pyramid import renderers +from pyramid.security import Allow, Authenticated from pyramid.tweens import EXCVIEW from warehouse import config @@ -328,6 +329,7 @@ def __init__(self): pretend.call(".packaging"), pretend.call(".redirects"), pretend.call(".routes"), + pretend.call(".sponsors"), pretend.call(".admin"), pretend.call(".forklift"), pretend.call(".sentry"), @@ -412,3 +414,18 @@ def __init__(self): ] assert xmlrpc_renderer_cls.calls == [pretend.call(allow_none=True)] + + +def test_root_factory_access_control_list(): + acl = config.RootFactory.__acl__ + + assert len(acl) == 5 + assert acl[0] == (Allow, "group:admins", "admin") + assert acl[1] == (Allow, "group:moderators", "moderator") + assert acl[2] == (Allow, "group:psf_staff", "psf_staff") + assert acl[3] == ( + Allow, + "group:with_admin_dashboard_access", + "admin_dashboard_access", + ) + assert acl[4] == (Allow, Authenticated, "manage:user") diff --git a/warehouse/accounts/__init__.py b/warehouse/accounts/__init__.py index 1757c6ce7894..e1ed5f7513a3 100644 --- a/warehouse/accounts/__init__.py +++ b/warehouse/accounts/__init__.py @@ -106,6 +106,12 @@ def _authenticate(userid, request): principals.append("group:admins") if user.is_moderator or user.is_superuser: principals.append("group:moderators") + if user.is_psf_staff or user.is_superuser: + principals.append("group:psf_staff") + + # user must have base admin access if any admin permission + if principals: + principals.append("group:with_admin_dashboard_access") return principals diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index d1487d12946e..abd3708dbf49 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -75,6 +75,7 @@ class User(SitemapMixin, db.Model): is_active = Column(Boolean, nullable=False, server_default=sql.false()) is_superuser = Column(Boolean, nullable=False, server_default=sql.false()) is_moderator = Column(Boolean, nullable=False, server_default=sql.false()) + is_psf_staff = Column(Boolean, nullable=False, server_default=sql.false()) date_joined = Column(DateTime, server_default=sql.func.now()) last_login = Column(DateTime, nullable=False, server_default=sql.func.now()) disabled_for = Column( diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py index a8776c0d03ae..b21947c9780a 100644 --- a/warehouse/admin/routes.py +++ b/warehouse/admin/routes.py @@ -163,3 +163,15 @@ def includeme(config): config.add_route( "admin.verdicts.review", "/admin/verdicts/{verdict_id}/review", domain=warehouse ) + + # Sponsor related Admin pages + config.add_route("admin.sponsor.list", "/admin/sponsors/", domain=warehouse) + config.add_route( + "admin.sponsor.create", "/admin/sponsors/create/", domain=warehouse + ) + config.add_route( + "admin.sponsor.delete", "/admin/sponsors/{sponsor_id}/delete/", domain=warehouse + ) + config.add_route( + "admin.sponsor.edit", "/admin/sponsors/{sponsor_id}/", domain=warehouse + ) diff --git a/warehouse/admin/templates/admin/base.html b/warehouse/admin/templates/admin/base.html index 7598713c632c..a56274ad4f78 100644 --- a/warehouse/admin/templates/admin/base.html +++ b/warehouse/admin/templates/admin/base.html @@ -85,6 +85,7 @@ diff --git a/warehouse/admin/templates/admin/sponsors/edit.html b/warehouse/admin/templates/admin/sponsors/edit.html new file mode 100644 index 000000000000..773c4cc37749 --- /dev/null +++ b/warehouse/admin/templates/admin/sponsors/edit.html @@ -0,0 +1,134 @@ +{# + # 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" %} + +{% macro render_field(label, field, input_id, placeholder=None, class=None) %} +
+ + +
+ {{ field(id=input_id, class=class, placeholder=placeholder)}} + + {% if field.errors %} + + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} +
+
+{% endmacro %} + +{% block title %}{% if sponsor %}{{ sponsor.name }}{% else %}Create sponsor{% endif %}{% endblock %} + +{% block breadcrumb %} +
  • Sponsors
  • + {% if sponsor %} +
  • {{ sponsor.name }}
  • + {% else %} +
  • Create sponsor
  • + {% endif %} +{% endblock %} + +{% block content %} +
    +
    +
    +
    + {% if sponsor %} +

    Edit Sponsor

    + + + + {% else %} +

    Create sponsor

    + {% endif %} +
    +
    +
    + + + {% if form.errors.__all__ %} + {% for error in form.errors.__all__ %} +
    {{ error }}
    + {% endfor %} + {% endif %} + + {{ render_field("Name", form.name, "sponsor-name", class="form-control", placeholder="Name") }} + {% if sponsor %} + {{ render_field("Is Active?", form.is_active, "sponsor-is-active") }} + {% endif %} + {{ render_field("Service", form.service, "sponsor-service", class="form-control", placeholder="Service") }} + {{ render_field("Landing URL", form.link_url, "sponsor-link-url", class="form-control", placeholder="Landing URL") }} + {{ render_field("Color logo", form.color_logo_url, "sponsor-color-logo-url", class="form-control", placeholder="Color logo") }} + {{ render_field("White logo", form.white_logo_url, "sponsor-white-logo-url", class="form-control", placeholder="White logo") }} + {{ render_field("Activity (as Markdown text)", form.activity_markdown, "sponsor-activity-markdown", placeholder="Activity text as markdown format") }} + + {{ render_field("Footer", form.footer, "sponsor-footer") }} + {{ render_field("PSF Sponsor", form.psf_sponsor, "sponsor-psf-sponsor") }} + {{ render_field("Infra Sponsor", form.infra_sponsor, "sponsor-infra-sponsor") }} + {{ render_field("One Time", form.one_time, "sponsor-one-time") }} + {{ render_field("Sidebar", form.sidebar, "sponsor-sidebar") }} + +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +{% endblock %} diff --git a/warehouse/admin/templates/admin/sponsors/list.html b/warehouse/admin/templates/admin/sponsors/list.html new file mode 100644 index 000000000000..db2942e6c1a8 --- /dev/null +++ b/warehouse/admin/templates/admin/sponsors/list.html @@ -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. +-#} +{% extends "admin/base.html" %} + +{% block title %}Sponsors{% endblock %} + +{% block breadcrumb %} +
  • Sponsors
  • +{% endblock %} + +{% block content %} + +
    + {% if request.has_permission('psf_staff') %} + + {% endif %} +
    + + + + + + + + {% for sponsor in sponsors %} + + + + + + {% endfor %} +
    Active?NameVisibility
    + {{ sponsor.name }} + {{ sponsor.visibility }}
    + +
    +
    +{% endblock content %} diff --git a/warehouse/admin/templates/admin/users/detail.html b/warehouse/admin/templates/admin/users/detail.html index 59ed8552dff7..65cb984d436c 100644 --- a/warehouse/admin/templates/admin/users/detail.html +++ b/warehouse/admin/templates/admin/users/detail.html @@ -178,6 +178,7 @@

    Permissions

    {{ render_field("Active", form.is_active, "is-active") }} {{ render_field("Superuser", form.is_superuser, "is-superuser")}} {{ render_field("Moderator", form.is_moderator, "is-moderator")}} + {{ render_field("PSF Staff", form.is_psf_staff, "is-psf-staff")}} diff --git a/warehouse/admin/templates/admin/users/list.html b/warehouse/admin/templates/admin/users/list.html index 2349004a7f82..fcc83659660a 100644 --- a/warehouse/admin/templates/admin/users/list.html +++ b/warehouse/admin/templates/admin/users/list.html @@ -45,6 +45,7 @@ Email Admin Moderator + PSF Staff Active 2FA @@ -59,6 +60,7 @@ {{ user.email }} {% if user.is_superuser %}{% endif %} {% if user.is_moderator %}{% endif %} + {% if user.is_psf_staff %}{% endif %} {% if user.is_active %}{% endif %} {% if user.has_two_factor %}{% endif %} diff --git a/warehouse/admin/views/core.py b/warehouse/admin/views/core.py index a24768cd397c..a26e41f52377 100644 --- a/warehouse/admin/views/core.py +++ b/warehouse/admin/views/core.py @@ -23,7 +23,7 @@ def forbidden(exc, request): @view_config( route_name="admin.dashboard", renderer="admin/dashboard.html", - permission="moderator", + permission="admin_dashboard_access", uses_session=True, ) def dashboard(request): diff --git a/warehouse/admin/views/sponsors.py b/warehouse/admin/views/sponsors.py new file mode 100644 index 000000000000..3cb053afa5ef --- /dev/null +++ b/warehouse/admin/views/sponsors.py @@ -0,0 +1,191 @@ +# 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 + +from pyramid.httpexceptions import HTTPNotFound, HTTPSeeOther +from pyramid.view import view_config +from sqlalchemy.orm.exc import NoResultFound + +from warehouse.forms import Form, URIValidator +from warehouse.sponsors.models import Sponsor + + +class SponsorForm(Form): + name = wtforms.fields.StringField( + validators=[ + wtforms.validators.Length(max=100), + wtforms.validators.DataRequired(), + ], + ) + service = wtforms.fields.StringField( + validators=[wtforms.validators.Length(max=256), wtforms.validators.Optional()] + ) + + link_url = wtforms.fields.StringField( + validators=[ + wtforms.validators.DataRequired(), + URIValidator(), + ] + ) + color_logo_url = wtforms.fields.StringField( + validators=[ + URIValidator(), + ] + ) + white_logo_url = wtforms.fields.StringField( + validators=[ + wtforms.validators.Optional(), + URIValidator(), + ] + ) + + activity_markdown = wtforms.fields.TextAreaField(render_kw={"rows": 10, "cols": 60}) + + is_active = wtforms.fields.BooleanField(default=False) + footer = wtforms.fields.BooleanField() + psf_sponsor = wtforms.fields.BooleanField() + infra_sponsor = wtforms.fields.BooleanField() + one_time = wtforms.fields.BooleanField() + sidebar = wtforms.fields.BooleanField() + + def validate(self, *args, **kwargs): + if not super().validate(*args, **kwargs): + return False + + require_white_logo = self.footer.data or self.infra_sponsor.data + if require_white_logo and not self.white_logo_url.data: + self.white_logo_url.errors.append( + "Must have white logo if is a footer sponsor." + ) + return False + + return True + + +@view_config( + route_name="admin.sponsor.list", + renderer="admin/sponsors/list.html", + permission="admin_dashboard_access", + request_method="GET", + uses_session=True, +) +def sponsor_list(request): + sponsors = ( + request.db.query(Sponsor).order_by(Sponsor.is_active.desc(), Sponsor.name).all() + ) + for sponsor in sponsors: + visibility = [ + "PSF Sponsor" if sponsor.psf_sponsor else None, + "Infra Sponsor" if sponsor.infra_sponsor else None, + "One time" if sponsor.one_time else None, + "Footer" if sponsor.footer else None, + "Sidebar" if sponsor.sidebar else None, + ] + sponsor.visibility = " | ".join([v for v in visibility if v]) + return {"sponsors": sponsors} + + +@view_config( + route_name="admin.sponsor.edit", + renderer="admin/sponsors/edit.html", + permission="admin_dashboard_access", + request_method="GET", + uses_session=True, + require_csrf=True, + require_methods=False, +) +@view_config( + route_name="admin.sponsor.edit", + renderer="admin/sponsors/edit.html", + permission="psf_staff", + request_method="POST", + uses_session=True, + require_csrf=True, + require_methods=False, +) +def edit_sponsor(request): + id_ = request.matchdict["sponsor_id"] + try: + sponsor = request.db.query(Sponsor).filter(Sponsor.id == id_).one() + except NoResultFound: + raise HTTPNotFound + + form = SponsorForm(request.POST if request.method == "POST" else None, sponsor) + + if request.method == "POST" and form.validate(): + form.populate_obj(sponsor) + request.session.flash("Sponsor updated", queue="success") + return HTTPSeeOther(location=request.current_route_path()) + + return {"sponsor": sponsor, "form": form} + + +@view_config( + route_name="admin.sponsor.create", + renderer="admin/sponsors/edit.html", + permission="admin_dashboard_access", + request_method="GET", + uses_session=True, + require_csrf=True, + require_methods=False, +) +@view_config( + route_name="admin.sponsor.create", + renderer="admin/sponsors/edit.html", + permission="psf_staff", + request_method="POST", + uses_session=True, + require_csrf=True, + require_methods=False, +) +def create_sponsor(request): + form = SponsorForm(request.POST if request.method == "POST" else None) + + if request.method == "POST" and form.validate(): + sponsor = Sponsor(**form.data) + request.db.add(sponsor) + request.session.flash( + f"Added new sponsor '{sponsor.name}'", + queue="success", + ) + redirect_url = request.route_url("admin.sponsor.list") + return HTTPSeeOther(location=redirect_url) + + return {"form": form} + + +@view_config( + route_name="admin.sponsor.delete", + require_methods=["POST"], + permission="psf_staff", + uses_session=True, + require_csrf=True, +) +def delete_sponsor(request): + id_ = request.matchdict["sponsor_id"] + try: + sponsor = request.db.query(Sponsor).filter(Sponsor.id == id_).one() + except NoResultFound: + raise HTTPNotFound + + # Safeguard check on sponsor name + if sponsor.name != request.params.get("sponsor"): + request.session.flash("Wrong confirmation input", queue="error") + return HTTPSeeOther( + request.route_url("admin.sponsor.edit", sponsor_id=sponsor.id) + ) + + # Delete the sponsor + request.db.delete(sponsor) + request.session.flash(f"Deleted sponsor {sponsor.name}", queue="success") + return HTTPSeeOther(request.route_url("admin.sponsor.list")) diff --git a/warehouse/admin/views/users.py b/warehouse/admin/views/users.py index 020274bd72fb..4ae6d61dfd3a 100644 --- a/warehouse/admin/views/users.py +++ b/warehouse/admin/views/users.py @@ -89,6 +89,7 @@ class UserForm(forms.Form): is_active = wtforms.fields.BooleanField() is_superuser = wtforms.fields.BooleanField() is_moderator = wtforms.fields.BooleanField() + is_psf_staff = wtforms.fields.BooleanField() emails = wtforms.fields.FieldList(wtforms.fields.FormField(EmailForm)) diff --git a/warehouse/cli/sponsors.py b/warehouse/cli/sponsors.py new file mode 100644 index 000000000000..c89943ac3658 --- /dev/null +++ b/warehouse/cli/sponsors.py @@ -0,0 +1,585 @@ +# 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 click + +from warehouse.cli import warehouse +from warehouse.sponsors.models import Sponsor + +# SPONSORS is a copy from the sponsors variable defined in the include +# templates/includes/sponsors.html +SPONSORS_DICTS = [ + dict( + name="Google", + service="Object Storage and Download Analytics", + url="https://careers.google.com/", + image="google.png", + activity=["Google is a visionary sponsor of the Python Software Foundation."], + footer=True, + psf_sponsor=True, + infra_sponsor=True, + one_time=False, + sidebar=True, + ), + dict( + name="Bloomberg", + url="https://www.techatbloomberg.com/", + image="bloomberg.png", + activity=[ + "Bloomberg is a visionary sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=True, + ), + dict( + name="Microsoft", + url="https://azure.microsoft.com/en-us/develop/python/", + image="microsoft.png", + activity=[ + "Microsoft is a visionary sponsor of the Python Software Foundation." + ], + footer=True, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=True, + ), + dict( + name="Facebook / Instagram", + url="https://research.fb.com/", + image="facebook.png", + activity=[ + "Facebook / Instagram is a sustainability sponsor of the Python Software Foundation." # noqa + ], + footer=True, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=True, + ), + dict( + name="Salesforce", + url="https://opensource.salesforce.com/", + image="salesforce.png", + activity=[ + "Salesforce is a sustainability sponsor of the Python Software Foundation." + ], + footer=True, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=True, + ), + dict( + name="Capital One", + url="https://www.capitalone.com/tech/", + image="capitalone.png", + activity=[ + "Capital One is a maintaining sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=True, + ), + dict( + name="Slack", + url="https://slack.com", + image="slack.png", + activity=["Slack is a maintaining sponsor of the Python Software Foundation."], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=True, + ), + dict( + name="Corning", + url="https://www.corning.com/", + image="corning.png", + activity=[ + "Corning is a maintaining sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=True, + ), + dict( + name="Latacora", + url="https://www.latacora.com", + image="latacora.png", + activity=[ + "Latacora is a contributing sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=True, + ), + dict( + name="Cockroach Labs", + url="http://www.cockroachlabs.com/", + image="cockroach.png", + activity=[ + "Cockroach Labs is a contributing sponsor of the Python Software Foundation." # noqa + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=True, + ), + dict( + name="Red Hat", + url="https://www.redhat.com/en", + image="redhat.png", + activity=[ + "Red Hat is a contributing sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=True, + ), + dict( + name="JetBrains", + url="https://www.jetbrains.com/pycharm", + image="jetbrains.png", + activity=[ + "JetBrains is a supporting sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="LinkedIn", + url="https://www.linkedin.com", + image="linkedin.png", + activity=[ + "LinkedIn is a supporting sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="OpenEDG Python Institute", + url="https://pythoninstitute.org/", + image="openEDG.png", + activity=[ + "OpenEDG Python Institute is a supporting sponsor of the Python Software Foundation." # noqa + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="CircleCI", + url="http://www.circleci.com", + image="circleci.png", + activity=[ + "CircleCI is a supporting sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Blackfire.io", + url="https://www.blackfire.io/python", + image="blackfire.png", + activity=[ + "Blackfire.io is a supporting sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Mattermost", + url="http://mattermost.com", + image="mattermost.png", + activity=["Mattermost is a partner sponsor of the Python Software Foundation."], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Tidelift", + url="https://tidelift.com/", + image="tidelift.png", + activity=["Tidelift is a partner sponsor of the Python Software Foundation."], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Caktus Group", + url="https://www.caktusgroup.com/?utm_source=psf&utm_medium=sponsor&utm_campaign=caktus", # noqa + image="caktus.png", + activity=[ + "Caktus Group is a partner sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Reuven Lerner — Python training", + url="https://lerner.co.il", + image="reuven.png", + activity=[ + "Reuven Lerner is a partner sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Saleor", + url="http://saleor.io/", + image="saleor.png", + activity=["Saleor is a partner sponsor of the Python Software Foundation."], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Cuttlesoft", + url="https://cuttlesoft.com", + image="cuttlesoft.png", + activity=["Cuttlesoft is a partner sponsor of the Python Software Foundation."], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="SonarSource", + url="https://www.sonarsource.com/", + image="sonarsource.png", + activity=[ + "SonarSource is a partner sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Tara AI", + url="http://tara.ai/?utm_source=PyCon&utm_medium=Sponsorship&utm_campaign=PyCon%202021", # noqa + image="tara.png", + activity=["Tara AI is a partner sponsor of the Python Software Foundation."], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Elasticsearch, Inc.", + url="https://www.elastic.co/", + image="elastic.png", + activity=[ + "Elasticsearch, Inc. is a partner sponsor of the Python Software Foundation." # noqa + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Netflix", + url="https://about.netflix.com/", + image="netflix.png", + activity=["Netflix is a partner sponsor of the Python Software Foundation."], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Scout APM", + url="https://ter.li/jjv5k0", + image="scout.png", + activity=["Scout APM is a partner sponsor of the Python Software Foundation."], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Real Python", + url="https://realpython.com/", + image="realpython.png", + activity=[ + "Real Python is a participating sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Adimian.be SPRL", + url="https://www.adimian.com/", + image="adimian.png", + activity=[ + "Adimian.be SPRL is a participating sponsor of the Python Software Foundation." # noqa + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Python Academy", + url="https://www.python-academy.com/", + image="pythonacademy.png", + activity=[ + "Python Academy is a participating sponsor of the Python Software Foundation." # noqa + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Adafruit", + url="https://www.adafruit.com/circuitpython", + image="adafruit.png", + activity=[ + "Adafruit is a participating sponsor of the Python Software Foundation." + ], + footer=False, + psf_sponsor=True, + infra_sponsor=False, + one_time=False, + sidebar=False, + ), + dict( + name="Pingdom", + service="Monitoring", + url="https://www.pingdom.com/", + image="pingdom.png", + activity=[ + "PyPI's infrastructure volunteers use Pingdom to monitor and receive alerts about downtime and other issues affecting end-users." # noqa + ], + footer=False, + psf_sponsor=False, + infra_sponsor=True, + one_time=False, + sidebar=False, + ), + dict( + name="Sentry", + service="Error logging", + url="https://getsentry.com/for/python", + image="sentry.png", + activity=[ + "The PyPI team uses Sentry to capture, record, and respond to exceptions and errors on PyPI." # noqa + ], + footer=False, + psf_sponsor=False, + infra_sponsor=True, + one_time=False, + sidebar=False, + ), + dict( + name="AWS", + service="Cloud computing", + url="https://aws.amazon.com/", + image="aws.png", + activity=[ + "PyPI uses AWS infrastructure to host the machines that power our services, serve DNS, host our databases, and send and monitor email.", # noqa + ], + footer=False, + psf_sponsor=False, + infra_sponsor=True, + one_time=False, + sidebar=False, + ), + dict( + name="Datadog", + service="Monitoring", + url="https://www.datadoghq.com/", + image="datadog.png", + activity=[ + "PyPI uses Datadog to collect metrics from the applications, services, and infrastructure behind the scenes allowing for the team to measure the impact of new changes, monitor for problems, and alert when systems fail." # noqa + ], + footer=False, + psf_sponsor=False, + infra_sponsor=True, + one_time=False, + sidebar=False, + ), + dict( + name="Fastly", + service="CDN", + url="https://www.fastly.com/", + image="fastly.png", + activity=[ + "PyPI uses Fastly's CDN to quickly serve content to end-users, allowing us to minimize our hosting infrastructure and obscure possible downtime." # noqa + ], + footer=False, + psf_sponsor=False, + infra_sponsor=True, + one_time=False, + sidebar=False, + ), + dict( + name="DigiCert", + service="EV certificate", + url="https://www.digicert.com/", + image="digicert.png", + activity=[ + "PyPI uses Digicert to secure communication and prove identity with an EV Certificate." # noqa + ], + footer=False, + psf_sponsor=False, + infra_sponsor=True, + one_time=False, + sidebar=False, + ), + dict( + name="StatusPage", + service="Status page", + url="https://statuspage.io", + image="statuspage.png", + activity=[ + "The PyPI team uses StatusPage to communicate downtime, service degradation, or maintenance windows to end-users." # noqa + ], + footer=False, + psf_sponsor=False, + infra_sponsor=True, + one_time=False, + sidebar=False, + ), + dict( + name="Mozilla", + url="https://www.mozilla.org", + image="mozilla.png", + activity=[ + "In late 2017 the Python Software Foundation was awarded a Mozilla Open Source Support (MOSS) award.", # noqa + "This award was used to move PyPI from its legacy codebase and deploy Warehouse - the new codebase powering PyPI.", # noqa + ], + footer=False, + psf_sponsor=False, + infra_sponsor=False, + one_time=True, + sidebar=False, + ), + dict( + name="Open Technology Fund", + url="https://www.opentech.fund", + image="otf.png", + activity=[ + "In 2019 the Python Software Foundation was awarded a contract through the OTF Core Infrastructure Fund.", # noqa + "This contract included an audit and improvements to the accessibility of PyPI, internationalization and translations for PyPI user interface, and security features including two-factor authentication and API tokens for uploads.", # noqa + ], + footer=False, + psf_sponsor=False, + infra_sponsor=False, + one_time=True, + sidebar=False, + ), +] + + +WHITE_BASE_URL = "https://pypi.org/static/images/sponsors/white/" +BLACK_BASE_URL = "https://pypi.org/static/images/sponsors/color/" + + +@warehouse.group() # pragma: no branch +def sponsors(): + """ + Manage operations on top of sponsors. + """ + + +@sponsors.command() +@click.pass_obj +def populate_db(config): + """ + Sync the Warehouse database with initial sponsors list. + Once this command is executed once, you shouldn't need to run + it again. + """ + # Imported here because we don't want to trigger an import from anything + # but warehouse.cli at the module scope. + from warehouse.db import Session + + session = Session(bind=config.registry["sqlalchemy.engine"]) + + for data in SPONSORS_DICTS: + name = data["name"] + sponsor = session.query(Sponsor).filter_by(name=name).one_or_none() + if sponsor: + print(f"Skipping {name} sponsor because it already exists.") + continue + + params = data.copy() + img = params.pop("image") + params["is_active"] = True + params["link_url"] = params.pop("url") + params["activity_markdown"] = "\n\n".join(params.pop("activity", [])).strip() + params["color_logo_url"] = BLACK_BASE_URL + img + if params["footer"] or params["infra_sponsor"]: + params["white_logo_url"] = WHITE_BASE_URL + img + + sponsor = Sponsor(**params) + try: + session.add(sponsor) + session.commit() + print(f"{name} sponsor created with success.") + except Exception as e: + session.rollback() + print(f"Error while creating {name} sponsor:") + print(f"\t{e}") diff --git a/warehouse/config.py b/warehouse/config.py index 595f86a08977..00cf712d4135 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -59,6 +59,8 @@ class RootFactory: __acl__ = [ (Allow, "group:admins", "admin"), (Allow, "group:moderators", "moderator"), + (Allow, "group:psf_staff", "psf_staff"), + (Allow, "group:with_admin_dashboard_access", "admin_dashboard_access"), (Allow, Authenticated, "manage:user"), ] @@ -437,6 +439,9 @@ def configure(settings=None): # Register all our URL routes for Warehouse. config.include(".routes") + # Allow the sponsors app to list sponsors + config.include(".sponsors") + # Include our admin application config.include(".admin") diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index c739967f54dc..fddb4201b69e 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -371,11 +371,11 @@ msgstr "" #: warehouse/templates/pages/help.html:862 #: warehouse/templates/pages/help.html:867 #: warehouse/templates/pages/security.html:36 -#: warehouse/templates/pages/sponsors.html:35 -#: warehouse/templates/pages/sponsors.html:39 -#: warehouse/templates/pages/sponsors.html:43 -#: warehouse/templates/pages/sponsors.html:47 -#: warehouse/templates/pages/sponsors.html:66 +#: warehouse/templates/pages/sponsors.html:33 +#: warehouse/templates/pages/sponsors.html:37 +#: warehouse/templates/pages/sponsors.html:41 +#: warehouse/templates/pages/sponsors.html:45 +#: warehouse/templates/pages/sponsors.html:64 #: warehouse/templates/upload.html:26 msgid "External link" msgstr "" @@ -5195,31 +5195,31 @@ msgstr "" msgid "Security policy" msgstr "" -#: warehouse/templates/pages/sponsors.html:25 +#: warehouse/templates/pages/sponsors.html:23 msgid "Support PyPI and related projects" msgstr "" -#: warehouse/templates/pages/sponsors.html:26 +#: warehouse/templates/pages/sponsors.html:24 msgid "Sponsor the Python Software Foundation" msgstr "" -#: warehouse/templates/pages/sponsors.html:29 +#: warehouse/templates/pages/sponsors.html:27 msgid "" "The Python Software Foundation raises and distributes funds to improve " "Python's packaging ecosystem." msgstr "" -#: warehouse/templates/pages/sponsors.html:31 +#: warehouse/templates/pages/sponsors.html:29 msgid "Recent projects funded include:" msgstr "" -#: warehouse/templates/pages/sponsors.html:34 +#: warehouse/templates/pages/sponsors.html:32 msgid "" "The successful relaunch of the Python Package Index, powered by the new " "'Warehouse' codebase" msgstr "" -#: warehouse/templates/pages/sponsors.html:35 +#: warehouse/templates/pages/sponsors.html:33 #, python-format msgid "" "With Mozilla Open Source Support Program in 2018" msgstr "" -#: warehouse/templates/pages/sponsors.html:38 +#: warehouse/templates/pages/sponsors.html:36 msgid "" "Improving PyPI's security and accessibility, and adding support for " "multiple locales" msgstr "" -#: warehouse/templates/pages/sponsors.html:39 +#: warehouse/templates/pages/sponsors.html:37 #, python-format msgid "" "With $80,000 in funding from the in 2019" msgstr "" -#: warehouse/templates/pages/sponsors.html:42 +#: warehouse/templates/pages/sponsors.html:40 msgid "Additional security-focused features for PyPI" msgstr "" -#: warehouse/templates/pages/sponsors.html:43 +#: warehouse/templates/pages/sponsors.html:41 #, python-format msgid "" "With in 2019 and 2020" msgstr "" -#: warehouse/templates/pages/sponsors.html:46 +#: warehouse/templates/pages/sponsors.html:44 msgid "Overhauling pip's user experience and dependency resolver" msgstr "" -#: warehouse/templates/pages/sponsors.html:47 +#: warehouse/templates/pages/sponsors.html:45 #, python-format msgid "" "With Mozilla Open Source Support Program in 2020" msgstr "" -#: warehouse/templates/pages/sponsors.html:51 +#: warehouse/templates/pages/sponsors.html:49 msgid "" "With your support, the PSF can continue to fund packaging improvements, " "benefiting millions of Python users around the world." msgstr "" -#: warehouse/templates/pages/sponsors.html:59 +#: warehouse/templates/pages/sponsors.html:57 msgid "PSF Sponsorship" msgstr "" -#: warehouse/templates/pages/sponsors.html:62 +#: warehouse/templates/pages/sponsors.html:60 msgid "" "All of these initiatives help maintain and support the tools that the " "Python community uses daily. This work can only be done with the generous" " financial support that you or your organization provides." msgstr "" -#: warehouse/templates/pages/sponsors.html:63 +#: warehouse/templates/pages/sponsors.html:61 msgid "Your contributions matter and they make an impact. Every donation counts!" msgstr "" -#: warehouse/templates/pages/sponsors.html:67 +#: warehouse/templates/pages/sponsors.html:65 msgid "Become a sponsor" msgstr "" -#: warehouse/templates/pages/sponsors.html:69 +#: warehouse/templates/pages/sponsors.html:67 msgid "" "The PSF is recognized by the IRS as a 501(c)(3) non-profit charitable " "organization, and donations are tax-deductible for organizations that pay" " taxes in the United States." msgstr "" -#: warehouse/templates/pages/sponsors.html:80 +#: warehouse/templates/pages/sponsors.html:78 msgid "Get your logo on PyPI.org" msgstr "" -#: warehouse/templates/pages/sponsors.html:81 +#: warehouse/templates/pages/sponsors.html:79 msgid "" "Looking for brand visibility? In the last year*, 21.1 million people from" " 237 countries visited PyPI.org." msgstr "" -#: warehouse/templates/pages/sponsors.html:82 +#: warehouse/templates/pages/sponsors.html:80 msgid "* Data as of March 2020" msgstr "" -#: warehouse/templates/pages/sponsors.html:85 +#: warehouse/templates/pages/sponsors.html:83 msgid "Strengthen the Python ecosystem" msgstr "" -#: warehouse/templates/pages/sponsors.html:86 +#: warehouse/templates/pages/sponsors.html:84 msgid "" "Funds raised by the Python Software Foundation go directly towards " "improving the tools your company uses every day." msgstr "" -#: warehouse/templates/pages/sponsors.html:89 +#: warehouse/templates/pages/sponsors.html:87 msgid "Boost your reputation" msgstr "" -#: warehouse/templates/pages/sponsors.html:90 +#: warehouse/templates/pages/sponsors.html:88 msgid "" "Enhance your company's reputation by investing in Python and the open " "source community." diff --git a/warehouse/migrations/versions/590c513f1c74_new_psf_staff_boolean_flag.py b/warehouse/migrations/versions/590c513f1c74_new_psf_staff_boolean_flag.py new file mode 100644 index 000000000000..79599a44cc65 --- /dev/null +++ b/warehouse/migrations/versions/590c513f1c74_new_psf_staff_boolean_flag.py @@ -0,0 +1,54 @@ +# 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 psf_staff boolean flag + +Revision ID: 590c513f1c74 +Revises: d0c22553b338 +Create Date: 2021-06-07 11:49:50.688410 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "590c513f1c74" +down_revision = "d0c22553b338" + +# Note: It is VERY important to ensure that a migration does not lock for a +# long period of time and to ensure that each individual migration does +# not break compatibility with the *previous* version of the code base. +# This is because the migrations will be ran automatically as part of the +# deployment process, but while the previous version of the code is still +# up and running. Thus backwards incompatible changes must be broken up +# over multiple migrations inside of multiple pull requests in order to +# phase them in over multiple deploys. + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "users", + sa.Column( + "is_psf_staff", + sa.Boolean(), + server_default=sa.sql.false(), + nullable=False, + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "is_psf_staff") + # ### end Alembic commands ### diff --git a/warehouse/migrations/versions/d0c22553b338_sponsor_model.py b/warehouse/migrations/versions/d0c22553b338_sponsor_model.py new file mode 100644 index 000000000000..68df1cbfbd60 --- /dev/null +++ b/warehouse/migrations/versions/d0c22553b338_sponsor_model.py @@ -0,0 +1,73 @@ +# 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. +""" +Sponsor model + +Revision ID: d0c22553b338 +Revises: 69b928240b2f +Create Date: 2021-05-26 18:23:27.021443 +""" + +import sqlalchemy as sa +import sqlalchemy_utils + +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "d0c22553b338" +down_revision = "69b928240b2f" + +# Note: It is VERY important to ensure that a migration does not lock for a +# long period of time and to ensure that each individual migration does +# not break compatibility with the *previous* version of the code base. +# This is because the migrations will be ran automatically as part of the +# deployment process, but while the previous version of the code is still +# up and running. Thus backwards incompatible changes must be broken up +# over multiple migrations inside of multiple pull requests in order to +# phase them in over multiple deploys. + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "sponsors", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("name", sa.String(), nullable=False), + sa.Column("service", sa.String(), nullable=True), + sa.Column("activity_markdown", sa.Text(), nullable=True), + sa.Column("link_url", sqlalchemy_utils.types.url.URLType(), nullable=False), + sa.Column( + "color_logo_url", sqlalchemy_utils.types.url.URLType(), nullable=False + ), + sa.Column( + "white_logo_url", sqlalchemy_utils.types.url.URLType(), nullable=True + ), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("footer", sa.Boolean(), nullable=False), + sa.Column("psf_sponsor", sa.Boolean(), nullable=False), + sa.Column("infra_sponsor", sa.Boolean(), nullable=False), + sa.Column("one_time", sa.Boolean(), nullable=False), + sa.Column("sidebar", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("sponsors") + # ### end Alembic commands ### diff --git a/warehouse/sponsors/__init__.py b/warehouse/sponsors/__init__.py new file mode 100644 index 000000000000..1aefa75f02e2 --- /dev/null +++ b/warehouse/sponsors/__init__.py @@ -0,0 +1,24 @@ +# 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 true + +from warehouse.sponsors.models import Sponsor + + +def _sponsors(request): + return request.db.query(Sponsor).filter(Sponsor.is_active == true()).all() + + +def includeme(config): + # Add a request method which will allow to list sponsors + config.add_request_method(_sponsors, name="sponsors", reify=True) diff --git a/warehouse/sponsors/models.py b/warehouse/sponsors/models.py new file mode 100644 index 000000000000..df11e0b10df8 --- /dev/null +++ b/warehouse/sponsors/models.py @@ -0,0 +1,59 @@ +# 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, String, Text +from sqlalchemy_utils.types.url import URLType + +from warehouse import db +from warehouse.utils import readme +from warehouse.utils.attrs import make_repr + + +class Sponsor(db.Model): + __tablename__ = "sponsors" + __repr__ = make_repr("name") + + name = Column(String, nullable=False) + service = Column(String) + activity_markdown = Column(Text) + + link_url = Column(URLType, nullable=False) + color_logo_url = Column(URLType, nullable=False) + white_logo_url = Column(URLType) + + # control flags + is_active = Column(Boolean, default=False, nullable=False) + footer = Column(Boolean, default=False, nullable=False) + psf_sponsor = Column(Boolean, default=False, nullable=False) + infra_sponsor = Column(Boolean, default=False, nullable=False) + one_time = Column(Boolean, default=False, nullable=False) + sidebar = Column(Boolean, default=False, nullable=False) + + @property + def color_logo_img(self): + return f'{ self.name }' + + @property + def white_logo_img(self): + if not self.white_logo_url: + return "" + return f'{ self.name }' + + @property + def activity(self): + """ + Render raw activity markdown as HTML + """ + if not self.activity_markdown: + return "" + return readme.render(self.activity_markdown, "text/markdown") diff --git a/warehouse/templates/includes/current-user-indicator.html b/warehouse/templates/includes/current-user-indicator.html index c2b5bea4f80b..52a6e33d0d1c 100644 --- a/warehouse/templates/includes/current-user-indicator.html +++ b/warehouse/templates/includes/current-user-indicator.html @@ -23,7 +23,7 @@