Skip to content

Commit 64b910f

Browse files
authored
Add email notification on project/release removal (#7071)
* Add email notication on package/release removal Until now, where there are multiple contributors on a single the project, if one of them deletes a release or the whole project the other contributors don't get any notification, which is problematic. Connected with issue #5714. Signed-off-by: Martin Vrachev <[email protected]> * Use lower case for submitter role Signed-off-by: Martin Vrachev <[email protected]> * Update messages.pot with remove emails Signed-off-by: Martin Vrachev <[email protected]>
1 parent e462bba commit 64b910f

File tree

11 files changed

+878
-6
lines changed

11 files changed

+878
-6
lines changed

tests/unit/email/test_init.py

Lines changed: 398 additions & 0 deletions
Large diffs are not rendered by default.

tests/unit/manage/test_views.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2310,7 +2310,75 @@ def test_delete_project_disallow_deletion(self):
23102310
pretend.call("manage.project.settings", project_name="foo")
23112311
]
23122312

2313-
def test_delete_project(self, db_request):
2313+
def test_get_project_contributors(self, db_request):
2314+
project = ProjectFactory.create(name="foo")
2315+
db_request.session = pretend.stub(
2316+
flash=pretend.call_recorder(lambda *a, **kw: None),
2317+
)
2318+
2319+
db_request.user = UserFactory.create()
2320+
project.users = [db_request.user]
2321+
2322+
res = views.get_project_contributors(project.name, db_request)
2323+
assert res == [db_request.user]
2324+
2325+
def test_get_user_role_in_project_single_role_owner(self, db_request):
2326+
project = ProjectFactory.create(name="foo")
2327+
db_request.session = pretend.stub(
2328+
flash=pretend.call_recorder(lambda *a, **kw: None),
2329+
)
2330+
db_request.user = UserFactory.create()
2331+
project.users = [db_request.user]
2332+
RoleFactory(user=db_request.user, project=project)
2333+
2334+
res = views.get_user_role_in_project(
2335+
project.name, db_request.user.username, db_request
2336+
)
2337+
assert res == "Owner"
2338+
2339+
def test_get_user_role_in_project_single_role_maintainer(self, db_request):
2340+
project = ProjectFactory.create(name="foo")
2341+
db_request.session = pretend.stub(
2342+
flash=pretend.call_recorder(lambda *a, **kw: None),
2343+
)
2344+
db_request.user = UserFactory.create()
2345+
project.users = [db_request.user]
2346+
RoleFactory(user=db_request.user, project=project, role_name="Maintainer")
2347+
2348+
res = views.get_user_role_in_project(
2349+
project.name, db_request.user.username, db_request
2350+
)
2351+
assert res == "Maintainer"
2352+
2353+
def test_get_user_role_in_project_two_roles_owner_and_maintainer(self, db_request):
2354+
project = ProjectFactory.create(name="foo")
2355+
db_request.session = pretend.stub(
2356+
flash=pretend.call_recorder(lambda *a, **kw: None),
2357+
)
2358+
db_request.user = UserFactory.create()
2359+
project.users = [db_request.user]
2360+
RoleFactory(user=db_request.user, project=project, role_name="Owner")
2361+
RoleFactory(user=db_request.user, project=project, role_name="Maintainer")
2362+
2363+
res = views.get_user_role_in_project(
2364+
project.name, db_request.user.username, db_request
2365+
)
2366+
assert res == "Owner"
2367+
2368+
def test_get_user_role_in_project_no_role(self, db_request):
2369+
project = ProjectFactory.create(name="foo")
2370+
db_request.session = pretend.stub(
2371+
flash=pretend.call_recorder(lambda *a, **kw: None),
2372+
)
2373+
db_request.user = UserFactory.create()
2374+
project.users = [db_request.user]
2375+
2376+
res = views.get_user_role_in_project(
2377+
project.name, db_request.user.username, db_request
2378+
)
2379+
assert res == ""
2380+
2381+
def test_delete_project(self, monkeypatch, db_request):
23142382
project = ProjectFactory.create(name="foo")
23152383

