Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit b9dddce

Browse files
JelteFpolobo
andcommittedJun 9, 2025·
Introduce Drafts page and automatic CF creation
This introduce a new type of CommitFest a "Draft" CommitFest. This CommitFest is never "In Progress", but it can be open. It exists for a year. It opens when the final regular CommitFest of the year becomes "In Progress" and stays open for exactly a year. It never becomes "In Progress" itself. Adding a second type of CommitFest also needed a redesign of quite a few things, like the homepage. Also management of the CommitFests needed to be made a bit easier, so admins don't forget to close/create Draft CommitFests. So now, closing/opening/creating CommitFests is done automatically when the time is right for that. A help page is also introduced to explain the CommitFest app. The naming of CommitFests has been changed too. Since we now have a Draft CF every year that needs a name, it seemed reasonable to align the names of the other CFs with that too. So each PG release cycle now has 5 regular commitfests that are called: - PG18-1 - PG18-2 - PG18-3 - PG18-4 - PG18-Final And a single Draft CommitFest, called: - PG18-Draft Finally, it also adds a small initial API endpoint for the CFBot, to request the commitfests that need CI runs. Future PRs will extend this API surface to also include/allow requesting the actual patches that CI should run on. Co-Authored-By: David G. Johnston <[email protected]>
1 parent d17a0a8 commit b9dddce

File tree

14 files changed

+776
-172
lines changed

14 files changed

+776
-172
lines changed
 

‎pgcommitfest/commitfest/apiv1.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from django.http import (
2+
HttpResponse,
3+
)
4+
5+
import json
6+
from datetime import date, datetime, timedelta, timezone
7+
8+
from .models import (
9+
CommitFest,
10+
)
11+
12+
13+
def datetime_serializer(obj):
14+
if isinstance(obj, date):
15+
return obj.isoformat()
16+
17+
if isinstance(obj, datetime):
18+
return obj.replace(tzinfo=timezone.utc).isoformat()
19+
20+
if hasattr(obj, "to_json"):
21+
return obj.to_json()
22+
23+
raise TypeError(f"Type {type(obj)} not serializable to JSON")
24+
25+
26+
def api_response(payload, status=200, content_type="application/json"):
27+
response = HttpResponse(
28+
json.dumps(payload, default=datetime_serializer), status=status
29+
)
30+
response["Content-Type"] = content_type
31+
response["Access-Control-Allow-Origin"] = "*"
32+
return response
33+
34+
35+
def commitfestst_that_need_ci(request):
36+
cfs = CommitFest.relevant_commitfests()
37+
38+
# We continue to run CI on the previous commitfest for a week after it ends
39+
# to give people some time to move patches over to the next one.
40+
if cfs["previous"].enddate <= datetime.now(timezone.utc).date() - timedelta(days=7):
41+
del cfs["previous"]
42+
43+
del cfs["next_open"]
44+
del cfs["final"]
45+
46+
return api_response({"commitfests": cfs})

‎pgcommitfest/commitfest/fixtures/auth_data.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,41 @@
8888
"groups": [],
8989
"user_permissions": []
9090
}
91+
},
92+
{
93+
"model": "auth.user",
94+
"pk": 6,
95+
"fields": {
96+
"password": "",
97+
"last_login": null,
98+
"is_superuser": false,
99+
"username": "prolific-author",
100+
"first_name": "Prolific",
101+
"last_name": "Author",
102+
"email": "",
103+
"is_staff": false,
104+
"is_active": true,
105+
"date_joined": "2025-01-01T00:00:00",
106+
"groups": [],
107+
"user_permissions": []
108+
}
109+
},
110+
{
111+
"model": "auth.user",
112+
"pk": 7,
113+
"fields": {
114+
"password": "",
115+
"last_login": null,
116+
"is_superuser": false,
117+
"username": "prolific-reviewer",
118+
"first_name": "Prolific",
119+
"last_name": "Reviewer",
120+
"email": "",
121+
"is_staff": false,
122+
"is_active": true,
123+
"date_joined": "2025-01-01T00:00:00",
124+
"groups": [],
125+
"user_permissions": []
126+
}
91127
}
92128
]

