Skip to content

Commit 12e3337

Browse files
berinhardewdurbin
authored andcommitted
Sync PSF sponsors with logo placement API (pypi#10766)
* Add new config variables to integrate with pythondotorg * Add new fields with data from pythondotorg logo placement API * Register task to update sponsors table * Code linter * Fix typo * Minimal working code to integrate with logo palcement endpoint * First unit test to figure out how to mock requests * Create a new sponsor * Add logic to update existing sponsors * Reformat code * Leave HTTP vs HTTPS configuration to be handle by env variable * Fix linter errors * Create sponsor directly from the exception handler * Run task every 10 minutes * There's no need for the if condition * Only schedule cron job if there's a token in the env * Remove pythondotorg env variables from dev * Run linter Co-authored-by: Ee Durbin <[email protected]>
1 parent 04a0c46 commit 12e3337

File tree

9 files changed

+393
-2
lines changed

9 files changed

+393
-2
lines changed

tests/common/db/sponsors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,8 @@ class Meta:
3535
infra_sponsor = False
3636
one_time = False
3737
sidebar = True
38+
39+
origin = "manual"
40+
level_name = ""
41+
level_order = 0
42+
slug = factory.Faker("slug")

tests/unit/sponsors/test_init.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,48 @@
1212

1313
import pretend
1414

15+
from celery.schedules import crontab
1516
from sqlalchemy import true
1617

1718
from warehouse import sponsors
1819
from warehouse.sponsors.models import Sponsor
20+
from warehouse.sponsors.tasks import update_pypi_sponsors
1921

2022
from ...common.db.sponsors import SponsorFactory
2123

2224

2325
def test_includeme():
26+
settings = {"pythondotorg.api_token": "test-token"}
2427
config = pretend.stub(
25-
add_request_method=pretend.call_recorder(lambda f, name, reify: None)
28+
add_request_method=pretend.call_recorder(lambda f, name, reify: None),
29+
add_periodic_task=pretend.call_recorder(lambda crontab, task: None),
30+
registry=pretend.stub(settings=settings),
2631
)
2732

2833
sponsors.includeme(config)
2934

3035
assert config.add_request_method.calls == [
3136
pretend.call(sponsors._sponsors, name="sponsors", reify=True),
3237
]
38+
assert config.add_periodic_task.calls == [
39+
pretend.call(crontab(minute=10), update_pypi_sponsors),
40+
]
41+
42+
43+
def test_do_not_schedule_sponsor_api_integration_if_no_token():
44+
settings = {}
45+
config = pretend.stub(
46+
add_request_method=pretend.call_recorder(lambda f, name, reify: None),
47+
add_periodic_task=pretend.call_recorder(lambda crontab, task: None),
48+
registry=pretend.stub(settings=settings),
49+
)
50+
51+
sponsors.includeme(config)
52+
53+
assert config.add_request_method.calls == [
54+
pretend.call(sponsors._sponsors, name="sponsors", reify=True),
55+
]
56+
assert not config.add_periodic_task.calls
3357

3458

3559
def test_list_sponsors(db_request):

tests/unit/sponsors/test_tasks.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
from urllib.parse import urlencode
13+
14+
import pretend
15+
import pytest
16+
17+
from requests.exceptions import HTTPError
18+
19+
from warehouse.sponsors import tasks
20+
from warehouse.sponsors.models import Sponsor
21+
22+
from ...common.db.sponsors import SponsorFactory
23+
24+
25+
@pytest.fixture
26+
def fake_task_request():
27+
cfg = {
28+
"pythondotorg.host": "https://API_HOST",
29+
"pythondotorg.api_token": "API_TOKEN",
30+
}
31+
request = pretend.stub(registry=pretend.stub(settings=cfg))
32+
return request
33+
34+
35+
@pytest.fixture
36+
def sponsor_api_data():
37+
return [
38+
{
39+
"publisher": "pypi",
40+
"flight": "sponsors",
41+
"sponsor": "Sponsor Name",
42+
"sponsor_slug": "sponsor-name",
43+
"description": "Sponsor description",
44+
"logo": "https://logourl.com",
45+
"start_date": "2021-02-17",
46+
"end_date": "2022-02-17",
47+
"sponsor_url": "https://sponsor.example.com/",
48+
"level_name": "Partner",
49+
"level_order": 5,
50+
}
51+
]
52+
53+
54+
def test_raise_error_if_invalid_response(monkeypatch, db_request, fake_task_request):
55+
response = pretend.stub(
56+
status_code=418,
57+
text="I'm a teapot",
58+
raise_for_status=pretend.raiser(HTTPError),
59+
)
60+
requests = pretend.stub(get=pretend.call_recorder(lambda url, headers: response))
61+
monkeypatch.setattr(tasks, "requests", requests)
62+
63+
with pytest.raises(HTTPError):
64+
tasks.update_pypi_sponsors(fake_task_request)
65+
66+
qs = urlencode({"publisher": "pypi", "flight": "sponsors"})
67+
headers = {"Authorization": "Token API_TOKEN"}
68+
expected_url = f"https://API_HOST/api/v2/sponsors/logo-placement/?{qs}"
69+
assert requests.get.calls == [pretend.call(expected_url, headers=headers)]
70+
71+
72+
def test_create_new_sponsor_if_no_matching(
73+
monkeypatch, db_request, fake_task_request, sponsor_api_data
74+
):
75+
response = pretend.stub(
76+
raise_for_status=lambda: None, json=lambda: sponsor_api_data
77+
)
78+
requests = pretend.stub(get=pretend.call_recorder(lambda url, headers: response))
79+
monkeypatch.setattr(tasks, "requests", requests)
80+
assert 0 == len(db_request.db.query(Sponsor).all())
81+
82+
fake_task_request.db = db_request.db
83+
tasks.update_pypi_sponsors(fake_task_request)
84+
85+
db_sponsor = db_request.db.query(Sponsor).one()
86+
assert "sponsor-name" == db_sponsor.slug
87+
assert "Sponsor Name" == db_sponsor.name
88+
assert "Sponsor description" == db_sponsor.service
89+
assert "https://sponsor.example.com/" == db_sponsor.link_url
90+
assert "https://logourl.com" == db_sponsor.color_logo_url
91+
assert db_sponsor.activity_markdown is None
92+
assert db_sponsor.white_logo_url is None
93+
assert db_sponsor.is_active is True
94+
assert db_sponsor.psf_sponsor is True
95+
assert db_sponsor.footer is False
96+
assert db_sponsor.infra_sponsor is False
97+
assert db_sponsor.one_time is False
98+
assert db_sponsor.sidebar is False
99+
assert "remote" == db_sponsor.origin
100+
assert "Partner" == db_sponsor.level_name
101+
assert 5 == db_sponsor.level_order
102+
103+
104+
def test_update_remote_sponsor_with_same_name_with_new_logo(
105+
monkeypatch, db_request, fake_task_request, sponsor_api_data
106+
):
107+
response = pretend.stub(
108+
raise_for_status=lambda: None, json=lambda: sponsor_api_data
109+
)
110+
requests = pretend.stub(get=pretend.call_recorder(lambda url, headers: response))
111+
monkeypatch.setattr(tasks, "requests", requests)
112+
created_sponsor = SponsorFactory.create(
113+
name=sponsor_api_data[0]["sponsor"],
114+
psf_sponsor=True,
115+
footer=False,
116+
sidebar=False,
117+
one_time=False,
118+
origin="manual",
119+
)
120+
121+
fake_task_request.db = db_request.db
122+
tasks.update_pypi_sponsors(fake_task_request)
123+
124+
assert 1 == len(db_request.db.query(Sponsor).all())
125+
db_sponsor = db_request.db.query(Sponsor).one()
126+
assert db_sponsor.id == created_sponsor.id
127+
assert "sponsor-name" == db_sponsor.slug
128+
assert "Sponsor description" == db_sponsor.service
129+
assert "https://sponsor.example.com/" == db_sponsor.link_url
130+
assert "https://logourl.com" == db_sponsor.color_logo_url
131+
assert db_sponsor.activity_markdown is created_sponsor.activity_markdown
132+
assert db_sponsor.white_logo_url is created_sponsor.white_logo_url
133+
assert db_sponsor.is_active is True
134+
assert db_sponsor.psf_sponsor is True
135+
assert db_sponsor.footer is False
136+
assert db_sponsor.infra_sponsor is False
137+
assert db_sponsor.one_time is False
138+
assert db_sponsor.sidebar is False
139+
assert "remote" == db_sponsor.origin
140+
assert "Partner" == db_sponsor.level_name
141+
assert 5 == db_sponsor.level_order
142+
143+
144+
def test_do_not_update_if_not_psf_sponsor(
145+
monkeypatch, db_request, fake_task_request, sponsor_api_data
146+
):
147+
response = pretend.stub(
148+
raise_for_status=lambda: None, json=lambda: sponsor_api_data
149+
)
150+
requests = pretend.stub(get=pretend.call_recorder(lambda url, headers: response))
151+
monkeypatch.setattr(tasks, "requests", requests)
152+
infra_sponsor = SponsorFactory.create(
153+
name=sponsor_api_data[0]["sponsor"],
154+
psf_sponsor=False,
155+
infra_sponsor=True,
156+
one_time=False,
157+
origin="manual",
158+
)
159+
160+
fake_task_request.db = db_request.db
161+
tasks.update_pypi_sponsors(fake_task_request)
162+
163+
assert 1 == len(db_request.db.query(Sponsor).all())
164+
db_sponsor = db_request.db.query(Sponsor).one()
165+
assert db_sponsor.id == infra_sponsor.id
166+
assert "manual" == db_sponsor.origin
167+
assert "sponsor-name" != db_sponsor.slug
168+
169+
170+
def test_update_remote_sponsor_with_same_slug_with_new_logo(
171+
monkeypatch, db_request, fake_task_request, sponsor_api_data
172+
):
173+
response = pretend.stub(
174+
raise_for_status=lambda: None, json=lambda: sponsor_api_data
175+
)
176+
requests = pretend.stub(get=pretend.call_recorder(lambda url, headers: response))
177+
monkeypatch.setattr(tasks, "requests", requests)
178+
created_sponsor = SponsorFactory.create(
179+
slug=sponsor_api_data[0]["sponsor_slug"],
180+
psf_sponsor=True,
181+
footer=False,
182+
sidebar=False,
183+
one_time=False,
184+
origin="manual",
185+
)
186+
187+
fake_task_request.db = db_request.db
188+
tasks.update_pypi_sponsors(fake_task_request)
189+
190+
assert 1 == len(db_request.db.query(Sponsor).all())
191+
db_sponsor = db_request.db.query(Sponsor).one()
192+
assert db_sponsor.id == created_sponsor.id
193+
assert "Sponsor Name" == db_sponsor.name
194+
assert "Sponsor description" == db_sponsor.service
195+
196+
197+
def test_flag_existing_psf_sponsor_to_false_if_not_present_in_api_response(
198+
monkeypatch, db_request, fake_task_request, sponsor_api_data
199+
):
200+
response = pretend.stub(
201+
raise_for_status=lambda: None, json=lambda: sponsor_api_data
202+
)
203+
requests = pretend.stub(get=pretend.call_recorder(lambda url, headers: response))
204+
monkeypatch.setattr(tasks, "requests", requests)
205+
created_sponsor = SponsorFactory.create(
206+
slug="other-slug",
207+
name="Other Sponsor",
208+
psf_sponsor=True,
209+
footer=True,
210+
sidebar=True,
211+
origin="manual",
212+
)
213+
214+
fake_task_request.db = db_request.db
215+
tasks.update_pypi_sponsors(fake_task_request)
216+
217+
assert 2 == len(db_request.db.query(Sponsor).all())
218+
created_sponsor = (
219+
db_request.db.query(Sponsor).filter(Sponsor.id == created_sponsor.id).one()
220+
)
221+
# no longer PSF sponsor but stay active as sidebar/footer sponsor
222+
assert created_sponsor.psf_sponsor is False
223+
assert created_sponsor.sidebar is True
224+
assert created_sponsor.footer is True
225+
assert created_sponsor.is_active is True

tests/unit/test_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ def __init__(self):
233233
"site.name": "Warehouse",
234234
"token.two_factor.max_age": 300,
235235
"token.default.max_age": 21600,
236+
"pythondotorg.host": "python.org",
236237
"warehouse.xmlrpc.client.ratelimit_string": "3600 per hour",
237238
"warehouse.xmlrpc.search.enabled": True,
238239
"github.token_scanning_meta_api.url": (

warehouse/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ def configure(settings=None):
235235
maybe_set_compound(settings, "breached_passwords", "backend", "BREACHED_PASSWORDS")
236236
maybe_set_compound(settings, "malware_check", "backend", "MALWARE_CHECK_BACKEND")
237237

238+
# Pythondotorg integration settings
239+
maybe_set(settings, "pythondotorg.host", "PYTHONDOTORG_HOST", default="python.org")
240+
maybe_set(settings, "pythondotorg.api_token", "PYTHONDOTORG_API_TOKEN")
241+
238242
# Configure our ratelimiters
239243
maybe_set(
240244
settings,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
"""
13+
New Sponsor columns to save data from pythondotorg API
14+
15+
Revision ID: 19cf76d2d459
16+
Revises: 29a8901a4635
17+
Create Date: 2022-02-13 14:31:18.366248
18+
"""
19+
20+
import sqlalchemy as sa
21+
22+
from alembic import op
23+
24+
revision = "19cf76d2d459"
25+
down_revision = "29a8901a4635"
26+
27+
# Note: It is VERY important to ensure that a migration does not lock for a
28+
# long period of time and to ensure that each individual migration does
29+
# not break compatibility with the *previous* version of the code base.
30+
# This is because the migrations will be ran automatically as part of the
31+
# deployment process, but while the previous version of the code is still
32+
# up and running. Thus backwards incompatible changes must be broken up
33+
# over multiple migrations inside of multiple pull requests in order to
34+
# phase them in over multiple deploys.
35+
36+
37+
def upgrade():
38+
# ### commands auto generated by Alembic - please adjust! ###
39+
op.add_column("sponsors", sa.Column("origin", sa.String(), nullable=True))
40+
op.add_column("sponsors", sa.Column("level_name", sa.String(), nullable=True))
41+
op.add_column("sponsors", sa.Column("level_order", sa.Integer(), nullable=True))
42+
op.add_column("sponsors", sa.Column("slug", sa.String(), nullable=True))
43+
# ### end Alembic commands ###
44+
45+
46+
def downgrade():
47+
# ### commands auto generated by Alembic - please adjust! ###
48+
op.drop_column("sponsors", "slug")
49+
op.drop_column("sponsors", "level_order")
50+
op.drop_column("sponsors", "level_name")
51+
op.drop_column("sponsors", "origin")
52+
# ### end Alembic commands ###

warehouse/sponsors/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13+
from celery.schedules import crontab
1314
from sqlalchemy import true
1415

1516
from warehouse.sponsors.models import Sponsor
17+
from warehouse.sponsors.tasks import update_pypi_sponsors
1618

1719

1820
def _sponsors(request):
@@ -22,3 +24,7 @@ def _sponsors(request):
2224
def includeme(config):
2325
# Add a request method which will allow to list sponsors
2426
config.add_request_method(_sponsors, name="sponsors", reify=True)
27+
28+
# Add a periodic task to update sponsors table
29+
if config.registry.settings.get("pythondotorg.api_token"):
30+
config.add_periodic_task(crontab(minute=10), update_pypi_sponsors)

warehouse/sponsors/models.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13-
from sqlalchemy import Boolean, Column, String, Text
13+
from sqlalchemy import Boolean, Column, Integer, String, Text
1414
from sqlalchemy_utils.types.url import URLType
1515

1616
from warehouse import db
@@ -38,6 +38,12 @@ class Sponsor(db.Model):
3838
one_time = Column(Boolean, default=False, nullable=False)
3939
sidebar = Column(Boolean, default=False, nullable=False)
4040

41+
# pythondotorg integration
42+
origin = Column(String, default="manual")
43+
level_name = Column(String)
44+
level_order = Column(Integer, default=0)
45+
slug = Column(String)
46+
4147
@property
4248
def color_logo_img(self):
4349
return f'<img src="{ self.color_logo_url }" alt="{ self.name }">'

0 commit comments

Comments
 (0)