23162384
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
@@ -2319,6 +2387,22 @@ def test_delete_project(self, db_request):
23192387
)
23202388
db_request.POST["confirm_project_name"] = project.normalized_name
23212389
db_request.user = UserFactory.create()
2390+
2391+
get_user_role_in_project = pretend.call_recorder(
2392+
lambda project_name, username, req: "Owner"
2393+
)
2394+
monkeypatch.setattr(views, "get_user_role_in_project", get_user_role_in_project)
2395+
2396+
get_project_contributors = pretend.call_recorder(
2397+
lambda project_name, req: [db_request.user]
2398+
)
2399+
monkeypatch.setattr(views, "get_project_contributors", get_project_contributors)
2400+
2401+
send_removed_project_email = pretend.call_recorder(lambda req, user, **k: None)
2402+
monkeypatch.setattr(
2403+
views, "send_removed_project_email", send_removed_project_email
2404+
)
2405+
23222406
db_request.remote_addr = "192.168.1.1"
23232407

23242408
result = views.delete_project(project, db_request)
@@ -2329,6 +2413,26 @@ def test_delete_project(self, db_request):
23292413
assert db_request.route_path.calls == [pretend.call("manage.projects")]
23302414
assert isinstance(result, HTTPSeeOther)
23312415
assert result.headers["Location"] == "/the-redirect"
2416+
2417+
assert get_user_role_in_project.calls == [
2418+
pretend.call(project.name, db_request.user.username, db_request,),
2419+
pretend.call(project.name, db_request.user.username, db_request,),
2420+
]
2421+
2422+
assert get_project_contributors.calls == [
2423+
pretend.call(project.name, db_request,)
2424+
]
2425+
2426+
assert send_removed_project_email.calls == [
2427+
pretend.call(
2428+
db_request,
2429+
db_request.user,
2430+
project_name=project.name,
2431+
submitter_name=db_request.user.username,
2432+
submitter_role="Owner",
2433+
recipient_role="Owner",
2434+
)
2435+
]
23322436
assert not (db_request.db.query(Project).filter(Project.name == "foo").count())
23332437

23342438

@@ -2495,6 +2599,7 @@ def test_delete_project_release(self, monkeypatch):
24952599
project=pretend.stub(
24962600
name="foobar", record_event=pretend.call_recorder(lambda *a, **kw: None)
24972601
),
2602+
created=datetime.datetime(2017, 2, 5, 17, 18, 18, 462_634),
24982603
)
24992604
request = pretend.stub(
25002605
POST={"confirm_version": release.version},
@@ -2511,7 +2616,25 @@ def test_delete_project_release(self, monkeypatch):
25112616
)
25122617
journal_obj = pretend.stub()
25132618
journal_cls = pretend.call_recorder(lambda **kw: journal_obj)
2619+
2620+
get_user_role_in_project = pretend.call_recorder(
2621+
lambda project_name, username, req: "Owner"
2622+
)
2623+
monkeypatch.setattr(views, "get_user_role_in_project", get_user_role_in_project)
2624+
get_project_contributors = pretend.call_recorder(
2625+
lambda project_name, request: [request.user]
2626+
)
2627+
monkeypatch.setattr(views, "get_project_contributors", get_project_contributors)
2628+
25142629
monkeypatch.setattr(views, "JournalEntry", journal_cls)
2630+
send_removed_project_release_email = pretend.call_recorder(
2631+
lambda req, contrib, **k: None
2632+
)
2633+
monkeypatch.setattr(
2634+
views,
2635+
"send_removed_project_release_email",
2636+
send_removed_project_release_email,
2637+
)
25152638

25162639
view = views.ManageProjectRelease(release, request)
25172640

@@ -2520,6 +2643,25 @@ def test_delete_project_release(self, monkeypatch):
25202643
assert isinstance(result, HTTPSeeOther)
25212644
assert result.headers["Location"] == "/the-redirect"
25222645