‎pgcommitfest/commitfest/fixtures/commitfest_data.json

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,40 +24,44 @@
2424
"model": "commitfest.commitfest",
2525
"pk": 1,
2626
"fields": {
27-
"name": "Sample Old Commitfest",
27+
"name": "PG18-3",
2828
"status": 4,
29-
"startdate": "2024-05-01",
30-
"enddate": "2024-05-31"
29+
"startdate": "2024-11-01",
30+
"enddate": "2024-11-30",
31+
"draft": false
3132
}
3233
},
3334
{
3435
"model": "commitfest.commitfest",
3536
"pk": 2,
3637
"fields": {
37-
"name": "Sample In Progress Commitfest",
38+
"name": "PG18-4",
3839
"status": 3,
3940
"startdate": "2025-01-01",
40-
"enddate": "2025-02-28"
41+
"enddate": "2025-01-31",
42+
"draft": false
4143
}
4244
},
4345
{
4446
"model": "commitfest.commitfest",
4547
"pk": 3,
4648
"fields": {
47-
"name": "Sample Open Commitfest",
49+
"name": "PG18-Final",
4850
"status": 2,
4951
"startdate": "2025-03-01",
50-
"enddate": "2025-03-31"
52+
"enddate": "2025-03-31",
53+
"draft": false
5154
}
5255
},
5356
{
5457
"model": "commitfest.commitfest",
5558
"pk": 4,
5659
"fields": {
57-
"name": "Sample Future Commitfest",
58-
"status": 1,
59-
"startdate": "2025-05-01",
60-
"enddate": "2025-05-31"
60+
"name": "PG18-Drafts",
61+
"status": 2,
62+
"startdate": "2024-03-01",
63+
"enddate": "2025-02-28",
64+
"draft": true
6165
}
6266
},
6367
{
@@ -237,6 +241,33 @@
237241
]
238242
}
239243
},
244+
{
245+
"model": "commitfest.patch",
246+
"pk": 8,
247+
"fields": {
248+
"name": "Test DGJ Multi-Author and Reviewer",
249+
"topic": 3,
250+
"wikilink": "",
251+
"gitlink": "",
252+
"targetversion": 1,
253+
"committer": 4,
254+
"created": "2025-02-01T00:00:00",
255+
"modified": "2025-02-01T00:00:00",
256+
"lastmail": "2025-02-01T00:00:00",
257+
"authors": [
258+
3,
259+
6
260+
],
261+
"reviewers": [
262+
1,
263+
7
264+
],
265+
"subscribers": [],
266+
"mailthread_set": [
267+
8
268+
]
269+
}
270+
},
240271
{
241272
"model": "commitfest.patchoncommitfest",
242273
"pk": 1,
@@ -325,6 +356,17 @@
325356
"status": 1
326357
}
327358
},
359+
{
360+
"model": "commitfest.patchoncommitfest",
361+
"pk": 9,
362+
"fields": {
363+
"patch": 8,
364+
"commitfest": 4,
365+
"enterdate": "2025-02-01T00:00:00",
366+
"leavedate": null,
367+
"status": 1
368+
}
369+
},
328370
{
329371
"model": "commitfest.patchhistory",
330372
"pk": 1,
@@ -632,6 +674,33 @@
632674
"latestmsgid": "example@message-31"
633675
}
634676
},
677+
{
678+
"model": "commitfest.mailthread",
679+
"pk": 8,
680+
"fields": {
681+
"messageid": "dgj-example@message-08",
682+
"subject": "Test DGJ Multi-Author and Reviewer",
683+
"firstmessage": "2025-02-01T00:00:00",
684+
"firstauthor": "test@test.com",
685+
"latestmessage": "2025-02-01T00:00:00",
686+
"latestauthor": "test@test.com",
687+
"latestsubject": "Test DGJ Multi-Author and Reviewer",
688+
"latestmsgid": "dgj-example@message-08"
689+
}
690+
},
691+
{
692+
"model": "commitfest.mailthreadattachment",
693+
"pk": 8,
694+
"fields": {
695+
"mailthread": 8,
696+
"messageid": "dgj-example@message-08",
697+
"attachmentid": 1,
698+
"filename": "v1-0001-content.patch",
699+
"date": "2025-02-01T00:00:00",
700+
"author": "test@test.com",
701+
"ispatch": true
702+
}
703+
},
635704
{
636705
"model": "commitfest.patchstatus",
637706
"pk": 1,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 4.2.19 on 2025-06-08 10:47
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("commitfest", "0010_add_failing_since_column"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="commitfest",
14+
name="draft",
15+
field=models.BooleanField(default=False),
16+
),
17+
migrations.AlterField(
18+
model_name="commitfest",
19+
name="status",
20+
field=models.IntegerField(
21+
choices=[(2, "Open"), (3, "In Progress"), (4, "Closed")], default=2
22+
),
23+
),
24+
]
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from django.db import migrations
2+
3+
4+
class Migration(migrations.Migration):
5+
dependencies = [
6+
("commitfest", "0011_add_draft_remove_future"),
7+
]
8+
operations = [
9+
migrations.RunSQL(
10+
"""
11+
CREATE UNIQUE INDEX cf_enforce_maxoneopen_idx
12+
ON commitfest_commitfest (status, draft)
13+
WHERE status not in (1,4);
14+
""",
15+
reverse_sql="""
16+
DROP INDEX IF EXISTS cf_enforce_maxoneopen_idx;
17+
""",
18+
),
19+
migrations.RunSQL(
20+
"""
21+
CREATE UNIQUE INDEX poc_enforce_maxoneoutcome_idx
22+
ON commitfest_patchoncommitfest (patch_id)
23+
WHERE status not in (5);
24+
""",
25+
reverse_sql="""
26+
DROP INDEX IF EXISTS poc_enforce_maxoneoutcome_idx;
27+
""",
28+
),
29+
migrations.RunSQL(
30+
"""
31+
ALTER TABLE commitfest_patchoncommitfest
32+
ADD CONSTRAINT status_and_leavedate_correlation
33+
CHECK ((status IN (4,5,6,7,8)) = (leavedate IS NOT NULL));
34+
""",
35+
reverse_sql="""
36+
ALTER TABLE commitfest_patchoncommitfest
37+
DROP CONSTRAINT IF EXISTS status_and_leavedate_correlation;
38+
""",
39+
),
40+
migrations.RunSQL(
41+
"""
42+
COMMENT ON COLUMN commitfest_patchoncommitfest.leavedate IS
43+
$$A leave date is recorded in two situations, both of which
44+
means this particular patch-cf combination became inactive
45+
on the corresponding date. For status 5 the patch was moved
46+
to some other cf. For 4,6,7, and 8, this was the final cf.
47+
$$
48+
""",
49+
reverse_sql="""
50+
COMMENT ON COLUMN commitfest_patchoncommitfest.leavedate IS NULL;
51+
""",
52+
),
53+
migrations.RunSQL(
54+
"""
55+
COMMENT ON TABLE commitfest_patchoncommitfest IS
56+
$$This is a re-entrant table: patches may become associated
57+
with a given cf multiple times, resetting the entrydate and clearing
58+
the leavedate each time. Non-final statuses never have a leavedate
59+
while final statuses always do. The final status of 5 (moved) is
60+
special in that all but one of the rows a patch has in this table
61+
must have it as the status.
62+
$$
63+
""",
64+
reverse_sql="""
65+
COMMENT ON TABLE commitfest_patchoncommitfest IS NULL;
66+
""",
67+
),
68+
]

‎pgcommitfest/commitfest/models.py

Lines changed: 261 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from django.contrib.auth.models import User
2-
from django.db import models
2+
from django.db import models, transaction
3+
from django.db.models import Q
34
from django.shortcuts import get_object_or_404
45

5-
from datetime import datetime
6+
from datetime import datetime, timedelta, timezone
67

78
from pgcommitfest.userprofile.models import UserProfile
89

@@ -34,47 +35,222 @@ class Meta:
3435

3536

3637
class CommitFest(models.Model):
37-
STATUS_FUTURE = 1
3838
STATUS_OPEN = 2
3939
STATUS_INPROGRESS = 3
4040
STATUS_CLOSED = 4
4141
_STATUS_CHOICES = (
42-
(STATUS_FUTURE, "Future"),
4342
(STATUS_OPEN, "Open"),
4443
(STATUS_INPROGRESS, "In Progress"),
4544
(STATUS_CLOSED, "Closed"),
4645
)
4746
_STATUS_LABELS = (
48-
(STATUS_FUTURE, "default"),
4947
(STATUS_OPEN, "info"),
5048
(STATUS_INPROGRESS, "success"),
5149
(STATUS_CLOSED, "danger"),
5250
)
5351
name = models.CharField(max_length=100, blank=False, null=False, unique=True)
5452
status = models.IntegerField(
55-
null=False, blank=False, default=1, choices=_STATUS_CHOICES
53+
null=False, blank=False, default=2, choices=_STATUS_CHOICES
5654
)
57-
startdate = models.DateField(blank=True, null=True)
58-
enddate = models.DateField(blank=True, null=True)
55+
startdate = models.DateField(blank=False, null=False)
56+
enddate = models.DateField(blank=False, null=False)
57+
draft = models.BooleanField(blank=False, null=False, default=False)
5958

