Skip to content

Commit ddfe04d

Browse files
committed
Send email for CF close
1 parent bc839b6 commit ddfe04d

File tree

3 files changed

+292
-1
lines changed

3 files changed

+292
-1
lines changed

pgcommitfest/commitfest/models.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from datetime import datetime, timedelta, timezone
88

9+
from pgcommitfest.mailqueue.util import send_template_mail
910
from pgcommitfest.userprofile.models import UserProfile
1011

1112
from .util import DiffableModel
@@ -109,6 +110,75 @@ def to_json(self):
109110
"enddate": self.enddate.isoformat(),
110111
}
111112

113+
def send_closure_notifications(self):
114+
"""Send email notifications to authors of open patches when this commitfest is closed."""
115+
116+
# Get all patches with open status in this commitfest
117+
open_patches = (
118+
self.patchoncommitfest_set.filter(
119+
status__in=[
120+
PatchOnCommitFest.STATUS_REVIEW,
121+
PatchOnCommitFest.STATUS_AUTHOR,
122+
PatchOnCommitFest.STATUS_COMMITTER,
123+
]
124+
)
125+
.select_related("patch")
126+
.prefetch_related("patch__authors")
127+
)
128+
129+
# Collect unique authors across all open patches
130+
authors_to_notify = set()
131+
for poc in open_patches:
132+
for author in poc.patch.authors.all():
133+
if author.email:
134+
authors_to_notify.add(author)
135+
136+
# Get the next open commitfest if available
137+
next_cf = (
138+
CommitFest.objects.filter(
139+
status=CommitFest.STATUS_OPEN,
140+
draft=False,
141+
startdate__gt=self.enddate,
142+
)
143+
.order_by("startdate")
144+
.first()
145+
)
146+
147+
if next_cf:
148+
next_cf_url = f"https://commitfest.postgresql.org/{next_cf.id}/"
149+
else:
150+
next_cf_url = "https://commitfest.postgresql.org/"
151+
152+
# Send email to each author
153+
for author in authors_to_notify:
154+
# Get user's notification email preference
155+
email = author.email
156+
try:
157+
if author.userprofile and author.userprofile.notifyemail:
158+
email = author.userprofile.notifyemail.email
159+
except UserProfile.DoesNotExist:
160+
pass
161+
162+
# Get user's open patches in this commitfest
163+
user_patches = [
164+
poc for poc in open_patches if author in poc.patch.authors.all()
165+
]
166+
167+
send_template_mail(
168+
settings.NOTIFICATION_FROM,
169+
None,
170+
email,
171+
f"Commitfest {self.name} has closed",
172+
"mail/commitfest_closure.txt",
173+
{
174+
"user": author,
175+
"commitfest": self,
176+
"patches": user_patches,
177+
"next_cf": next_cf,
178+
"next_cf_url": next_cf_url,
179+
},
180+
)
181+
112182
@staticmethod
113183
def _are_relevant_commitfests_up_to_date(cfs, current_date):
114184
inprogress_cf = cfs["in_progress"]
@@ -143,15 +213,18 @@ def _refresh_relevant_commitfests(cls, for_update):
143213
if inprogress_cf and inprogress_cf.enddate < current_date:
144214
inprogress_cf.status = CommitFest.STATUS_CLOSED
145215
inprogress_cf.save()
216+
inprogress_cf.send_closure_notifications()
146217

147218
open_cf = cfs["open"]
148219

149220
if open_cf.startdate <= current_date:
150221
if open_cf.enddate < current_date:
151222
open_cf.status = CommitFest.STATUS_CLOSED
223+
open_cf.save()
224+
open_cf.send_closure_notifications()
152225
else:
153226
open_cf.status = CommitFest.STATUS_INPROGRESS
154-
open_cf.save()
227+
open_cf.save()
155228

156229
cls.next_open_cf(current_date).save()
157230

@@ -162,6 +235,7 @@ def _refresh_relevant_commitfests(cls, for_update):
162235
# If the draft commitfest has started, we need to update it
163236
draft_cf.status = CommitFest.STATUS_CLOSED
164237
draft_cf.save()
238+
draft_cf.send_closure_notifications()
165239
cls.next_draft_cf(current_date).save()
166240