2646+
assert get_user_role_in_project.calls == [
2647+
pretend.call(release.project.name, request.user.username, request,),
2648+
pretend.call(release.project.name, request.user.username, request,),
2649+
]
2650+
assert get_project_contributors.calls == [
2651+
pretend.call(release.project.name, request,)
2652+
]
2653+
2654+
assert send_removed_project_release_email.calls == [
2655+
pretend.call(
2656+
request,
2657+
request.user,
2658+
release=release,
2659+
submitter_name=request.user.username,
2660+
submitter_role="Owner",
2661+
recipient_role="Owner",
2662+
)
2663+
]
2664+
25232665
assert request.db.delete.calls == [pretend.call(release)]
25242666
assert request.db.add.calls == [pretend.call(journal_obj)]
25252667
assert request.flags.enabled.calls == [

warehouse/email/__init__.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,40 @@ def send_two_factor_removed_email(request, user, method):
213213
return {"method": pretty_methods[method], "username": user.username}
214214

215215

216+
@_email("removed-project")
217+
def send_removed_project_email(
218+
request, user, *, project_name, submitter_name, submitter_role, recipient_role
219+
):
220+
recipient_role_descr = "an owner"
221+
if recipient_role == "Maintainer":
222+
recipient_role_descr = "a maintainer"
223+
224+
return {
225+
"project": project_name,
226+
"submitter": submitter_name,
227+
"submitter_role": submitter_role.lower(),
228+
"recipient_role_descr": recipient_role_descr,
229+
}
230+
231+
232+
@_email("removed-project-release")
233+
def send_removed_project_release_email(
234+
request, user, *, release, submitter_name, submitter_role, recipient_role
235+
):
236+
recipient_role_descr = "an owner"
237+
if recipient_role == "Maintainer":
238+
recipient_role_descr = "a maintainer"
239+
240+
return {
241+
"project": release.project.name,
242+
"release": release.version,
243+
"release_date": release.created.strftime("%Y-%m-%d"),
244+
"submitter": submitter_name,
245+
"submitter_role": submitter_role.lower(),
246+
"recipient_role_descr": recipient_role_descr,
247+
}
248+
249+
216250
def includeme(config):
217251
email_sending_class = config.maybe_dotted(config.registry.settings["mail.backend"])
218252
config.register_service_factory(email_sending_class.create_service, IEmailSender)

warehouse/locale/messages.pot

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,25 +169,25 @@ msgstr ""
169169
msgid "Email address ${email_address} verified. ${confirm_message}."
170170
msgstr ""
171171

172-
#: warehouse/manage/views.py:172
172+
#: warehouse/manage/views.py:174
173173
msgid "Email ${email_address} added - check your email for a verification link"
174174
msgstr ""
175175

176-
#: warehouse/manage/views.py:653 warehouse/manage/views.py:689
176+
#: warehouse/manage/views.py:655 warehouse/manage/views.py:691
177177
msgid ""
178178
"You must provision a two factor method before recovery codes can be "
179179
"generated"
180180
msgstr ""
181181

182-
#: warehouse/manage/views.py:664
182+
#: warehouse/manage/views.py:666
183183
msgid "Recovery codes already generated"
184184
msgstr ""
185185

186-
#: warehouse/manage/views.py:665
186+
#: warehouse/manage/views.py:667
187187
msgid "Generating new recovery codes will invalidate your existing codes."
188188
msgstr ""
189189

190-
#: warehouse/manage/views.py:715
190+
#: warehouse/manage/views.py:717
191191
msgid "Invalid credentials. Try again"
192192
msgstr ""
193193

@@ -1255,6 +1255,48 @@ msgid ""
12551255
"<code>%(new_email)s</code>"
12561256
msgstr ""
12571257

1258+
#: warehouse/templates/email/removed-project/body.html:25
1259+
#, python-format
1260+
msgid "The project %(project)s has been deleted."
1261+
msgstr ""
1262+
1263+
#: warehouse/templates/email/removed-project-release/body.html:26
1264+
#: warehouse/templates/email/removed-project/body.html:26
1265+
#, python-format
1266+
msgid ""
1267+
"<strong>Deleted by:</strong> %(submitter)s with a role:\n"
1268+
" %(role)s."
1269+
msgstr ""
1270+
1271+
#: warehouse/templates/email/removed-project-release/body.html:32
1272+
#: warehouse/templates/email/removed-project/body.html:32
1273+
#, python-format
1274+
msgid ""
1275+
"If this was a mistake, you can email <a\n"
1276+
" href=\"%(href)s\">%(email_address)s</a> to communicate with the PyPI "
1277+
"administrators."
1278+
msgstr ""
1279+
1280+
#: warehouse/templates/email/removed-project/body.html:38
1281+
#, python-format
1282+
msgid ""
1283+
"You are receiving this because you are %(recipient_role_descr)s of this "
1284+
"project."
1285+
msgstr ""
1286+
1287+
#: warehouse/templates/email/removed-project-release/body.html:25
1288+
#, python-format
1289+
msgid "The %(project)s release %(release)s released on %(date)s has been deleted."
1290+
msgstr ""
1291+
1292+
#: warehouse/templates/email/removed-project-release/body.html:38
1293+
#, python-format
1294+
msgid ""
1295+
"\n"
1296+
"You are receiving this because you are %(recipient_role_descr)s of this "
1297+
"project."
1298+
msgstr ""
1299+
12581300
#: warehouse/templates/email/two-factor-added/body.html:18
12591301
#, python-format
12601302
msgid ""

0 commit comments

Comments
 (0)