6059
@property
6160
def statusstring(self):
6261
return [v for k, v in self._STATUS_CHOICES if k == self.status][0]
6362

6463
@property
6564
def periodstring(self):
66-
if self.startdate and self.enddate:
67-
return "{0} - {1}".format(self.startdate, self.enddate)
68-
return ""
65+
return "{0} - {1}".format(self.startdate, self.enddate)
66+
67+
@property
68+
def dev_cycle(self) -> int:
69+
if self.startdate.month in [1, 3]:
70+
return self.startdate.year - 2007
71+
else:
72+
return self.startdate.year - 2006
6973

7074
@property
7175
def title(self):
7276
return "Commitfest %s" % self.name
7377

7478
@property
75-
def isopen(self):
79+
def is_closed(self):
80+
return self.status == self.STATUS_CLOSED
81+
82+
@property
83+
def is_open(self):
7684
return self.status == self.STATUS_OPEN
7785

86+
@property
87+
def is_open_regular(self):
88+
return self.is_open and not self.draft
89+
90+
@property
91+
def is_open_draft(self):
92+
return self.is_open and self.draft
93+
94+
@property
95+
def is_in_progress(self):
96+
return self.status == self.STATUS_INPROGRESS
97+
98+
def to_json(self):
99+
return {
100+
"id": self.id,
101+
"name": self.name,
102+
"status": self.statusstring,
103+
"startdate": self.startdate.isoformat(),
104+
"enddate": self.enddate.isoformat(),
105+
}
106+
107+
@staticmethod
108+
def _are_relevant_commitfests_up_to_date(cfs, current_date):
109+
inprogress_cf = cfs["in_progress"]
110+
111+
if inprogress_cf and inprogress_cf.enddate < current_date:
112+
return False
113+
114+
if cfs["open"].startdate <= current_date:
115+
return False
116+
117+
if not cfs["draft"] or cfs["draft"].enddate < current_date:
118+
return False
119+
120+
return True
121+
122+
@classmethod
123+
def _refresh_relevant_commitfests(cls, for_update):
124+
cfs = CommitFest.relevant_commitfests(for_update=for_update, refresh=False)
125+
current_date = datetime.now(timezone.utc).date()
126+
127+
if cls._are_relevant_commitfests_up_to_date(cfs, current_date):
128+
return cfs
129+
130+
with transaction.atomic():
131+
cfs = CommitFest.relevant_commitfests(for_update=True, refresh=False)
132+
if cls._are_relevant_commitfests_up_to_date(cfs, current_date):
133+
# Some other request has already updated the commitfests, so we
134+
# return the new version
135+
return cfs
136+
137+
inprogress_cf = cfs["in_progress"]
138+
if inprogress_cf and inprogress_cf.enddate < current_date:
139+
inprogress_cf.status = CommitFest.STATUS_CLOSED
140+
inprogress_cf.save()
141+
142+
open_cf = cfs["open"]
143+
144+
if open_cf.startdate <= current_date:
145+
if open_cf.enddate < current_date:
146+
open_cf.status = CommitFest.STATUS_CLOSED
147+
else:
148+
open_cf.status = CommitFest.STATUS_INPROGRESS
149+
open_cf.save()
150+
151+
cls.next_open_cf(current_date).save()
152+
153+
draft_cf = cfs["draft"]
154+
if not draft_cf:
155+
cls.next_draft_cf(current_date).save()
156+
elif draft_cf.enddate < current_date:
157+
# If the draft commitfest has started, we need to update it
158+
draft_cf.status = CommitFest.STATUS_CLOSED
159+
draft_cf.save()
160+
cls.next_draft_cf(current_date).save()
161+
162+
return cls.relevant_commitfests(for_update=for_update)
163+
164+
@classmethod
165+
def relevant_commitfests(cls, for_update=False, refresh=True):
166+
if refresh:
167+
return cls._refresh_relevant_commitfests(for_update=for_update)
168+
169+
query_base = CommitFest.objects.order_by("-enddate")
170+
if for_update:
171+
query_base = query_base.select_for_update(no_key=True)
172+
last_three_commitfests = query_base.filter(draft=False)[:3]
173+
174+
cfs = {}
175+
cfs["open"] = last_three_commitfests[0]
176+
177+
if last_three_commitfests[1].status == CommitFest.STATUS_INPROGRESS:
178+
cfs["in_progress"] = last_three_commitfests[1]
179+
cfs["previous"] = last_three_commitfests[2]
180+
181+
else:
182+
cfs["in_progress"] = None
183+
cfs["previous"] = last_three_commitfests[1]
184+
if cfs["open"].startdate.month == 3:
185+
cfs["final"] = cfs["open"]
186+
187+
if cfs["in_progress"] and cfs["in_progress"].startdate.month == 3:
188+
cfs["final"] = cfs["in_progress"]
189+
elif cfs["open"].startdate.month == 3:
190+
cfs["final"] = cfs["open"]
191+
else:
192+
cfs["final"] = cls.next_open_cf(
193+
datetime(year=cfs["open"].dev_cycle + 2007, month=2, day=1)
194+
)
195+
196+
cfs["draft"] = query_base.filter(draft=True).order_by("-startdate").first()
197+
cfs["next_open"] = cls.next_open_cf(cfs["open"].enddate + timedelta(days=1))
198+
199+
return cfs
200+
201+
@staticmethod
202+
def next_open_cf(from_date):
203+
# We don't have a CF in december, so we don't need to worry about 12 mod 12 being 0
204+
cf_months = [7, 9, 11, 1, 3]
205+
next_open_cf_month = min(
206+
(month for month in cf_months if month > from_date.month), default=1
207+
)
208+
next_open_cf_year = from_date.year
209+
if next_open_cf_month == 1:
210+
next_open_cf_year += 1
211+
212+
next_open_dev_cycle = next_open_cf_year - 2006
213+
if next_open_cf_month in [1, 3]:
214+
next_open_dev_cycle -= 1
215+
216+
if next_open_cf_month == 3:
217+
name = f"PG{next_open_dev_cycle}-Final"
218+
else:
219+
cf_number = cf_months.index(next_open_cf_month) + 1
220+
name = f"PG{next_open_dev_cycle}-{cf_number}"
221+
start_date = datetime(
222+
year=next_open_cf_year, month=next_open_cf_month, day=1
223+
).date()
224+
end_date = datetime(
225+
year=next_open_cf_year, month=next_open_cf_month + 1, day=1
226+
).date() - timedelta(days=1)
227+
228+
return CommitFest(
229+
name=name,
230+
status=CommitFest.STATUS_OPEN,
231+
startdate=start_date,
232+
enddate=end_date,
233+
)
234+
235+
@staticmethod
236+
def next_draft_cf(start_date):
237+
dev_cycle = start_date.year - 2006
238+
if start_date.month < 3:
239+
dev_cycle -= 1
240+
241+
end_year = dev_cycle + 2007
242+
243+
name = f"PG{dev_cycle}-Drafts"
244+
end_date = datetime(year=end_year, month=3, day=1).date() - timedelta(days=1)
245+
246+
return CommitFest(
247+
name=name,
248+
status=CommitFest.STATUS_OPEN,
249+
startdate=start_date,
250+
enddate=end_date,
251+
draft=True,
252+
)
253+
78254
def __str__(self):
79255
return self.name
80256

