diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2beb0666..232eb040 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,3 +33,37 @@ jobs: - name: Run djhtml run: djhtml pgcommitfest/*/templates/*.html pgcommitfest/*/templates/*.inc --tabwidth=1 --check + + test: + runs-on: ubuntu-24.04 + name: "Django Tests" + + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python with uv + uses: astral-sh/setup-uv@v4 + + - name: Create CI settings + run: cp pgcommitfest/local_settings_example.py pgcommitfest/local_settings.py + + - name: Install dependencies + run: uv sync --extra dev + + - name: Run tests + run: uv run pytest --create-db diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index bd660ac8..810ed81a 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -11,7 +11,7 @@ on: jobs: deployment: runs-on: ubuntu-latest - environment: ${{ startsWith(github.ref, 'refs/tags/') && 'main' || github.ref_name }} + environment: ${{ startsWith(github.ref, 'refs/tags/') && 'prod' || github.ref_name }} steps: - name: Trigger deploy run: | diff --git a/media/commitfest/css/commitfest.css b/media/commitfest/css/commitfest.css index 8bce3826..21cbb2d6 100644 --- a/media/commitfest/css/commitfest.css +++ b/media/commitfest/css/commitfest.css @@ -222,3 +222,37 @@ a:hover .badge { width: 300px; } } + +/* Allow long text to wrap in table cells */ +.table td.wrap-text { + overflow-wrap: break-word; + word-break: break-word; + min-width: 100px; + max-width: 300px; +} + +.table td.patch-column { + min-width: 150px; +} + +/* Make the tags selectize dropdown taller with bottom padding for overlay */ +#id_tags + .selectize-control .selectize-dropdown-content, +#id_tag + .selectize-control .selectize-dropdown-content { + max-height: 325px; + padding-bottom: 2.5em; +} + +/* Gradient fade overlay for scroll hint - shows items through transparent top */ +.selectize-scroll-hint { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient( + to bottom, + rgba(248, 249, 250, 0) 0%, + rgba(248, 249, 250, 1) 50% + ) !important; + border-top: none !important; + pointer-events: none; +} diff --git a/media/commitfest/formatting_failure.svg b/media/commitfest/formatting_failure.svg new file mode 100644 index 00000000..e46c84bc --- /dev/null +++ b/media/commitfest/formatting_failure.svg @@ -0,0 +1,5 @@ + diff --git a/pgcommitfest/auth.py b/pgcommitfest/auth.py index c3756902..06cc609c 100644 --- a/pgcommitfest/auth.py +++ b/pgcommitfest/auth.py @@ -211,7 +211,10 @@ def auth_receive(request): # Finally, check of we have a data package that tells us where to # redirect the user. if 'd' in data: - (nonces, datas, tags) = data['d'][0].split('$') + splitdata = data['d'][0].split('$') + if len(splitdata) != 3: + return HttpResponse("Invalid login pass-through data received, likely because of an old link. Please try again.") + (nonces, datas, tags) = splitdata decryptor = AES.new( SHA256.new(settings.SECRET_KEY.encode('ascii')).digest()[:32], AES.MODE_SIV, diff --git a/pgcommitfest/commitfest/admin.py b/pgcommitfest/commitfest/admin.py index 90d54c4a..2982c223 100644 --- a/pgcommitfest/commitfest/admin.py +++ b/pgcommitfest/commitfest/admin.py @@ -32,6 +32,16 @@ class PatchAdmin(admin.ModelAdmin): list_display = ("name",) +class MailThreadAdmin(admin.ModelAdmin): + list_display = ( + "messageid", + "subject", + "firstmessage", + "latestmsgid", + "latestmessage", + ) + + class MailThreadAttachmentAdmin(admin.ModelAdmin): list_display = ( "date", @@ -68,5 +78,5 @@ class TagAdmin(admin.ModelAdmin): admin.site.register(CfbotBranch) admin.site.register(CfbotTask) -admin.site.register(MailThread) +admin.site.register(MailThread, MailThreadAdmin) admin.site.register(MailThreadAttachment, MailThreadAttachmentAdmin) diff --git a/pgcommitfest/commitfest/ajax.py b/pgcommitfest/commitfest/ajax.py index 329a83f9..8137dfdf 100644 --- a/pgcommitfest/commitfest/ajax.py +++ b/pgcommitfest/commitfest/ajax.py @@ -114,17 +114,23 @@ def refresh_single_thread(thread): ) if thread.latestmsgid != r[-1]["msgid"]: # There is now a newer mail in the thread! + parse_and_add_attachments(r, thread) + # Potentially update the last mail date - if there wasn't already a mail on each patch + # from a *different* thread that had an earlier date. + new_latestmessage = r[-1]["date"] + for p in thread.patches.filter(lastmail__lt=new_latestmessage): + p.lastmail = new_latestmessage + p.save() + + # Finally, we update the thread entry itself. We should only do that at + # the end, in case any of the previous steps fail. Then we'll retry on + # the next run. We could also do this in a transaction, but that would + # mean we lock a bunch of rows for potentially a long time. thread.latestmsgid = r[-1]["msgid"] thread.latestmessage = r[-1]["date"] thread.latestauthor = r[-1]["from"] thread.latestsubject = r[-1]["subj"] thread.save() - parse_and_add_attachments(r, thread) - # Potentially update the last mail date - if there wasn't already a mail on each patch - # from a *different* thread that had an earlier date. - for p in thread.patches.filter(lastmail__lt=thread.latestmessage): - p.lastmail = thread.latestmessage - p.save() @transaction.atomic diff --git a/pgcommitfest/commitfest/apiv1.py b/pgcommitfest/commitfest/apiv1.py index a90ba4d3..86a53062 100644 --- a/pgcommitfest/commitfest/apiv1.py +++ b/pgcommitfest/commitfest/apiv1.py @@ -1,22 +1,26 @@ from django.http import ( HttpResponse, ) +from django.shortcuts import get_object_or_404 import json from datetime import date, datetime, timedelta, timezone from .models import ( CommitFest, + Patch, + PatchOnCommitFest, ) def datetime_serializer(obj): - if isinstance(obj, date): - return obj.isoformat() - + # datetime must be checked before date, since datetime is a subclass of date if isinstance(obj, datetime): return obj.replace(tzinfo=timezone.utc).isoformat() + if isinstance(obj, date): + return obj.isoformat() + if hasattr(obj, "to_json"): return obj.to_json() @@ -44,3 +48,70 @@ def commitfestst_that_need_ci(request): del cfs["final"] return api_response({"commitfests": cfs}) + + +def commitfest_patches(request, cfid): + """Return all patches for a commitfest. + + This endpoint provides the data that cfbot previously scraped from the + commitfest HTML page. + """ + cf = get_object_or_404(CommitFest, pk=cfid) + + pocs = ( + PatchOnCommitFest.objects.filter(commitfest=cf) + .select_related("patch") + .prefetch_related("patch__authors") + .order_by("patch__id") + ) + + patches = [] + for poc in pocs: + patch = poc.patch + authors = [f"{a.first_name} {a.last_name}" for a in patch.authors.all()] + patches.append( + { + "id": patch.id, + "name": patch.name, + "status": poc.statusstring, + "authors": authors, + "last_email_time": patch.lastmail, + } + ) + + return api_response( + { + "commitfest_id": cf.id, + "patches": patches, + } + ) + + +def patch_threads(request, patch_id): + """Return thread information for a patch. + + This endpoint provides the data that cfbot previously scraped from the + patch HTML page to construct thread URLs. + """ + patch = get_object_or_404(Patch, pk=patch_id) + + threads = [] + for thread in patch.mailthread_set.all(): + latest_attachment = thread.mailthreadattachment_set.first() + threads.append( + { + "messageid": thread.messageid, + "subject": thread.subject, + "latest_message_id": thread.latestmsgid, + "latest_message_time": thread.latestmessage, + "has_attachment": latest_attachment is not None, + } + ) + + return api_response( + { + "patch_id": patch.id, + "name": patch.name, + "threads": threads, + } + ) diff --git a/pgcommitfest/commitfest/feeds.py b/pgcommitfest/commitfest/feeds.py index 9aff9025..8a1250df 100644 --- a/pgcommitfest/commitfest/feeds.py +++ b/pgcommitfest/commitfest/feeds.py @@ -30,12 +30,7 @@ def item_description(self, item): ) def item_link(self, item): - if self.cfid: - return "https://commitfest.postgresql.org/{0}/{1}/".format( - self.cfid, item["patchid"] - ) - else: - return "https://commitfest.postgresql.org/{cfid}/{patchid}/".format(**item) + return "https://commitfest.postgresql.org/patch/{patchid}/".format(**item) def item_pubdate(self, item): return item["date"] diff --git a/pgcommitfest/commitfest/fixtures/commitfest_data.json b/pgcommitfest/commitfest/fixtures/commitfest_data.json index 0c72d3db..eac5a329 100644 --- a/pgcommitfest/commitfest/fixtures/commitfest_data.json +++ b/pgcommitfest/commitfest/fixtures/commitfest_data.json @@ -1106,19 +1106,19 @@ "fields": { "branch_id": 345, "branch_name": "cf/2", - "commit_id": null, + "commit_id": "def456", "apply_url": "http://cfbot.cputube.org/patch_4573.log", - "status": "failed", - "needs_rebase_since": "2025-03-01T22:30:42", - "failing_since": "2025-02-01T22:30:42", + "status": "finished", + "needs_rebase_since": null, + "failing_since": null, "created": "2025-01-26T22:11:09.961", "modified": "2025-03-01T22:59:14.717", - "version": "", - "patch_count": null, - "first_additions": null, - "first_deletions": null, - "all_additions": null, - "all_deletions": null + "version": "v2", + "patch_count": 3, + "first_additions": 50, + "first_deletions": 10, + "all_additions": 120, + "all_deletions": 35 } }, { @@ -1295,5 +1295,119 @@ "created": "2025-07-03T06:29:25.086", "modified": "2025-07-03T06:29:25.086" } +}, +{ + "model": "commitfest.cfbottask", + "pk": 9, + "fields": { + "task_id": "12347", + "task_name": "FormattingCheck", + "patch": 1, + "branch_id": 123, + "position": 3, + "status": "FAILED", + "created": "2025-01-26T22:08:00.000", + "modified": "2025-01-26T22:08:30.000" + } +}, +{ + "model": "commitfest.cfbottask", + "pk": 10, + "fields": { + "task_id": "34501", + "task_name": "Linux build", + "patch": 2, + "branch_id": 345, + "position": 1, + "status": "COMPLETED", + "created": "2025-01-26T22:11:30.000", + "modified": "2025-01-26T22:12:15.000" + } +}, +{ + "model": "commitfest.cfbottask", + "pk": 11, + "fields": { + "task_id": "34502", + "task_name": "MacOS Build", + "patch": 2, + "branch_id": 345, + "position": 2, + "status": "COMPLETED", + "created": "2025-01-26T22:11:35.000", + "modified": "2025-01-26T22:13:20.000" + } +}, +{ + "model": "commitfest.cfbottask", + "pk": 12, + "fields": { + "task_id": "34503", + "task_name": "FormattingCheck", + "patch": 2, + "branch_id": 345, + "position": 3, + "status": "COMPLETED", + "created": "2025-01-26T22:11:40.000", + "modified": "2025-01-26T22:12:00.000" + } +}, +{ + "model": "commitfest.patch", + "pk": 9, + "fields": { + "name": "Abandoned patch in closed commitfest", + "topic": 3, + "wikilink": "", + "gitlink": "", + "targetversion": null, + "committer": null, + "created": "2024-10-15T10:00:00", + "modified": "2024-10-15T10:00:00", + "lastmail": "2024-10-01T10:00:00", + "tags": [], + "authors": [], + "reviewers": [], + "subscribers": [], + "mailthread_set": [ + 9 + ] + } +}, +{ + "model": "commitfest.mailthread", + "pk": 9, + "fields": { + "messageid": "abandoned@message-09", + "subject": "Abandoned patch in closed commitfest", + "firstmessage": "2024-10-01T10:00:00", + "firstauthor": "test@test.com", + "latestmessage": "2024-10-01T10:00:00", + "latestauthor": "test@test.com", + "latestsubject": "Abandoned patch in closed commitfest", + "latestmsgid": "abandoned@message-09" + } +}, +{ + "model": "commitfest.patchoncommitfest", + "pk": 10, + "fields": { + "patch": 9, + "commitfest": 1, + "enterdate": "2024-10-15T10:00:00", + "leavedate": null, + "status": 1 + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 23, + "fields": { + "patch": 9, + "date": "2024-10-15T10:00:00", + "by": 1, + "by_cfbot": false, + "what": "Created patch record" + } } ] diff --git a/pgcommitfest/commitfest/forms.py b/pgcommitfest/commitfest/forms.py index 2ec9fc2c..49d7366f 100644 --- a/pgcommitfest/commitfest/forms.py +++ b/pgcommitfest/commitfest/forms.py @@ -24,8 +24,17 @@ class CommitFestFilterForm(forms.Form): reviewer = forms.ChoiceField(required=False, label="Reviewer (type to search)") sortkey = forms.IntegerField(required=False) - def __init__(self, data, *args, **kwargs): + def __init__(self, data, commitfest=None, *args, **kwargs): super(CommitFestFilterForm, self).__init__(data, *args, **kwargs) + self.commitfest = commitfest + + # Update selectize_fields with cf parameter if commitfest is provided + if commitfest: + self.selectize_fields = { + "author": f"/lookups/user?cf={commitfest.id}", + "reviewer": f"/lookups/user?cf={commitfest.id}", + "tag": None, + } self.fields["sortkey"].widget = forms.HiddenInput() @@ -71,6 +80,10 @@ def __init__(self, data, *args, **kwargs): self.fields[f].widget.attrs = {"class": "input-medium"} +# Tags that get their own checkbox buttons as shortcuts (also appear in selectize dropdown) +CHECKBOX_TAG_NAMES = ["Bugfix", "Security", "Performance"] + + class PatchForm(forms.ModelForm): selectize_fields = { "authors": "/lookups/user", @@ -86,6 +99,8 @@ class Meta: "modified", "lastmail", "subscribers", + # TODO: Remove once we fully get rid of topics + "topic", ) def __init__(self, *args, **kwargs): @@ -101,6 +116,13 @@ def __init__(self, *args, **kwargs): self.fields["authors"].widget.attrs["class"] = "add-user-picker" self.fields["reviewers"].widget.attrs["class"] = "add-user-picker" + # Cache checkbox tags for use in JavaScript syncing and template rendering + self.checkbox_tags = list( + Tag.objects.filter(name__in=CHECKBOX_TAG_NAMES).values( + "id", "name", "color" + ) + ) + # Selectize multiple fields -- don't pre-populate everything for field, url in list(self.selectize_fields.items()): if url is None: @@ -146,6 +168,13 @@ class NewPatchForm(PatchForm): widget=ThreadPickWidget, ) + # "No tags apply" checkbox to force users to explicitly opt out of tagging + no_tags_apply = forms.BooleanField( + required=False, + label="No tags apply", + help_text="Check this if none of the tags above apply to this patch", + ) + def __init__(self, *args, **kwargs): request = kwargs.pop("request", None) super(NewPatchForm, self).__init__(*args, **kwargs) @@ -154,6 +183,24 @@ def __init__(self, *args, **kwargs): self.fields["authors"].queryset = User.objects.filter(pk=request.user.id) self.fields["authors"].initial = [request.user.id] + def clean(self): + cleaned_data = super().clean() + + has_tags = bool(cleaned_data.get("tags")) + no_tags = cleaned_data.get("no_tags_apply") + + if no_tags and has_tags: + raise ValidationError( + "You cannot select 'No tags apply' while also selecting tags." + ) + + if not no_tags and not has_tags: + raise ValidationError( + "Please select at least one tag, or check 'No tags apply' if none are applicable." + ) + + return cleaned_data + def clean_threadmsgid(self): try: _archivesAPI("/message-id.json/%s" % self.cleaned_data["threadmsgid"]) diff --git a/pgcommitfest/commitfest/lookups.py b/pgcommitfest/commitfest/lookups.py index 3765ce47..e285c883 100644 --- a/pgcommitfest/commitfest/lookups.py +++ b/pgcommitfest/commitfest/lookups.py @@ -1,15 +1,19 @@ from django.contrib.auth.models import User +from django.db import connection from django.db.models import Q -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, HttpResponseForbidden import json def userlookup(request): query = request.GET.get("query", None) + cf = request.GET.get("cf", None) + if not query: raise Http404() + # Start with base filters for active users matching the query users = User.objects.filter( Q(is_active=True), Q(username__icontains=query) @@ -17,6 +21,34 @@ def userlookup(request): | Q(last_name__icontains=query), ) + # If no commitfest filter is provided, require login + if not cf: + if not request.user.is_authenticated: + return HttpResponseForbidden( + "Login required when not filtering by commitfest" + ) + else: + # Filter users to only those who have participated in the specified commitfest. + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT cpa.user_id FROM commitfest_patch_authors cpa + INNER JOIN commitfest_patchoncommitfest poc ON poc.patch_id = cpa.patch_id + WHERE poc.commitfest_id = %(cf)s + UNION + SELECT cpr.user_id FROM commitfest_patch_reviewers cpr + INNER JOIN commitfest_patchoncommitfest poc ON poc.patch_id = cpr.patch_id + WHERE poc.commitfest_id = %(cf)s + UNION + SELECT p.committer_id FROM commitfest_patch p + INNER JOIN commitfest_patchoncommitfest poc ON poc.patch_id = p.id + WHERE poc.commitfest_id = %(cf)s AND p.committer_id IS NOT NULL + """, + {"cf": cf}, + ) + user_ids = [row[0] for row in cursor.fetchall()] + users = users.filter(id__in=user_ids) + return HttpResponse( json.dumps( { diff --git a/pgcommitfest/commitfest/migrations/0016_migrate_topics_to_tags.py b/pgcommitfest/commitfest/migrations/0016_migrate_topics_to_tags.py new file mode 100644 index 00000000..9f1cb3bf --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0016_migrate_topics_to_tags.py @@ -0,0 +1,90 @@ +# Generated by Django 4.2.19 on 2025-08-07 17:25 + +from django.db import migrations + + +def create_missing_tags_and_map_topics(apps, schema_editor): + """ + Create missing tags and map existing topics to appropriate tags. + """ + Tag = apps.get_model("commitfest", "Tag") + Patch = apps.get_model("commitfest", "Patch") + Topic = apps.get_model("commitfest", "Topic") + PatchTag = Patch.tags.through + + # Create missing tags + Tag.objects.get_or_create( + name="SQL Commands", + defaults={ + "color": "#198754", + "description": "SQL command implementation and enhancement", + }, + ) + + Tag.objects.get_or_create( + name="System Administration", + defaults={ + "color": "#495057", + "description": "System administration and configuration", + }, + ) + + # Topic to Tag mapping + topic_tag_mapping = { + "Testing": "Testing", + "Refactoring": "Refactoring Only", + "Documentation": "Docs Only", + "Code Comments": "Comments Only", + "Bug Fixes": "Bugfix", + "Performance": "Performance", + "Security": "Security", + "Monitoring & Control": "Monitoring", + "Clients": "libpq", + "SQL Commands": "SQL Commands", + "System Administration": "System Administration", + # 'Miscellaneous' and 'Server Features' are left untagged, because they + # are too vague. + # 'Procedural Languages' has no direct tag equivalent, because now + # there are tags per language. So there's no clear tag that should be + # chosen there. Similar for 'Replication & Recovery', which also has + # separate tags now for logical and physical replication. + } + + # Apply tags to existing patches based on their topics + for topic_name, tag_name in topic_tag_mapping.items(): + try: + topic = Topic.objects.get(topic=topic_name) + tag = Tag.objects.get(name=tag_name) + + # Get all patch IDs with this topic + patch_ids = list( + Patch.objects.filter(topic=topic).values_list("id", flat=True) + ) + + # Bulk create the patch-tag relationships + PatchTag.objects.bulk_create( + [PatchTag(patch_id=pid, tag_id=tag.id) for pid in patch_ids], + ignore_conflicts=True, + ) + + print( + f"Mapped {len(patch_ids)} patches from topic '{topic_name}' to tag '{tag_name}'" + ) + + except Topic.DoesNotExist: + print(f"Topic '{topic_name}' not found, skipping...") + except Tag.DoesNotExist: + print(f"Tag '{tag_name}' not found, skipping...") + + +class Migration(migrations.Migration): + dependencies = [ + ("commitfest", "0015_cfbot_duplicate_task_fix"), + ] + + operations = [ + migrations.RunPython( + create_missing_tags_and_map_topics, + migrations.RunPython.noop, + ), + ] diff --git a/pgcommitfest/commitfest/migrations/0017_make_topic_optional.py b/pgcommitfest/commitfest/migrations/0017_make_topic_optional.py new file mode 100644 index 00000000..c0b3e059 --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0017_make_topic_optional.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.19 on 2026-01-04 16:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("commitfest", "0016_migrate_topics_to_tags"), + ] + + operations = [ + migrations.AlterField( + model_name="patch", + name="topic", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="commitfest.topic", + ), + ), + ] diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index 4583cd79..6594e3c4 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone +from pgcommitfest.mailqueue.util import send_template_mail from pgcommitfest.userprofile.models import UserProfile from .util import DiffableModel @@ -109,6 +110,121 @@ def to_json(self): "enddate": self.enddate.isoformat(), } + def _should_auto_move_patch(self, patch, current_date): + """Determine if a patch should be automatically moved to the next commitfest. + + A patch qualifies for auto-move if it both: + 1. Has had email activity within the configured number of days + 2. Hasn't been failing CI for longer than the configured threshold + """ + activity_cutoff = current_date - timedelta( + days=settings.AUTO_MOVE_EMAIL_ACTIVITY_DAYS + ) + failing_cutoff = current_date - timedelta( + days=settings.AUTO_MOVE_MAX_FAILING_DAYS + ) + + # Check for recent email activity + if not patch.lastmail or patch.lastmail < activity_cutoff: + return False + + # Check if CI has been failing too long + try: + cfbot_branch = patch.cfbot_branch + if ( + cfbot_branch.failing_since + and cfbot_branch.failing_since < failing_cutoff + ): + return False + except CfbotBranch.DoesNotExist: + # IF no CFBot data exists, the patch is probably very new (i.e. no + # CI run has ever taken place for it yet). So we auto-move it in + # that case. + pass + + return True + + def auto_move_active_patches(self): + """Automatically move active patches to the next commitfest. + + A patch is moved if it has recent email activity and hasn't been + failing CI for too long. + """ + current_date = datetime.now() + + # Get the next open commitfest (must exist, raises IndexError otherwise) + # For draft CFs, find the next draft CF + # For regular CFs, find the next regular CF by start date + if self.draft: + next_cf = CommitFest.objects.filter( + status=CommitFest.STATUS_OPEN, + draft=True, + startdate__gt=self.enddate, + ).order_by("startdate")[0] + else: + next_cf = CommitFest.objects.filter( + status=CommitFest.STATUS_OPEN, + draft=False, + startdate__gt=self.enddate, + ).order_by("startdate")[0] + + # Get all patches with open status in this commitfest + open_pocs = self.patchoncommitfest_set.filter( + status__in=PatchOnCommitFest.OPEN_STATUSES + ).select_related("patch") + + for poc in open_pocs: + if self._should_auto_move_patch(poc.patch, current_date): + poc.patch.move(self, next_cf, by_user=None, by_cfbot=True) + + def send_closure_notifications(self): + """Send email notifications to authors of patches that are still open.""" + # Get patches that still need action (not moved, not closed) + open_pocs = list( + self.patchoncommitfest_set.filter( + status__in=PatchOnCommitFest.OPEN_STATUSES + ) + .select_related("patch") + .prefetch_related("patch__authors") + ) + + if not open_pocs: + return + + # Collect unique authors and their patches + authors_patches = {} + for poc in open_pocs: + for author in poc.patch.authors.all(): + if author not in authors_patches: + authors_patches[author] = [] + authors_patches[author].append(poc) + + # Send email to each author who has notifications enabled + for author, patches in authors_patches.items(): + try: + profile = author.userprofile + if not profile.notify_all_author: + continue + notifyemail = profile.notifyemail + except UserProfile.DoesNotExist: + # No profile means use default (notify=True), so continue + notifyemail = None + + email = notifyemail.email if notifyemail else author.email + + send_template_mail( + settings.NOTIFICATION_FROM, + None, + email, + f"Commitfest {self.name} has closed and you have unmoved patches", + "mail/commitfest_closure.txt", + { + "user": author, + "commitfest": self, + "patches": patches, + }, + ) + @staticmethod def _are_relevant_commitfests_up_to_date(cfs, current_date): inprogress_cf = cfs["in_progress"] @@ -143,26 +259,33 @@ def _refresh_relevant_commitfests(cls, for_update): if inprogress_cf and inprogress_cf.enddate < current_date: inprogress_cf.status = CommitFest.STATUS_CLOSED inprogress_cf.save() + inprogress_cf.auto_move_active_patches() + inprogress_cf.send_closure_notifications() open_cf = cfs["open"] if open_cf.startdate <= current_date: if open_cf.enddate < current_date: open_cf.status = CommitFest.STATUS_CLOSED + open_cf.save() + cls.next_open_cf(current_date).save() + open_cf.auto_move_active_patches() + open_cf.send_closure_notifications() else: open_cf.status = CommitFest.STATUS_INPROGRESS - open_cf.save() - - cls.next_open_cf(current_date).save() + open_cf.save() + cls.next_open_cf(current_date).save() draft_cf = cfs["draft"] if not draft_cf: cls.next_draft_cf(current_date).save() elif draft_cf.enddate < current_date: - # If the draft commitfest has started, we need to update it + # Create next CF first so auto_move has somewhere to move patches draft_cf.status = CommitFest.STATUS_CLOSED draft_cf.save() cls.next_draft_cf(current_date).save() + draft_cf.auto_move_active_patches() + draft_cf.send_closure_notifications() return cls.relevant_commitfests(for_update=for_update) @@ -347,7 +470,9 @@ class Patch(models.Model, DiffableModel): name = models.CharField( max_length=500, blank=False, null=False, verbose_name="Description" ) - topic = models.ForeignKey(Topic, blank=False, null=False, on_delete=models.CASCADE) + # Topic is deprecated, tags are used instead. For now this field is kept + # for debugging purposes in case of problems with the migration. + topic = models.ForeignKey(Topic, blank=True, null=True, on_delete=models.CASCADE) tags = models.ManyToManyField(Tag, related_name="patches", blank=True) # One patch can be in multiple commitfests, if it has history @@ -454,7 +579,9 @@ def update_lastmail(self): else: self.lastmail = max(threads, key=lambda t: t.latestmessage).latestmessage - def move(self, from_cf, to_cf, by_user, allow_move_to_in_progress=False): + def move( + self, from_cf, to_cf, by_user, allow_move_to_in_progress=False, by_cfbot=False + ): """Returns the new PatchOnCommitFest object, or raises UserInputError""" current_poc = self.current_patch_on_commitfest() @@ -499,6 +626,7 @@ def move(self, from_cf, to_cf, by_user, allow_move_to_in_progress=False): PatchHistory( patch=self, by=by_user, + by_cfbot=by_cfbot, what=f"Moved from CF {from_cf} to CF {to_cf}", ).save_and_notify() @@ -687,9 +815,12 @@ def save_and_notify( ) ) - # Current or previous authors wants all notifications + # Current or previous authors wants all notifications (default is True, + # so include users without a profile) recipients.extend( - self.patch.authors.filter(userprofile__notify_all_author=True) + self.patch.authors.filter( + Q(userprofile__isnull=True) | Q(userprofile__notify_all_author=True) + ) ) for u in set(recipients): diff --git a/pgcommitfest/commitfest/templates/base.html b/pgcommitfest/commitfest/templates/base.html index 8970990c..c7f2d113 100644 --- a/pgcommitfest/commitfest/templates/base.html +++ b/pgcommitfest/commitfest/templates/base.html @@ -1,112 +1,115 @@ {%load commitfest%} - - -
-