167241
return cls.relevant_commitfests(for_update=for_update)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
Hello {{user.first_name|default:user.username}},
2+
3+
Commitfest {{commitfest.name}} has now closed. You have {{patches|length}} open patch{{patches|length|pluralize:"es"}} in this commitfest:
4+
5+
{%for poc in patches%}
6+
- {{poc.patch.name}}
7+
https://commitfest.postgresql.org/{{commitfest.id}}/{{poc.patch.id}}/
8+
{%endfor%}
9+
10+
Please take action on {{patches|length|pluralize:"this patch,these patches"}}:
11+
12+
1. If you want to continue working on {{patches|length|pluralize:"it,them"}}, move {{patches|length|pluralize:"it,them"}} to the next commitfest{% if next_cf %}: {{next_cf_url}}{% endif %}
13+
14+
2. If you no longer wish to pursue {{patches|length|pluralize:"this patch,these patches"}}, please close {{patches|length|pluralize:"it,them"}} with an appropriate status (Withdrawn, Returned with feedback, etc.)
15+
16+
{% if next_cf %}The next commitfest is {{next_cf.name}}, which runs from {{next_cf.startdate}} to {{next_cf.enddate}}.{% else %}Please check https://commitfest.postgresql.org/ for upcoming commitfests.{% endif %}
17+
18+
Thank you for your contributions to PostgreSQL!
19+
20+
--
21+
This is an automated message from the PostgreSQL Commitfest application.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import base64
2+
from datetime import datetime
3+
from email import message_from_string
4+
5+
import pytest
6+
7+
from pgcommitfest.commitfest.models import Patch, PatchOnCommitFest, Topic
8+
from pgcommitfest.mailqueue.models import QueuedMail
9+
10+
pytestmark = pytest.mark.django_db
11+
12+
13+
def get_email_body(queued_mail):
14+
"""Extract and decode the email body from a QueuedMail object."""
15+
msg = message_from_string(queued_mail.fullmsg)
16+
for part in msg.walk():
17+
if part.get_content_type() == "text/plain":
18+
payload = part.get_payload()
19+
return base64.b64decode(payload).decode("utf-8")
20+
return ""
21+
22+
23+
@pytest.fixture
24+
def topic():
25+
"""Create a test topic."""
26+
return Topic.objects.create(topic="General")
27+
28+
29+
def test_send_closure_notifications_to_authors_of_open_patches(
30+
alice, in_progress_cf, topic
31+
):
32+
"""Authors of patches with open status should receive closure notifications."""
33+
patch = Patch.objects.create(name="Test Patch", topic=topic)
34+
patch.authors.add(alice)
35+
PatchOnCommitFest.objects.create(
36+
patch=patch,
37+
commitfest=in_progress_cf,
38+
enterdate=datetime.now(),
39+
status=PatchOnCommitFest.STATUS_REVIEW,
40+
)
41+
42+
in_progress_cf.send_closure_notifications()
43+
44+
assert QueuedMail.objects.count() == 1
45+
mail = QueuedMail.objects.first()
46+
assert mail.receiver == alice.email
47+
assert f"Commitfest {in_progress_cf.name} has closed" in mail.fullmsg
48+
body = get_email_body(mail)
49+
assert "Test Patch" in body
50+
51+
52+
def test_no_notification_for_committed_patches(alice, in_progress_cf, topic):
53+
"""Authors of committed patches should not receive notifications."""
54+
patch = Patch.objects.create(name="Committed Patch", topic=topic)
55+
patch.authors.add(alice)
56+
PatchOnCommitFest.objects.create(
57+
patch=patch,
58+
commitfest=in_progress_cf,
59+
enterdate=datetime.now(),
60+
leavedate=datetime.now(),
61+
status=PatchOnCommitFest.STATUS_COMMITTED,
62+
)
63+
64+
in_progress_cf.send_closure_notifications()
65+
66+
assert QueuedMail.objects.count() == 0
67+
68+
69+
def test_no_notification_for_withdrawn_patches(alice, in_progress_cf, topic):
70+
"""Authors of withdrawn patches should not receive notifications."""
71+
patch = Patch.objects.create(name="Withdrawn Patch", topic=topic)
72+
patch.authors.add(alice)
73+
PatchOnCommitFest.objects.create(
74+
patch=patch,
75+
commitfest=in_progress_cf,
76+
enterdate=datetime.now(),
77+
leavedate=datetime.now(),
78+
status=PatchOnCommitFest.STATUS_WITHDRAWN,
79+
)
80+
81+
in_progress_cf.send_closure_notifications()
82+
83+
assert QueuedMail.objects.count() == 0
84+
85+
86+
def test_one_email_per_author_with_multiple_patches(alice, in_progress_cf, topic):
87+
"""An author with multiple open patches should receive one email listing all patches."""
88+
patch1 = Patch.objects.create(name="Patch One", topic=topic)
89+
patch1.authors.add(alice)
90+
PatchOnCommitFest.objects.create(
91+
patch=patch1,
92+
commitfest=in_progress_cf,
93+
enterdate=datetime.now(),
94+
status=PatchOnCommitFest.STATUS_REVIEW,
95+
)
96+
97+
patch2 = Patch.objects.create(name="Patch Two", topic=topic)
98+
patch2.authors.add(alice)
99+
PatchOnCommitFest.objects.create(
100+
patch=patch2,
101+
commitfest=in_progress_cf,
102+
enterdate=datetime.now(),
103+
status=PatchOnCommitFest.STATUS_AUTHOR,
104+
)
105+
106+
in_progress_cf.send_closure_notifications()
107+
108+
assert QueuedMail.objects.count() == 1
109+
mail = QueuedMail.objects.first()
110+
body = get_email_body(mail)
111+
assert "Patch One" in body
112+
assert "Patch Two" in body
113+
114+
115+
def test_multiple_authors_receive_separate_emails(alice, bob, in_progress_cf, topic):
116+
"""Each author of open patches should receive their own notification."""
117+
patch1 = Patch.objects.create(name="Alice Patch", topic=topic)
118+
patch1.authors.add(alice)
119+
PatchOnCommitFest.objects.create(
120+
patch=patch1,
121+
commitfest=in_progress_cf,
122+
enterdate=datetime.now(),
123+
status=PatchOnCommitFest.STATUS_REVIEW,
124+
)
125+
126+
patch2 = Patch.objects.create(name="Bob Patch", topic=topic)
127+
patch2.authors.add(bob)
128+
PatchOnCommitFest.objects.create(
129+
patch=patch2,
130+
commitfest=in_progress_cf,
131+
enterdate=datetime.now(),
132+
status=PatchOnCommitFest.STATUS_COMMITTER,
133+
)
134+
135+
in_progress_cf.send_closure_notifications()
136+
137+
assert QueuedMail.objects.count() == 2
138+
receivers = set(QueuedMail.objects.values_list("receiver", flat=True))
139+
assert receivers == {alice.email, bob.email}
140+
141+
142+
def test_notification_includes_next_commitfest_info(alice, in_progress_cf, open_cf, topic):
143+
"""Notification should include information about the next open commitfest."""
144+
patch = Patch.objects.create(name="Test Patch", topic=topic)
145+
patch.authors.add(alice)
146+
PatchOnCommitFest.objects.create(
147+
patch=patch,
148+
commitfest=in_progress_cf,
149+
enterdate=datetime.now(),
150+
status=PatchOnCommitFest.STATUS_REVIEW,
151+
)
152+
153+
in_progress_cf.send_closure_notifications()
154+
155+
mail = QueuedMail.objects.first()
156+
body = get_email_body(mail)
157+
assert open_cf.name in body
158+
assert f"https://commitfest.postgresql.org/{open_cf.id}/" in body
159+
160+
161+
def test_coauthors_both_receive_notification(alice, bob, in_progress_cf, topic):
162+
"""Both co-authors of a patch should receive notifications."""
163+
patch = Patch.objects.create(name="Coauthored Patch", topic=topic)
164+
patch.authors.add(alice)
165+
patch.authors.add(bob)
166+
PatchOnCommitFest.objects.create(
167+
patch=patch,
168+
commitfest=in_progress_cf,
169+
enterdate=datetime.now(),
170+
status=PatchOnCommitFest.STATUS_REVIEW,
171+
)
172+
173+
in_progress_cf.send_closure_notifications()
174+
175+
assert QueuedMail.objects.count() == 2
176+
receivers = set(QueuedMail.objects.values_list("receiver", flat=True))
177+
assert receivers == {alice.email, bob.email}
178+
179+
180+
def test_no_notification_for_author_without_email(bob, in_progress_cf, topic):
181+
"""Authors without email addresses should be skipped."""
182+
bob.email = ""
183+
bob.save()
184+
185+
patch = Patch.objects.create(name="Test Patch", topic=topic)
186+
patch.authors.add(bob)
187+
PatchOnCommitFest.objects.create(
188+
patch=patch,
189+
commitfest=in_progress_cf,
190+
enterdate=datetime.now(),
191+
status=PatchOnCommitFest.STATUS_REVIEW,
192+
)
193+
194+
in_progress_cf.send_closure_notifications()
195+
196+
assert QueuedMail.objects.count() == 0

0 commit comments

Comments
 (0)