@@ -102,6 +278,10 @@ def __str__(self):
102278
return self.version
103279

104280

281+
class UserInputError(ValueError):
282+
pass
283+
284+
105285
class Patch(models.Model, DiffableModel):
106286
name = models.CharField(
107287
max_length=500, blank=False, null=False, verbose_name="Description"
@@ -159,11 +339,15 @@ class Patch(models.Model, DiffableModel):
159339
}
160340

161341
def current_commitfest(self):
162-
return self.commitfests.order_by("-startdate").first()
342+
return self.current_patch_on_commitfest().commitfest
163343

164344
def current_patch_on_commitfest(self):
165-
cf = self.current_commitfest()
166-
return get_object_or_404(PatchOnCommitFest, patch=self, commitfest=cf)
345+
# The unique partial index poc_enforce_maxoneoutcome_idx stores the PoC
346+
# No caching here (inside the instance) since the caller should just need
347+
# the PoC once per request.
348+
return get_object_or_404(
349+
PatchOnCommitFest, Q(patch=self) & ~Q(status=PatchOnCommitFest.STATUS_MOVED)
350+
)
167351

168352
# Some accessors
169353
@property
@@ -208,6 +392,43 @@ def update_lastmail(self):
208392
else:
209393
self.lastmail = max(threads, key=lambda t: t.latestmessage).latestmessage
210394

395+
def move(self, from_cf, to_cf):
396+
current_poc = self.current_patch_on_commitfest()
397+
if from_cf.id != current_poc.commitfest.id:
398+
raise UserInputError("Patch not in source commitfest.")
399+
400+
if from_cf.id == to_cf.id:
401+
raise UserInputError("Source and target commitfest are the same.")
402+
403+
if current_poc.status not in (
404+
PatchOnCommitFest.STATUS_REVIEW,
405+
PatchOnCommitFest.STATUS_AUTHOR,
406+
PatchOnCommitFest.STATUS_COMMITTER,
407+
):
408+
raise UserInputError(
409+
f"Patch in state {current_poc.statusstring} cannot be moved."
410+
)
411+
412+
if not to_cf.is_open:
413+
raise UserInputError("Patch can only be moved to an open commitfest")
414+
415+
old_status = current_poc.status
416+
417+
current_poc.set_status(PatchOnCommitFest.STATUS_MOVED)
418+
419+
new_poc, _ = PatchOnCommitFest.objects.update_or_create(
420+
patch=current_poc.patch,
421+
commitfest=to_cf,
422+
defaults=dict(
423+
status=old_status,
424+
enterdate=datetime.now(),
425+
leavedate=None,
426+
),
427+
)
428+
new_poc.save()
429+
self.set_modified()
430+
self.save()
431+
211432
def __str__(self):
212433
return self.name
213434

@@ -224,7 +445,7 @@ class PatchOnCommitFest(models.Model):
224445
STATUS_AUTHOR = 2
225446
STATUS_COMMITTER = 3
226447
STATUS_COMMITTED = 4
227-
STATUS_NEXT = 5
448+
STATUS_MOVED = 5
228449
STATUS_REJECTED = 6
229450
STATUS_RETURNED = 7
230451
STATUS_WITHDRAWN = 8
@@ -233,7 +454,7 @@ class PatchOnCommitFest(models.Model):
233454
(STATUS_AUTHOR, "Waiting on Author"),
234455
(STATUS_COMMITTER, "Ready for Committer"),
235456
(STATUS_COMMITTED, "Committed"),
236-
(STATUS_NEXT, "Moved to next CF"),
457+
(STATUS_MOVED, "Moved to different CF"),
237458
(STATUS_REJECTED, "Rejected"),
238459
(STATUS_RETURNED, "Returned with feedback"),
239460
(STATUS_WITHDRAWN, "Withdrawn"),
@@ -243,7 +464,7 @@ class PatchOnCommitFest(models.Model):
243464
(STATUS_AUTHOR, "primary"),
244465
(STATUS_COMMITTER, "info"),
245466
(STATUS_COMMITTED, "success"),
246-
(STATUS_NEXT, "warning"),
467+
(STATUS_MOVED, "warning"),
247468
(STATUS_REJECTED, "danger"),
248469
(STATUS_RETURNED, "danger"),
249470
(STATUS_WITHDRAWN, "danger"),
@@ -273,10 +494,32 @@ def is_closed(self):
273494
def is_open(self):
274495
return not self.is_closed
275496

497+
@property
498+
def is_committed(self):
499+
return self.status == self.STATUS_COMMITTED
500+
501+
@property
502+
def needs_committer(self):
503+
return self.status == self.STATUS_COMMITTER
504+
276505
@property
277506
def statusstring(self):
278507
return [v for k, v in self._STATUS_CHOICES if k == self.status][0]
279508

