Skip to content

Commit f0f96fa

Browse files
ewdurbinmiketheman
andauthored
Terms of Service rollout (#17629)
* allow marking a flash message as safe to render HTML * terms of use -> terms of service * track user and organization tos engagements * implement a task to notify users of ToS update via email * draft of blog post * add pricing/payment terms * Apply suggestions from code review Co-authored-by: Mike Fiedler <[email protected]> * clarify that ToU is being superseded * set effective date for existing users * add a batch size tunable to notify_users_of_tos_update task * Apply suggestions from code review Co-authored-by: Mike Fiedler <[email protected]> * suggested in code review * refactor needs_tos_update -> needs_tos_flash, reduce interface * rename trash field name * lint * comments * clarifying * punt * refactor record_tos_engagement * update factory to use enum rather than string * translations * dev: don't flash ToS banner to admin users * Apply suggestions from code review Co-authored-by: Mike Fiedler <[email protected]> --------- Co-authored-by: Mike Fiedler <[email protected]>
1 parent b3131fc commit f0f96fa

37 files changed

+1156
-167
lines changed

dev/db/post-migrations.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ VALUES
5050
-- Set TOTP secret to IU7UP3EMIPI7EBPQUUSEHEJUFNBIWOYG for select users
5151
UPDATE users SET totp_secret = '\x453f47ec8c43d1f205f0a5244391342b428b3b06' WHERE username IN ('ewdurbin', 'di', 'dstufft', 'miketheman');
5252

53+
-- Create Terms of Service Engagements for select users to keep from flashing banner on login
54+
INSERT INTO user_terms_of_service_engagements (user_id, revision, created, engagement) (SELECT id, 'initial', NOW(), 'agreed' from users where username IN ('ewdurbin', 'di', 'dstufft', 'miketheman'));
55+
5356
-- Make select users owners of 'sampleproject'
5457
INSERT INTO roles (role_name, user_id, project_id)
5558
SELECT 'Owner', id, '4587cc12-e342-4880-9f61-ea4990fb81ea'

dev/environment

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ WAREHOUSE_ENV=development
55
WAREHOUSE_TOKEN=insecuretoken
66
WAREHOUSE_IP_SALT="insecure himalayan pink salt"
77

8+
TERMS_NOTIFICATION_BATCH_SIZE=0
9+
810
AWS_ACCESS_KEY_ID=foo
911
AWS_SECRET_ACCESS_KEY=foo
1012

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
title: Introducing our new Terms of Service
3+
description: PyPI is formalizing our policies to help us move forward with new services.
4+
authors:
5+
- ewdurbin
6+
date: 2025-02-25
7+
tags:
8+
- policies
9+
- transparency
10+
---
11+
12+
We're introducing a new
13+
[Terms of Service](https://policies.python.org/pypi.org/Terms-of-Service/)
14+
to formalize our relationship to users
15+
and enable us to move forward with providing new features and services,
16+
specifically
17+
[Organization Accounts](https://docs.pypi.org/organization-accounts/).
18+
19+
<!-- more -->
20+
21+
PyPI has had some form of [Terms of Use](https://policies.python.org/pypi.org/Terms-of-Use/)
22+
document for users since it
23+
[began accepting uploads in 2005](https://github.com/pypi/legacy/commit/b139c00cfc5794159afb1fc185d77dbc5fc1a2a4#diff-a67499b048e6bb6ef08d44c7a3c541199615b68e3bd153eb0ccedc492e3dec9dR7-R13)
24+
and has only been updated twice[^1] since.
25+
These terms have primarily served to protect PyPI
26+
and the Python Software Foundation (PSF) who operates it.
27+
28+
Over time we have introduced additional policies to protect our users and community
29+
such as our
30+
[Code of Conduct](https://policies.python.org/python.org/code-of-conduct/)
31+
[Privacy Notice](https://policies.python.org/pypi.org/Privacy-Notice/)
32+
and
33+
[Acceptable Use Policy](https://policies.python.org/pypi.org/Acceptable-Use-Policy/).
34+
35+
Our new
36+
[Terms of Service](https://policies.python.org/pypi.org/Terms-of-Service/)
37+
formalizes our relationship to PyPI users,
38+
makes protections for the PSF and PyPI users more explicit,
39+
and establishes terms we need to provide
40+
[Organization Accounts](https://docs.pypi.org/organization-accounts/)
41+
to paid
42+
[Corporate Organizations](https://docs.pypi.org/organization-accounts/pricing-and-payments/#corporate-organizations).
43+
44+
We have worked with our legal team to retain compatibility with the superseded
45+
[Terms of Use](https://policies.python.org/pypi.org/Terms-of-Use/)
46+
while adding as permissive a set of new terms as possible to ensure that PyPI users
47+
and the PSF are protected.
48+
49+
You will notice a banner on login reminding you of these updated terms,
50+
as well as an email notification to your primary email address if it has been verified.
51+
These terms will take effect for existing users March 27, 2025 and
52+
your continued use of PyPI after that date constitutes agreement to these new terms.
53+
54+
[^1]:
55+
See these commits for substantive changes since the Terms of Use was introduced:
56+
[2009-11-29](https://github.com/pypi/legacy/commit/ddbd32a78a431ab46cad912046c2492998edc618#diff-a6e30135c956f467cffa36eb37a756a53921754d55ddd6ea80d2a0b4c3f4abfaR16-R33)
57+
and
58+
[2016-12-16](https://github.com/pypi/legacy/commit/f645942c65a372fdacd4d48ffb4afed4502632e8#diff-bbf95bcc6416475537256acea89690f7c6b1f965c0306e9b883813bd3e4f6c10R15-R98).

docs/user/organization-accounts/pricing-and-payments.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ title: Pricing and Payments
1010

1111
### Pricing
1212

13-
Pricing for Corporate Organizations will be published at launch.
13+
* $5 per member User, per month
1414

1515
### Payment Terms
1616

17-
Payment terms for Corporate Organizations will be published at launch.
17+
Invoiced monthly, based on usage. Payment is due upon reciept and will be charged to the billing information on file.
1818

1919
## Community Organizations
2020

tests/common/db/accounts.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
Email,
2222
ProhibitedEmailDomain,
2323
ProhibitedUserName,
24+
TermsOfServiceEngagement,
2425
User,
26+
UserTermsOfServiceEngagement,
2527
)
2628

2729
from .base import WarehouseFactory
@@ -43,6 +45,13 @@ class Params:
4345
verified=True,
4446
)
4547
)
48+
# Shortcut to create a user with a ToS Agreement
49+
with_terms_of_service_agreement = factory.Trait(
50+
terms_of_service_engagements=factory.RelatedFactory(
51+
"tests.common.db.accounts.UserTermsOfServiceEngagementFactory",
52+
factory_related_name="user",
53+
)
54+
)
4655
# Allow passing a cleartext password to the factory
4756
# This will be hashed before saving the user.
4857
# Usage: UserFactory(clear_pwd="password")
@@ -85,6 +94,19 @@ class Meta:
8594
source = factory.SubFactory(User)
8695

8796

97+
class UserTermsOfServiceEngagementFactory(WarehouseFactory):
98+
class Meta:
99+
model = UserTermsOfServiceEngagement
100+
101+
revision = "initial"
102+
engagement = TermsOfServiceEngagement.Agreed
103+
created = factory.Faker(
104+
"date_time_between_dates",
105+
datetime_start=datetime.datetime(2025, 1, 1),
106+
datetime_end=datetime.datetime(2025, 2, 19),
107+
)
108+
109+
88110
class EmailFactory(WarehouseFactory):
89111
class Meta:
90112
model = Email

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ def get_app_config(database, nondefaults=None):
332332
"sessions.url": "redis://localhost:0/",
333333
"statuspage.url": "https://2p66nmmycsj3.statuspage.io",
334334
"warehouse.xmlrpc.cache.url": "redis://localhost:0/",
335+
"terms.revision": "initial",
335336
}
336337

337338
if nondefaults:

tests/functional/manage/test_views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def test_changing_password_succeeds(self, webtest, socket_enabled):
5757
"""A user can log in, and change their password."""
5858
# create a User
5959
user = UserFactory.create(
60-
with_verified_primary_email=True, clear_pwd="password"
60+
with_verified_primary_email=True,
61+
with_terms_of_service_agreement=True,
62+
clear_pwd="password",
6163
)
6264

6365
# visit login page

tests/unit/accounts/test_services.py

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,22 @@
4242
TooManyEmailsAdded,
4343
TooManyFailedLogins,
4444
)
45-
from warehouse.accounts.models import DisableReason, ProhibitedUserName
45+
from warehouse.accounts.models import (
46+
DisableReason,
47+
ProhibitedUserName,
48+
TermsOfServiceEngagement,
49+
UserTermsOfServiceEngagement,
50+
)
4651
from warehouse.events.tags import EventTag
4752
from warehouse.metrics import IMetricsService, NullMetrics
4853
from warehouse.rate_limiting.interfaces import IRateLimiter
4954

5055
from ...common.constants import REMOTE_ADDR
51-
from ...common.db.accounts import EmailFactory, UserFactory
56+
from ...common.db.accounts import (
57+
EmailFactory,
58+
UserFactory,
59+
UserTermsOfServiceEngagementFactory,
60+
)
5261
from ...common.db.ip_addresses import IpAddressFactory
5362

5463

@@ -1049,6 +1058,88 @@ def test_get_password_timestamp_no_value(self, user_service):
10491058

10501059
assert user_service.get_password_timestamp(user.id) == 0
10511060

1061+
def test_needs_tos_flash_no_engagements(self, user_service):
1062+
user = UserFactory.create()
1063+
assert user_service.needs_tos_flash(user.id, "initial") is True
1064+
1065+
def test_needs_tos_flash_with_passive_engagements(self, user_service):
1066+
user = UserFactory.create()
1067+
assert user_service.needs_tos_flash(user.id, "initial") is True
1068+
1069+
user_service.record_tos_engagement(
1070+
user.id, "initial", TermsOfServiceEngagement.Notified
1071+
)
1072+
assert user_service.needs_tos_flash(user.id, "initial") is True
1073+
1074+
user_service.record_tos_engagement(
1075+
user.id, "initial", TermsOfServiceEngagement.Flashed
1076+
)
1077+
assert user_service.needs_tos_flash(user.id, "initial") is True
1078+
1079+
def test_needs_tos_flash_with_viewed_engagement(self, user_service):
1080+
user = UserFactory.create()
1081+
assert user_service.needs_tos_flash(user.id, "initial") is True
1082+
1083+
user_service.record_tos_engagement(
1084+
user.id, "initial", TermsOfServiceEngagement.Viewed
1085+
)
1086+
assert user_service.needs_tos_flash(user.id, "initial") is False
1087+
1088+
def test_needs_tos_flash_with_agreed_engagement(self, user_service):
1089+
user = UserFactory.create()
1090+
assert user_service.needs_tos_flash(user.id, "initial") is True
1091+
1092+
user_service.record_tos_engagement(
1093+
user.id, "initial", TermsOfServiceEngagement.Agreed
1094+
)
1095+
assert user_service.needs_tos_flash(user.id, "initial") is False
1096+
1097+
def test_needs_tos_flash_if_engaged_more_than_30_days_ago(self, user_service):
1098+
user = UserFactory.create()
1099+
UserTermsOfServiceEngagementFactory.create(
1100+
user=user,
1101+
created=(datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=31)),
1102+
engagement=TermsOfServiceEngagement.Notified,
1103+
)
1104+
assert user_service.needs_tos_flash(user.id, "initial") is False
1105+
1106+
def test_record_tos_engagement_invalid_engagement(self, user_service):
1107+
user = UserFactory.create()
1108+
assert user.terms_of_service_engagements == []
1109+
with pytest.raises(ValueError): # noqa: PT011
1110+
user_service.record_tos_engagement(
1111+
user.id,
1112+
"initial",
1113+
None,
1114+
)
1115+
1116+
@pytest.mark.parametrize(
1117+
"engagement",
1118+
[
1119+
TermsOfServiceEngagement.Flashed,
1120+
TermsOfServiceEngagement.Notified,
1121+
TermsOfServiceEngagement.Viewed,
1122+
TermsOfServiceEngagement.Agreed,
1123+
],
1124+
)
1125+
def test_record_tos_engagement(self, user_service, db_request, engagement):
1126+
user = UserFactory.create()
1127+
assert user.terms_of_service_engagements == []
1128+
user_service.record_tos_engagement(
1129+
user.id,
1130+
"initial",
1131+
engagement,
1132+
)
1133+
assert (
1134+
db_request.db.query(UserTermsOfServiceEngagement)
1135+
.filter(
1136+
UserTermsOfServiceEngagement.user_id == user.id,
1137+
UserTermsOfServiceEngagement.revision == "initial",
1138+
UserTermsOfServiceEngagement.engagement == engagement,
1139+
)
1140+
.count()
1141+
) == 1
1142+
10521143

10531144
class TestTokenService:
10541145
def test_verify_service(self):

tests/unit/accounts/test_tasks.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,108 @@
1313
from datetime import datetime, timedelta, timezone
1414

1515
import pretend
16+
import pytest
1617

17-
from warehouse.accounts.tasks import compute_user_metrics
18+
from warehouse.accounts import tasks
19+
from warehouse.accounts.models import TermsOfServiceEngagement
20+
from warehouse.accounts.tasks import compute_user_metrics, notify_users_of_tos_update
1821

1922
from ...common.db.accounts import EmailFactory, UserFactory
2023
from ...common.db.packaging import ProjectFactory, ReleaseFactory
2124

2225

26+
def test_notify_users_of_tos_update(db_request, user_service, monkeypatch):
27+
db_request.registry.settings = {"terms.revision": "initial"}
28+
users_to_notify = UserFactory.create_batch(3, with_verified_primary_email=True)
29+
# Users we should not notify because they have already agreed to ToS
30+
UserFactory.create_batch(
31+
5, with_verified_primary_email=True, with_terms_of_service_agreement=True
32+
)
33+
# Users we should not notify because they don't have a primary/verified email
34+
UserFactory.create_batch(7)
35+
36+
send_email = pretend.call_recorder(lambda request, user: None)
37+
monkeypatch.setattr(tasks, "send_user_terms_of_service_updated", send_email)
38+
39+
user_service.record_tos_engagement = pretend.call_recorder(
40+
lambda user_id, revision, engagement: None
41+
)
42+
43+
notify_users_of_tos_update(db_request)
44+
45+
assert send_email.calls == [pretend.call(db_request, u) for u in users_to_notify]
46+
assert user_service.record_tos_engagement.calls == [
47+
pretend.call(u.id, "initial", TermsOfServiceEngagement.Notified)
48+
for u in users_to_notify
49+
]
50+
51+
52+
@pytest.mark.parametrize("batch_size", [0, 10])
53+
def test_notify_users_of_tos_update_respects_batch_size(
54+
db_request, batch_size, user_service, monkeypatch
55+
):
56+
db_request.registry.settings = {
57+
"terms.revision": "initial",
58+
"terms.notification_batch_size": batch_size,
59+
}
60+
users_to_notify = UserFactory.create_batch(20, with_verified_primary_email=True)
61+
62+
send_email = pretend.call_recorder(lambda request, user: None)
63+
monkeypatch.setattr(tasks, "send_user_terms_of_service_updated", send_email)
64+
65+
user_service.record_tos_engagement = pretend.call_recorder(
66+
lambda user_id, revision, engagement: None
67+
)
68+
69+
notify_users_of_tos_update(db_request)
70+
71+
assert (
72+
send_email.calls
73+
== [pretend.call(db_request, u) for u in users_to_notify][:batch_size]
74+
)
75+
assert (
76+
user_service.record_tos_engagement.calls
77+
== [
78+
pretend.call(u.id, "initial", TermsOfServiceEngagement.Notified)
79+
for u in users_to_notify
80+
][:batch_size]
81+
)
82+
83+
84+
def test_notify_users_of_tos_update_does_not_renotify(
85+
db_request, user_service, monkeypatch
86+
):
87+
db_request.registry.settings = {"terms.revision": "initial"}
88+
users_to_notify = UserFactory.create_batch(3, with_verified_primary_email=True)
89+
# Users we should not notify because they have already agreed to ToS
90+
UserFactory.create_batch(
91+
5, with_verified_primary_email=True, with_terms_of_service_agreement=True
92+
)
93+
# Users we should not notify because they don't have a primary/verified email
94+
UserFactory.create_batch(7)
95+
96+
send_email = pretend.call_recorder(lambda request, user: None)
97+
monkeypatch.setattr(tasks, "send_user_terms_of_service_updated", send_email)
98+
99+
user_service.record_tos_engagement(
100+
users_to_notify[-1].id, "initial", TermsOfServiceEngagement.Notified
101+
)
102+
103+
user_service.record_tos_engagement = pretend.call_recorder(
104+
lambda user_id, revision, engagement: None
105+
)
106+
107+
notify_users_of_tos_update(db_request)
108+
109+
assert send_email.calls == [
110+
pretend.call(db_request, u) for u in users_to_notify[:-1]
111+
]
112+
assert user_service.record_tos_engagement.calls == [
113+
pretend.call(u.id, "initial", TermsOfServiceEngagement.Notified)
114+
for u in users_to_notify[:-1]
115+
]
116+
117+
23118
def _create_old_users_and_releases():
24119
users = UserFactory.create_batch(3, is_active=True)
25120
for user in users:

0 commit comments

Comments
 (0)