509+
@classmethod
510+
def current_for_patch(cls, patch_id):
511+
return get_object_or_404(
512+
cls, Q(patch_id=patch_id) & ~Q(status=cls.STATUS_MOVED)
513+
)
514+
515+
def set_status(self, status):
516+
self.status = status
517+
self.leavedate = datetime.now() if not self.is_open else None
518+
self.patch.set_modified()
519+
520+
self.patch.save()
521+
self.save()
522+
280523
class Meta:
281524
unique_together = (
282525
(
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{%extends "base.html"%}
2+
{%block contents%}
3+
<ul>
4+
{%for c in commitfests%}
5+
<li><a href="/{{c.id}}/">{{c}}</a> ({{c.statusstring}} - {{c.periodstring}})</li>
6+
{%endfor%}
7+
</ul>
8+
<br/>
9+
{%endblock%}
10+
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{%extends "base.html"%}
2+
{%block contents%}
3+
<p>
4+
This is the "CommitFest app", the website where the PostgreSQL community tracks proposed changes to PostgreSQL. If you're familiar with GitHub, then this website fulfills a similar purpose to the list of Pull Requests (PRs) on GitHub repo. The most important difference is that the CommitFest app is not the "source of truth", instead the PostgreSQL mailinglists are. The "CommitFest app" is simply a tool to help the communtiy keep track of changes proposed on the mailinglist in an organized manner. Below are the most important concepts that you should know about when using the CommitFest app.
5+
</p>
6+
<h2>CommitFest</h2>
7+
<p>
8+
PostgreSQL development is organized into "CommitFests" (often abbreviated to "CF"). Each CommitFest contains a list of entries called <a href="#patch">patches</a> (similar to PRs, see below for details). The main purpose of CommitFests are to make sure patches that people are interested in are not forgotten about, as well as running CI (aka CFBot) on these patches. Each CommitFest has a period where it is "Open", in which people can add patches to the CommitFest. This "Open" period is followed by an "In Progress" period, in which the idea is that committers and reviewers focus on reviewing and committing the patches in this "In Progress" CommitFest. At the end of the month a CommitFest gets "Closed" (and stays closed forever). Any not yet committed patches can be moved to the following "Open" CommitFest by their authors, to try again in the next cycle. Having these timebound periods has several benefits:
9+
<ol>
10+
<li>It gives a regular cadence of development to people who like to have this.</li>
11+
<li>It provides a natural age-out mechanism for patches that the submitter has lost interest in.</li>
12+
<li>It provides an easy way for reviewers/committers to prioritize which patches to review.</li>
13+
</ol>
14+
This <b>does not</b> mean that patches are only committed during a CommitFest, nor that people will only respond on the mainlinglist to patches in the "In Progress" CommitFest.
15+
</p>
16+
<p>There are 5 CommitFests per year. The first one is "In Progress" in <em>July</em> and starts the nine months feature development cycle of PostgreSQL. The next three are "In Progress" in <em>September</em>, <em>November</em> and <em>January</em>. The last CommitFest of the feature development cycle is "In Progress" in <em>March</em>, and ends a when the feature freeze starts. The exact date of the feature freeze depends on the year, but it's usually in early April.</p>
17+
18+
<h2>Patches</h2>
19+
<p>
20+
A "patch" is a bit of an overloaded term in the PostgreSQL community. Email threads on the mailing list often contain "patch files" as attachments, such a file is often referred to as a "patch". A single email can even contain multiple related "patch files", which are called a "patchset". However, in the context of a CommitFest app a "patch" usually means a "patch entry" in the CommitFest app. Such a "patch entry" is a reference to a mailinglist thread on which change to PostgreSQL has been proposed, by someone sending an email that contain one or more "patch files". The CommitFest app will automatically detect new versions of the patch files and update the "patch entry" accordingly.
21+
</p>
22+
<p>
23+
There are three active categories of patch status:
24+
<ul>
25+
<li>Waiting on Author - the author needs to make changes</li>
26+
<li>Needs Reviewer - the patch needs guidance or a review</li>
27+
<li>Ready for Committer - the patch is ready to be committed</li>
28+
</ul>
29+
And there are three preferred inactive categories of patch status for when
30+
a patch has been resolved, either by being committed or not:
31+
<ul>
32+
<li>Committed - a committer has applied the patches to the git repo</li>
33+
<li>Withdrawn - the author has withdrawn the patch from consideration</li>
34+
<li>Rejected - a committer has decided that the patch should not be applied</li>
35+
</ul>
36+
</p>
37+
38+
<h2>Drafts</h2>
39+
<p>
40+
Appart from the regular CommitFests, there is also a "Drafts" CommitFest that is used to collect patches for new features that are not yet ready for general reviewing. There are various reasons why a patch might not be ready for general reviewing but the author still wants to track it publicly in the "CommitFest app", this is usually due to the combination of one of the following reasons:
41+
<ul>
42+
<li>The author has temporarily lost interest, but expects to come back in the future.</li>
43+
<li>The author does not want to forget abuot </li>
44+
<li>The author wants feedback from a specific subset of people before requesting general feedback</li>
45+
<li>The author wants to have CI run on the patch, while they are polishing it further</li>
46+
<li>The author would like to be notified when the patch is in need of a rebase</li>
47+
</ul>
48+
Like regular CommitFests, a Draft CommitFest also has an "Open" period and a "Closed" state, but it has no "In Progress" period. The "Open" period for a Draft CommitFest last lasts a year. When the feature freeze occurs for a PostgreSQL version (in early April), the Draft CommitFest for that PostgreSQL version is closed, and a new one is immediately opened for the next PostgreSQL release.
49+
</p>
50+
<p>Another difference between Draft CommitFests and regular CommitFests is that Draft CommitFests don't list resolved patches.</p>
51+
{%endblock%}
Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,82 @@
11
{%extends "base.html"%}
22
{%block contents%}
33
<p>
4-
{%if inprogresscf%}A commitfest is currently in progress: <a href="/{{inprogresscf.id}}/">{{inprogresscf}}</a>.{%endif%}
4+
First time user? <a href="/help">Here is a help page</a> for you to understand how this website works.
55
</p>
66
<p>
7-
Useful links that you can use and bookmark:
7+
<table class="table" style="table-layout: auto; width: auto;">
8+
<style>
9+
td:first-child {
10+
text-align: right;
11+
}
12+
</style>
13+
<thead>
14+
<tr>
15+
<th></th>
16+
<th>Details</th>
17+
<th>In Progress</th>
18+
</tr>
19+
</thead>
20+
<tbody>
21+
<tr>
22+
<td><strong>Open:</strong></td>
23+
<td><a href="/{{cfs.open.id}}/">{{cfs.open}}</a></td>
24+
<td>{{cfs.open.periodstring}}</td>
25+
</tr>
26+
<tr>
27+
<td><strong>In Progress:</strong></td>
28+
<td>
29+
{%if cfs.in_progress %}
30+
<a href="/{{cfs.in_progress.id}}/">{{cfs.in_progress}}</a>
31+
{%else%}
32+
<span class="text-muted">None in progress</span>
33+
{%endif%}
34+
</td>
35+
<td>
36+
{%if cfs.in_progress %}
37+
{{cfs.in_progress.periodstring}}
38+
{%endif%}
39+
</td>
40+
</tr>
41+
<tr>
42+
<td><strong>Previous:</strong></td>
43+
<td><a href="/{{cfs.previous.id}}/">{{cfs.previous}}</a></td>
44+
<td>{{cfs.previous.periodstring}}</td>
45+
</tr>
46+
<tr>
47+
<td><strong>Draft:</strong></td>
48+
<td><a href="/{{cfs.draft.id}}/">{{cfs.draft}}</a></td>
49+
<td>{{cfs.draft.periodstring}}</td>
50+
</tr>
51+
<tr>
52+
<td><strong>Next open:</strong></td>
53+
<td>{{cfs.next_open}}</td>
54+
<td>{{cfs.next_open.periodstring}}</td>
55+
</tr>
56+
<tr>
57+
<td><strong>Final of this release:</strong></td>
58+
<td>
59+
{%if cfs.final.id %}
60+
<a href="/{{cfs.final.id}}/">{{cfs.final}}</a>
61+
{%else%}
62+
{{cfs.final}}
63+
{%endif%}
64+
</td>
65+
<td>
66+
{{cfs.final.periodstring}}
67+
</td>
68+
</tr>
69+
</tbody>
70+
</table>
871
</p>
72+
<h3>Search</h3>
73+
<form method="GET" action="/search/" class="form-inline">
74+
<div class="form-group">
75+
<input type="text" class="form-control" id="searchterm" name="searchterm" placeholder="Email Message-ID or keywords">
76+
</div>
77+
<button type="submit" class="btn btn-default">Search</button>
78+
</form>
79+
<h3>Useful pages (bookmarkable)</h3>
980
<ul>
1081
<li><a href="/me/">Your personal dashboard</a></li>
1182
<li><a href="/current/">All patches in the current commitfest</a></li>
@@ -14,19 +85,7 @@
1485
<li><a href="/current/?author=-3">Your entries in the current commitfest</a></li>
1586
<li><a href="/open/?author=-3">Your entries in the open commitfest</a></li>
1687
<li><a href="/current/?reviewer=-3">Entries that you are reviewing in current commitfest</a></li>
17-
</ul>
18-
<h3>Commands</h3>
19-
<form method="GET" action="/search/" class="form-inline">
20-
<div class="form-group">
21-
<input type="text" class="form-control" id="searchterm" name="searchterm" placeholder="Global search">
22-
</div>
23-
<button type="submit" class="btn btn-default">Search</button>
24-
</form>
25-
<h3>List of commitfests</h3>
26-
<ul>
27-
{%for c in commitfests%}
28-
<li><a href="/{{c.id}}/">{{c}}</a> ({{c.statusstring}}{%if c.startdate%} - {{c.periodstring}}{%endif%})</li>
29-
{%endfor%}
88+
<li><a href="/commitfest_history/">A list of all commitfests</a></li>
3089
</ul>
3190
<br/>
3291
{%endblock%}

‎pgcommitfest/commitfest/templates/me.html

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22
{%load commitfest %}
33
{%block contents%}
44
<a class="btn btn-default" href="/open/new/">New patch</a>
5-
<a class="btn btn-default" href="/current/">Current commitfest</a></li>
6-
<a class="btn btn-default" href="/open/">Open commitfest</a></li>
5+
{%if cfs.in_progress%}
6+
<a class="btn btn-default" href="/{{cfs.in_progress.id}}/">In Progress commitfest</a></li>
7+
{%endif%}
8+
<a class="btn btn-default" href="/{{cfs.open.id}}/">Open commitfest</a></li>
9+
{%if cfs.draft%}
10+
<a class="btn btn-default" href="/{{cfs.draft.id}}/">Draft commitfest</a></li>
11+
{%endif%}
712
<button type="button" class="btn btn-default{%if has_filter%} active{%endif%}" id="filterButton" onClick="togglePatchFilterButton('filterButton', 'collapseFilters')">Search/filter</button>
813
<form method="GET" action="/search/" class="form-inline search-bar pull-right">
914
<div class="form-group">

‎pgcommitfest/commitfest/templates/patch_commands.inc

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,28 @@
2121
<li role="presentation"><a href="close/reject/" onclick="return verify_reject()">Rejected</a></li>
2222
<li role="presentation"><a href="close/withdrawn/" onclick="return verify_withdrawn()">Withdrawn</a></li>
2323
<li role="presentation"><a href="close/feedback/" onclick="return verify_returned()">Returned with feedback</a></li>
24-
<li role="presentation"><a href="close/next/?cfid={{cf.id}}" onclick="return verify_next()">Move to next CF</a></li>
2524
<li role="presentation"><a href="close/committed/" onclick="return flagCommitted({%if patch.committer%}'{{patch.committer}}'{%elif is_committer%}'{{user.username}}'{%else%}null{%endif%})">Committed</a></li>
25+
<li role="presentation" class="divider"></li>
26+
<li role="presentation" class="dropdown-header">Move to</li>
27+
{%if not cf.is_open_regular %}
28+
<li role="presentation">
29+
<a
30+
href="move/?from_cf_id={{cf.id}}&to_cf_id={{cfs.open.id}}">
31+
Next CF: {{cfs.open.name}}</a>
32+
</li>
33+
{%endif%}
34+
{%if not cf.is_open_draft %}
35+
<li role="presentation">
36+
<a
37+
href="move/?from_cf_id={{cf.id}}&to_cf_id={{cfs.draft.id}}">
38+
{%if cf.draft %}
39+
Next Drafts:
40+
{%else%}
41+
Draft:
42+
{%endif%}
43+
{{cfs.draft.name}}</a>
44+
</li>
45+
{%endif%}
2646
</ul>
2747
</div>
2848

‎pgcommitfest/commitfest/templatetags/commitfest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def commitfeststatusstring(value):
2424
@stringfilter
2525
def commitfeststatuslabel(value):
2626
i = int(value)
27+
print(i, CommitFest._STATUS_LABELS)
2728
return [v for k, v in CommitFest._STATUS_LABELS if k == i][0]
2829

2930

‎pgcommitfest/commitfest/views.py

Lines changed: 91 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -42,30 +42,50 @@
4242
Patch,
4343
PatchHistory,
4444
PatchOnCommitFest,
45+
UserInputError,
4546
)
4647

4748

4849
def home(request):
49-
commitfests = list(CommitFest.objects.all())
50-
opencf = next((c for c in commitfests if c.status == CommitFest.STATUS_OPEN), None)
51-
inprogresscf = next(
52-
(c for c in commitfests if c.status == CommitFest.STATUS_INPROGRESS), None
53-
)
50+
cfs = CommitFest.relevant_commitfests()
5451

5552
return render(
5653
request,
5754
"home.html",
5855
{
59-
"commitfests": commitfests,
60-
"opencf": opencf,
61-
"inprogresscf": inprogresscf,
56+
"cfs": cfs,
6257
"title": "Commitfests",
6358
"header_activity": "Activity log",
6459
"header_activity_link": "/activity/",
6560
},
6661
)
6762

6863

64+
def commitfest_history(request):
65+
cfs = list(CommitFest.objects.order_by("-enddate"))
66+
67+
return render(
68+
request,
69+
"all_commitfests.html",
70+
{
71+
"commitfests": cfs,
72+
"title": "Commitfest history",
73+
"header_activity": "Activity log",
74+
"header_activity_link": "/activity/",
75+
},
76+
)
77+
78+
79+
def help(request):
80+
return render(
81+
request,
82+
"help.html",
83+
{
84+
"title": "What is the CommitFest app?",
85+
},
86+
)
87+
88+
6989
@login_required
7090
def me(request):
7191
cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_INPROGRESS))
@@ -128,6 +148,7 @@ def me(request):
128148
"header_activity": "Activity log",
129149
"header_activity_link": "/activity/",
130150
"userprofile": getattr(request.user, "userprofile", UserProfile()),
151+
"cfs": CommitFest.relevant_commitfests(),
131152
},
132153
)
133154

@@ -658,7 +679,7 @@ def patch(request, patchid):
658679
patch_commitfests = (
659680
PatchOnCommitFest.objects.select_related("commitfest")
660681
.filter(patch=patch)
661-
.order_by("-commitfest__startdate")
682+
.order_by("-enterdate")
662683
.all()
663684
)
664685
cf = patch_commitfests[0].commitfest
@@ -708,6 +729,7 @@ def patch(request, patchid):
708729
{"title": cf.title, "href": "/%s/" % cf.pk},
709730
],
710731
"userprofile": getattr(request.user, "userprofile", UserProfile()),
732+
"cfs": CommitFest.relevant_commitfests(),
711733
},
712734
)
713735

@@ -972,22 +994,16 @@ def status(request, patchid, status):
972994
patch__id=patchid,
973995
)
974996

975-
if status == "review":
976-
newstatus = PatchOnCommitFest.STATUS_REVIEW
977-
elif status == "author":
978-
newstatus = PatchOnCommitFest.STATUS_AUTHOR
979-
elif status == "committer":
980-
newstatus = PatchOnCommitFest.STATUS_COMMITTER
981-
else:
982-
raise Exception("Can't happen")
997+
status_mapping = {
998+
"review": PatchOnCommitFest.STATUS_REVIEW,
999+
"author": PatchOnCommitFest.STATUS_AUTHOR,
1000+
"committer": PatchOnCommitFest.STATUS_COMMITTER,
1001+
}
9831002

984-
if newstatus != poc.status:
985-
# Only save it if something actually changed
986-
poc.status = newstatus
987-
poc.patch.set_modified()
988-
poc.patch.save()
989-
poc.save()
1003+
new_status = status_mapping[status]
9901004

1005+
if new_status != poc.status:
1006+
poc.set_status(new_status)
9911007
PatchHistory(
9921008
patch=poc.patch, by=request.user, what="New status: %s" % poc.statusstring
9931009
).save_and_notify()
@@ -998,6 +1014,9 @@ def status(request, patchid, status):
9981014
@login_required
9991015
@transaction.atomic
10001016
def close(request, patchid, status):
1017+
if status == "next":
1018+
raise Exception("Can't happen, use transition/ endpoint")
1019+
10011020
patch = get_object_or_404(Patch.objects.select_related(), pk=patchid)
10021021
cf = patch.current_commitfest()
10031022

@@ -1015,106 +1034,15 @@ def close(request, patchid, status):
10151034
request,
10161035
"The patch was moved to a new commitfest by someone else. Please double check if you still want to retry this operation.",
10171036
)
1018-
return HttpResponseRedirect("/%s/%s/" % (cf.id, patch.id))
1037+
return HttpResponseRedirect(f"/patch/{patch.id}/")
10191038

10201039
poc = get_object_or_404(
10211040
PatchOnCommitFest.objects.select_related(),
10221041
commitfest__id=cf.id,
10231042
patch__id=patchid,
10241043
)
10251044

1026-
poc.leavedate = datetime.now()
1027-
1028-
# We know the status can't be one of the ones below, since we
1029-
# have checked that we're not closed yet. Therefor, we don't
1030-
# need to check if the individual status has changed.
1031-
if status == "reject":
1032-
poc.status = PatchOnCommitFest.STATUS_REJECTED
1033-
elif status == "withdrawn":
1034-
poc.status = PatchOnCommitFest.STATUS_WITHDRAWN
1035-
elif status == "feedback":
1036-
poc.status = PatchOnCommitFest.STATUS_RETURNED
1037-
elif status == "next":
1038-
# Only some patch statuses can actually be moved.
1039-
if poc.status in (
1040-
PatchOnCommitFest.STATUS_COMMITTED,
1041-
PatchOnCommitFest.STATUS_NEXT,
1042-
PatchOnCommitFest.STATUS_RETURNED,
1043-
PatchOnCommitFest.STATUS_REJECTED,
1044-
):
1045-
# Can't be moved!
1046-
messages.error(
1047-
request,
1048-
"A patch in status {0} cannot be moved to next commitfest.".format(
1049-
poc.statusstring
1050-
),
1051-
)
1052-
return HttpResponseRedirect("/%s/%s/" % (poc.commitfest.id, poc.patch.id))
1053-
elif poc.status in (
1054-
PatchOnCommitFest.STATUS_REVIEW,
1055-
PatchOnCommitFest.STATUS_AUTHOR,
1056-
PatchOnCommitFest.STATUS_COMMITTER,
1057-
):
1058-
# This one can be moved
1059-
pass
1060-
else:
1061-
messages.error(request, "Invalid existing patch status")
1062-
1063-
oldstatus = poc.status
1064-
1065-
poc.status = PatchOnCommitFest.STATUS_NEXT
1066-
# Figure out the commitfest to actually put it on
1067-
newcf = CommitFest.objects.filter(status=CommitFest.STATUS_OPEN)
1068-
if len(newcf) == 0:
1069-
# Ok, there is no open CF at all. Let's see if there is a
1070-
# future one.
1071-
newcf = CommitFest.objects.filter(status=CommitFest.STATUS_FUTURE)
1072-
if len(newcf) == 0:
1073-
messages.error(request, "No open and no future commitfest exists!")
1074-
return HttpResponseRedirect(
1075-
"/%s/%s/" % (poc.commitfest.id, poc.patch.id)
1076-
)
1077-
elif len(newcf) != 1:
1078-
messages.error(
1079-
request, "No open and multiple future commitfests exist!"
1080-
)
1081-
return HttpResponseRedirect(
1082-
"/%s/%s/" % (poc.commitfest.id, poc.patch.id)
1083-
)
1084-
elif len(newcf) != 1:
1085-
messages.error(request, "Multiple open commitfests exists!")
1086-
return HttpResponseRedirect("/%s/%s/" % (poc.commitfest.id, poc.patch.id))
1087-
elif newcf[0] == poc.commitfest:
1088-
# The current open CF is the same one that we are already on.
1089-
# In this case, try to see if there is a future CF we can
1090-
# move it to.
1091-
newcf = CommitFest.objects.filter(status=CommitFest.STATUS_FUTURE)
1092-
if len(newcf) == 0:
1093-
messages.error(
1094-
request,
1095-
"Cannot move patch to the same commitfest, and no future commitfests exist!",
1096-
)
1097-
return HttpResponseRedirect(
1098-
"/%s/%s/" % (poc.commitfest.id, poc.patch.id)
1099-
)
1100-
elif len(newcf) != 1:
1101-
messages.error(
1102-
request,
1103-
"Cannot move patch to the same commitfest, and multiple future commitfests exist!",
1104-
)
1105-
return HttpResponseRedirect(
1106-
"/%s/%s/" % (poc.commitfest.id, poc.patch.id)
1107-
)
1108-
# Create a mapping to the new commitfest that we are bouncing
1109-
# this patch to.
1110-
newpoc = PatchOnCommitFest(
1111-
patch=poc.patch,
1112-
commitfest=newcf[0],
1113-
status=oldstatus,
1114-
enterdate=datetime.now(),
1115-
)
1116-
newpoc.save()
1117-
elif status == "committed":
1045+
if status == "committed":
11181046
committer = get_object_or_404(Committer, user__username=request.GET["c"])
11191047
if committer != poc.patch.committer:
11201048
# Committer changed!
@@ -1126,12 +1054,14 @@ def close(request, patchid, status):
11261054
what="Changed committer to %s" % committer,
11271055
).save_and_notify(prevcommitter=prevcommitter)
11281056
poc.status = PatchOnCommitFest.STATUS_COMMITTED
1129-
else:
1130-
raise Exception("Can't happen")
11311057

1132-
poc.patch.set_modified()
1133-
poc.patch.save()
1134-
poc.save()
1058+
status_mapping = {
1059+
"reject": PatchOnCommitFest.STATUS_REJECTED,
1060+
"withdrawn": PatchOnCommitFest.STATUS_WITHDRAWN,
1061+
"feedback": PatchOnCommitFest.STATUS_RETURNED,
1062+
"committed": PatchOnCommitFest.STATUS_COMMITTED,
1063+
}
1064+
poc.set_status(status_mapping[status])
11351065

11361066
PatchHistory(
11371067
patch=poc.patch,
@@ -1140,7 +1070,46 @@ def close(request, patchid, status):
11401070
% (poc.commitfest, poc.statusstring),
11411071
).save_and_notify()
11421072

1143-
return HttpResponseRedirect("/%s/%s/" % (poc.commitfest.id, poc.patch.id))
1073+
return HttpResponseRedirect(f"/patch/{patchid}")
1074+
1075+
1076+
def int_param_or_none(request, param):
1077+
"""Helper function to convert a string to an int or return None."""
1078+
try:
1079+
return int(request.GET.get(param, ""))
1080+
except ValueError:
1081+
return None
1082+
1083+
1084+
@login_required
1085+
@transaction.atomic
1086+
def move(request, patchid):
1087+
from_cf_id = int_param_or_none(request, "from_cf_id")
1088+
to_cf_id = int_param_or_none(request, "to_cf_id")
1089+
if from_cf_id is None or to_cf_id is None:
1090+
messages.error(
1091+
request,
1092+
"Invalid or missing from_cf_id or to_cf_id GET parameter",
1093+
)
1094+
return HttpResponseRedirect(f"/patch/{patchid}/")
1095+
1096+
from_cf = get_object_or_404(CommitFest, pk=from_cf_id)
1097+
to_cf = get_object_or_404(CommitFest, pk=to_cf_id)
1098+
1099+
patch = get_object_or_404(Patch, pk=patchid)
1100+
try:
1101+
patch.move(from_cf, to_cf)
1102+
except UserInputError as e:
1103+
messages.error(request, f"Failed to move patch: {e}")
1104+
return HttpResponseRedirect(f"/patch/{patchid}/")
1105+
1106+
PatchHistory(
1107+
patch=patch,
1108+
by=request.user,
1109+
what=f"Moved from CF {from_cf} to CF {to_cf}",
1110+
).save_and_notify()
1111+
1112+
return HttpResponseRedirect(f"/patch/{patchid}/")
11441113

11451114

11461115
@login_required

‎pgcommitfest/urls.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import pgcommitfest.auth
55
import pgcommitfest.commitfest.ajax as ajax
6+
import pgcommitfest.commitfest.apiv1 as apiv1
67
import pgcommitfest.commitfest.lookups as lookups
78
import pgcommitfest.commitfest.reports as reports
89
import pgcommitfest.commitfest.views as views
@@ -15,6 +16,9 @@
1516

1617
urlpatterns = [
1718
re_path(r"^$", views.home),
19+
re_path(r"^api/v1/commitfests/needs_ci$", apiv1.commitfestst_that_need_ci),
20+
re_path(r"^help/$", views.help),
21+
re_path(r"^commitfest_history/$", views.commitfest_history),
1822
re_path(r"^me/$", views.me),
1923
re_path(r"^archive/$", views.archive),
2024
re_path(r"^activity(?P<rss>\.rss)?/", views.activity),
@@ -26,9 +30,8 @@
2630
re_path(r"^patch/(\d+)/edit/$", views.patchform),
2731
re_path(r"^(\d+)/new/$", views.newpatch),
2832
re_path(r"^patch/(\d+)/status/(review|author|committer)/$", views.status),
29-
re_path(
30-
r"^patch/(\d+)/close/(reject|withdrawn|feedback|committed|next)/$", views.close
31-
),
33+
re_path(r"^patch/(\d+)/close/(reject|withdrawn|feedback|committed)/$", views.close),
34+
re_path(r"^patch/(\d+)/move/$", views.move),
3235
re_path(r"^patch/(\d+)/reviewer/(become|remove)/$", views.reviewer),
3336
re_path(r"^patch/(\d+)/committer/(become|remove)/$", views.committer),
3437
re_path(r"^patch/(\d+)/(un)?subscribe/$", views.subscribe),

0 commit comments

Comments
 (0)
Please sign in to comment.