diff --git a/.editorconfig b/.editorconfig index b1eff40..e325654 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,6 +12,6 @@ trim_trailing_whitespace = true indent_style = space indent_size = 4 -[*.html] +[*.{html,inc}] indent_style = space indent_size = 1 diff --git a/Makefile b/Makefile index c034382..284c7a1 100644 --- a/Makefile +++ b/Makefile @@ -16,3 +16,10 @@ lint-fix-unsafe: npx @biomejs/biome check --fix --unsafe fix: format lint-fix-unsafe + +init-dev: + dropdb --if-exists pgcommitfest + createdb pgcommitfest + ./manage.py migrate + ./manage.py loaddata auth_data.json + ./manage.py loaddata commitfest_data.json diff --git a/media/commitfest/css/commitfest.css b/media/commitfest/css/commitfest.css index 72cae0e..985491f 100644 --- a/media/commitfest/css/commitfest.css +++ b/media/commitfest/css/commitfest.css @@ -91,3 +91,23 @@ div.form-group div.controls input.threadpick-input { .search-bar { display: inline-block; } + +.workflow table.table { + width: auto; +} + +#keyvalue-table { + display: none; +} + +#keyvalue-table.collapse.in { + display: table; +} + +#history-table { + display: none; +} + +#history-table.collapse.in { + display: table; +} diff --git a/pgcommitfest/commitfest/fixtures/auth_data.json b/pgcommitfest/commitfest/fixtures/auth_data.json index 88d8c70..9a6e2f0 100644 --- a/pgcommitfest/commitfest/fixtures/auth_data.json +++ b/pgcommitfest/commitfest/fixtures/auth_data.json @@ -88,5 +88,41 @@ "groups": [], "user_permissions": [] } +}, +{ + "model": "auth.user", + "pk": 6, + "fields": { + "password": "", + "last_login": null, + "is_superuser": false, + "username": "prolific-author", + "first_name": "Prolific", + "last_name": "Author", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2025-01-01T00:00:00", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 7, + "fields": { + "password": "", + "last_login": null, + "is_superuser": false, + "username": "prolific-reviewer", + "first_name": "Prolific", + "last_name": "Reviewer", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2025-01-01T00:00:00", + "groups": [], + "user_permissions": [] + } } ] diff --git a/pgcommitfest/commitfest/fixtures/commitfest_data.json b/pgcommitfest/commitfest/fixtures/commitfest_data.json index 6e5b32f..9317624 100644 --- a/pgcommitfest/commitfest/fixtures/commitfest_data.json +++ b/pgcommitfest/commitfest/fixtures/commitfest_data.json @@ -60,6 +60,16 @@ "enddate": "2025-05-31" } }, +{ + "model": "commitfest.commitfest", + "pk": 5, + "fields": { + "name": "Drafts v18", + "status": 5, + "startdate": "2024-09-01", + "enddate": "2025-08-31" + } +}, { "model": "commitfest.topic", "pk": 1, @@ -237,6 +247,25 @@ ] } }, +{ + "model": "commitfest.patch", + "pk": 8, + "fields": { + "name": "Test DGJ Multi-Author and Reviewer", + "topic": 3, + "wikilink": "", + "gitlink": "", + "targetversion": 1, + "committer": 4, + "created": "2025-02-01T00:00", + "modified": "2025-02-01T00:00", + "lastmail": null, + "authors": [6,3], + "reviewers": [7,1], + "subscribers": [], + "mailthread_set": [] + } +}, { "model": "commitfest.patchoncommitfest", "pk": 1, @@ -325,6 +354,17 @@ "status": 1 } }, +{ + "model": "commitfest.patchoncommitfest", + "pk": 9, + "fields": { + "patch": 8, + "commitfest": 5, + "enterdate": "2025-02-01T00:00:00", + "leavedate": null, + "status": 1 + } +}, { "model": "commitfest.patchhistory", "pk": 1, diff --git a/pgcommitfest/commitfest/migrations/0011_add_status_related_constraints.py b/pgcommitfest/commitfest/migrations/0011_add_status_related_constraints.py new file mode 100644 index 0000000..5291d19 --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0011_add_status_related_constraints.py @@ -0,0 +1,42 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("commitfest", "0010_add_failing_since_column"), + ] + operations = [ + migrations.RunSQL(""" +CREATE UNIQUE INDEX cf_enforce_maxoneopen_idx +ON commitfest_commitfest (status) +WHERE status not in (4); +"""), + migrations.RunSQL(""" +CREATE UNIQUE INDEX poc_enforce_maxoneoutcome_idx +ON commitfest_patchoncommitfest (patch_id) +WHERE status not in (5); +"""), + migrations.RunSQL(""" +ALTER TABLE commitfest_patchoncommitfest +ADD CONSTRAINT status_and_leavedate_correlation +CHECK ((status IN (4,5,6,7,8)) = (leavedate IS NOT NULL)); +"""), + migrations.RunSQL(""" +COMMENT ON COLUMN commitfest_patchoncommitfest.leavedate IS +$$A leave date is recorded in two situations, both of which +means this particular patch-cf combination became inactive +on the corresponding date. For status 5 the patch was moved +to some other cf. For 4,6,7, and 8, this was the final cf. +$$ +"""), + migrations.RunSQL(""" +COMMENT ON TABLE commitfest_patchoncommitfest IS +$$This is a re-entrant table: patches may become associated +with a given cf multiple times, resetting the entrydate and clearing +the leavedate each time. Non-final statuses never have a leavedate +while final statuses always do. The final status of 5 (moved) is +special in that all but one of the rows a patch has in this table +must have it as the status. +$$ +"""), + ] diff --git a/pgcommitfest/commitfest/migrations/0012_add_parked_cf_status.py b/pgcommitfest/commitfest/migrations/0012_add_parked_cf_status.py new file mode 100644 index 0000000..9075946 --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0012_add_parked_cf_status.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("commitfest", "0011_add_status_related_constraints"), + ] + operations = [ + migrations.AlterField( + model_name="commitfest", + name="status", + field=models.IntegerField( + choices=[ + (1, "Future"), + (2, "Open"), + (3, "In Progress"), + (4, "Closed"), + (5, "Parked"), + ], + default=1, + ), + ) + ] diff --git a/pgcommitfest/commitfest/migrations/0013_no_patches_in_future_cfs.py b/pgcommitfest/commitfest/migrations/0013_no_patches_in_future_cfs.py new file mode 100644 index 0000000..75ed886 --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0013_no_patches_in_future_cfs.py @@ -0,0 +1,57 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("commitfest", "0012_add_parked_cf_status"), + ] + operations = [ + migrations.RunSQL(""" +CREATE FUNCTION assert_poc_not_future_for_poc() +RETURNS TRIGGER AS $$ +DECLARE + cfstatus int; +BEGIN + SELECT status INTO cfstatus + FROM commitfest_commitfest + WHERE id = NEW.commitfest_id; + + IF cfstatus = 1 THEN + RAISE EXCEPTION 'Patches cannot exist on future commitfests'; + END IF; + + RETURN NEW; +END; +$$ +LANGUAGE plpgsql; + +CREATE FUNCTION assert_poc_not_future_for_cf() +RETURNS trigger AS $$ +BEGIN + -- Trigger checks that we only get called when status is 1 + PERFORM 1 + FROM commitfest_patchoncommitfest + WHERE commitfest_id = NEW.id + LIMIT 1; + + IF FOUND THEN + RAISE EXCEPTION 'Cannot change commitfest status to 1, patches exists.'; + END IF; + RETURN NEW; +END; +$$ +LANGUAGE plpgsql; + +CREATE TRIGGER assert_poc_commitfest_is_not_future +BEFORE INSERT OR UPDATE ON commitfest_patchoncommitfest +FOR EACH ROW +EXECUTE FUNCTION assert_poc_not_future_for_poc(); + +CREATE TRIGGER assert_poc_commitfest_is_not_future +-- Newly inserted cfs can't have patches +BEFORE UPDATE ON commitfest_commitfest +FOR EACH ROW +WHEN (NEW.status = 1) +EXECUTE FUNCTION assert_poc_not_future_for_cf(); +"""), + ] diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index fcd9edb..8548f61 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import User from django.db import models +from django.db.models import Q from django.shortcuts import get_object_or_404 from datetime import datetime @@ -38,17 +39,20 @@ class CommitFest(models.Model): STATUS_OPEN = 2 STATUS_INPROGRESS = 3 STATUS_CLOSED = 4 + STATUS_PARKED = 5 _STATUS_CHOICES = ( (STATUS_FUTURE, "Future"), (STATUS_OPEN, "Open"), - (STATUS_INPROGRESS, "In Progress"), + (STATUS_INPROGRESS, "Ongoing"), (STATUS_CLOSED, "Closed"), + (STATUS_PARKED, "Drafts"), ) _STATUS_LABELS = ( (STATUS_FUTURE, "default"), (STATUS_OPEN, "info"), (STATUS_INPROGRESS, "success"), (STATUS_CLOSED, "danger"), + (STATUS_PARKED, "default"), ) name = models.CharField(max_length=100, blank=False, null=False, unique=True) status = models.IntegerField( @@ -63,6 +67,8 @@ def statusstring(self): @property def periodstring(self): + # Current Workflow intent is to have all Committfest be time-bounded + # but the information is just contextual so we still permit null if self.startdate and self.enddate: return "{0} - {1}".format(self.startdate, self.enddate) return "" @@ -71,10 +77,22 @@ def periodstring(self): def title(self): return "Commitfest %s" % self.name + @property + def isclosed(self): + return self.status == self.STATUS_CLOSED + @property def isopen(self): return self.status == self.STATUS_OPEN + @property + def isinprogress(self): + return self.status == self.STATUS_INPROGRESS + + @property + def isparked(self): + return self.status == self.STATUS_PARKED + def __str__(self): return self.name @@ -158,12 +176,18 @@ class Patch(models.Model, DiffableModel): "reviewers": "reviewers_string", } + # XXX probably should just encourage using PoC.commitfest since most places + # dealing with the Patch need the PoC anyway. def current_commitfest(self): - return self.commitfests.order_by("-startdate").first() + return self.current_patch_on_commitfest().commitfest def current_patch_on_commitfest(self): - cf = self.current_commitfest() - return get_object_or_404(PatchOnCommitFest, patch=self, commitfest=cf) + # The unique partial index poc_enforce_maxoneoutcome_idx stores the PoC + # No caching here (inside the instance) since the caller should just need + # the PoC once per request. + return get_object_or_404( + PatchOnCommitFest, Q(patch=self) & ~Q(status=PatchOnCommitFest.STATUS_NEXT) + ) # Some accessors @property @@ -229,13 +253,13 @@ class PatchOnCommitFest(models.Model): STATUS_RETURNED = 7 STATUS_WITHDRAWN = 8 _STATUS_CHOICES = ( - (STATUS_REVIEW, "Needs review"), + (STATUS_REVIEW, "Needs Review"), (STATUS_AUTHOR, "Waiting on Author"), (STATUS_COMMITTER, "Ready for Committer"), (STATUS_COMMITTED, "Committed"), - (STATUS_NEXT, "Moved to next CF"), + (STATUS_NEXT, "Moved"), (STATUS_REJECTED, "Rejected"), - (STATUS_RETURNED, "Returned with feedback"), + (STATUS_RETURNED, "Returned with Feedback"), (STATUS_WITHDRAWN, "Withdrawn"), ) _STATUS_LABELS = ( @@ -273,6 +297,14 @@ def is_closed(self): def is_open(self): return not self.is_closed + @property + def is_committed(self): + return self.status == self.STATUS_COMMITTED + + @property + def is_committer(self): + return self.status == self.STATUS_COMMITTER + @property def statusstring(self): return [v for k, v in self._STATUS_CHOICES if k == self.status][0] @@ -528,3 +560,253 @@ class CfbotTask(models.Model): status = models.TextField(choices=STATUS_CHOICES, null=False) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) + + +# Workflow provides access to the elements required to support +# the workflow this application is built for. These elements exist +# independent of what the user is presently seeing on their page. +class Workflow(models.Model): + def get_poc_for_patchid_or_404(patchid): + return get_object_or_404( + Patch.objects.select_related(), pk=patchid + ).current_patch_on_commitfest() + + # At most a single Open CommitFest is allowed and this function returns it. + def open_cf(): + cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_OPEN)) + return cfs[0] if len(cfs) == 1 else None + + # At most a single In Progress CommitFest is allowed and this function returns it. + def inprogress_cf(): + cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_INPROGRESS)) + return cfs[0] if len(cfs) == 1 else None + + # At most a single Parked CommitFest is allowed and this function returns it. + def parked_cf(): + cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_PARKED)) + return cfs[0] if len(cfs) == 1 else None + + # Returns whether the user is a committer in general and for this patch + # since we retrieve all committers in order to answer these questions + # provide that list as a third return value. Passing None for both user + # and patch still returns the list of committers. + def isCommitter(user, patch): + all_committers = Committer.objects.filter(active=True).order_by( + "user__last_name", "user__first_name" + ) + if not user and not patch: + return False, False, all_committers + + committer = [c for c in all_committers if c.user == user] + if len(committer) == 1: + is_committer = True + is_this_committer = committer[0] == patch.committer + else: + is_committer = is_this_committer = False + return is_committer, is_this_committer, all_committers + + def getCommitfest(cfid): + if cfid is None or cfid == "": + return None + try: + int_cfid = int(cfid) + cfs = list(CommitFest.objects.filter(id=int_cfid)) + if len(cfs) == 1: + return cfs[0] + else: + return None + except ValueError: + return None + + # Implements a re-entrant Commitfest POC creation procedure. + # Returns the new POC object. + # Creates history and notifies as a side-effect. + def createNewPOC(patch, commitfest, initial_status, by_user): + poc, created = PatchOnCommitFest.objects.update_or_create( + patch=patch, + commitfest=commitfest, + defaults=dict( + enterdate=datetime.now(), + status=initial_status, + leavedate=None, + ), + ) + poc.patch.set_modified() + poc.patch.save() + poc.save() + + PatchHistory( + patch=poc.patch, + by=by_user, + what="{} in {}".format(poc.statusstring, commitfest.name), + ).save_and_notify() + + return poc + + # The rule surrounding patches is they may only be in one active + # commitfest at a time. The transition function takes a patch + # open in one commitfest and associates it, with the same status, + # in a new commitfest; then makes it inactive in the original. + # Returns the new POC object. + # Creates history and notifies as a side-effect. + def transitionPatch(poc, target_cf, by_user): + Workflow.userCanTransitionPatch(poc, target_cf, by_user) + + existing_status = poc.status + + # History looks cleaner if we've left the existing + # commitfest entry before joining the new one. Plus, + # not allowed to change non-current commitfest status + # and once the new POC is created it becomes current. + + Workflow.updatePOCStatus(poc, PatchOnCommitFest.STATUS_NEXT, by_user) + + new_poc = Workflow.createNewPOC(poc.patch, target_cf, existing_status, by_user) + + return new_poc + + def userCanTransitionPatch(poc, target_cf, user): + # Policies not allowed to be broken by anyone. + + # Prevent changes to non-current commitfest for the patch + # Meaning, change status to Moved before/during transitioning + if poc.commitfest != poc.patch.current_commitfest(): + raise Exception("PoC commitfest is not patch's current commitfest.") + + # The UI should be preventing people from trying to perform no-op requests + if poc.commitfest.id == target_cf.id: + raise Exception("Cannot transition to the same commitfest.") + + # This one is arguable but facilitates treating non-open status as final + # A determined staff member can always change the status first. + if poc.is_closed: + raise Exception("Cannot transition a closed patch.") + + # We trust privileged users to make informed choices + if user.is_staff: + return + + if target_cf.isclosed: + raise Exception("Cannot transition to a closed commitfest.") + + if target_cf.isinprogress: + raise Exception("Cannot transition to an in-progress commitfest.") + + # Prevent users from moving closed patches, or moving open ones to + # non-open commitfests. The else clause should be a can't happen. + if poc.is_open and target_cf.isopen: + pass + else: + # Default deny policy basis + raise Exception("Transition not permitted.") + + def userCanChangePOCStatus(poc, new_status, user): + # Policies not allowed to be broken by anyone. + + # Prevent changes to non-current commitfest for the patch + # Meaning, change status to Moved before/during transitioning + if poc.commitfest != poc.patch.current_commitfest(): + raise Exception("PoC commitfest is not patch's current commitfest.") + + # The UI should be preventing people from trying to perform no-op requests + if poc.status == new_status: + raise Exception("Cannot change to the same status.") + + # We want commits to happen from, usually, In Progress commitfests, + # or Open ones for exempt patches. We accept Future ones too just because + # they do represent a proper, if non-current, Commitfest. + if ( + poc.commitfest.id == CommitFest.STATUS_PARKED + and new_status == PatchOnCommitFest.STATUS_COMMITTED + ): + raise Exception("Cannot change status to committed in a parked commitfest.") + + # We trust privileged users to make informed choices + if user.is_staff: + return + + is_committer = Workflow.isCommitter(user, poc.patch) + + # XXX Not sure if we want to tighten this up to is_this_committer + # with only the is_staff exemption + if new_status == PatchOnCommitFest.STATUS_COMMITTED and not is_committer: + raise Exception("Only a committer can set status to committed.") + + if new_status == PatchOnCommitFest.STATUS_REJECTED and not is_committer: + raise Exception("Only a committer can set status to rejected.") + + if new_status == PatchOnCommitFest.STATUS_RETURNED and not is_committer: + raise Exception("Only a committer can set status to returned.") + + if ( + new_status == PatchOnCommitFest.STATUS_WITHDRAWN + and user not in poc.patch.authors.all() + ): + raise Exception("Only the author can set status to withdrawn.") + + # Prevent users from modifying closed patches + # The else clause should be considered a can't happen + if poc.is_open: + pass + else: + raise Exception("Cannot change status of closed patch.") + + # Update the status of a PoC + # Returns True if the status was changed, False for a same-status no-op. + # Creates history and notifies as a side-effect. + def updatePOCStatus(poc, new_status, by_user): + # XXX Workflow disallows this no-op but not quite ready to enforce it. + if poc.status == new_status: + return False + + Workflow.userCanChangePOCStatus(poc, new_status, by_user) + + poc.status = new_status + poc.leavedate = datetime.now() if not poc.is_open else None + poc.patch.set_modified() + poc.patch.save() + poc.save() + PatchHistory( + patch=poc.patch, + by=by_user, + what="{} in {}".format( + poc.statusstring, + poc.commitfest.name, + ), + ).save_and_notify() + + return True + + # Sets the value of the committer for the patch + # Returns True if the committer was changed, False for a same-committer no-op. + # Creates history and notifies as a side-effect. + def setCommitter(poc, committer, by_user): + if poc.patch.committer == committer: + return False + + prevcommitter = poc.patch.committer + poc.patch.committer = committer + poc.patch.set_modified() + poc.patch.save() + poc.save() + + if committer is None: + msg = "Removed {} as committer in {}".format( + prevcommitter.fullname, poc.commitfest.name + ) + elif prevcommitter is None: + msg = "Set {} as committer in {}".format( + poc.patch.committer.fullname, poc.commitfest.name + ) + else: + msg = "Changed to {} as committer in {}".format( + poc.patch.committer.fullname, poc.commitfest.name + ) + + PatchHistory( + patch=poc.patch, + by=by_user, + what=msg, + ).save_and_notify(prevcommitter=prevcommitter) + + return True diff --git a/pgcommitfest/commitfest/templates/base.html b/pgcommitfest/commitfest/templates/base.html index c70a7f7..b606d66 100644 --- a/pgcommitfest/commitfest/templates/base.html +++ b/pgcommitfest/commitfest/templates/base.html @@ -30,6 +30,9 @@ Log in {%endif%} +
  • + Workflow +
  • {%if header_activity%}
  • {{header_activity}}
  • {%endif%} diff --git a/pgcommitfest/commitfest/templates/patch.html b/pgcommitfest/commitfest/templates/patch.html index c2e7346..7c2d78d 100644 --- a/pgcommitfest/commitfest/templates/patch.html +++ b/pgcommitfest/commitfest/templates/patch.html @@ -1,205 +1,83 @@ {%extends "base.html"%} {%load commitfest%} {%block contents%} - {%include "patch_commands.inc"%} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {%if patch.wikilink%} - Wiki{%endif%}{%if patch.gitlink%} - Git - {%endif%} - - - - + + +
    ID{{patch.id}}
    Title{{patch.name}}
    CI (CFBot) - {%if not cfbot_branch %} - Not processed - {%elif cfbot_branch.needs_rebase_since %} - - Needs rebase! - Needs rebase {% cfsince cfbot_branch.needs_rebase_since %}. {%if cfbot_branch.failing_since and cfbot_branch.failing_since != cfbot_branch.needs_rebase_since %}Failing {% cfsince cfbot_branch.failing_since %}. {%endif%}
    Additional links previous successfully applied patch (outdated):
    - - - Summary - {%else%} - - - Summary - {%for c in cfbot_tasks %} - {%if c.status == 'COMPLETED'%} - - {%elif c.status == 'CREATED' or c.status == 'SCHEDULED' %} - - {%elif c.status == 'EXECUTING' %} - - {%else %} - - {%endif%} - {%endfor%} - {%endif%} - {%if cfbot_branch %} - - {%endif%} - -
    Stats (from CFBot) - {%if cfbot_branch and cfbot_branch.commit_id %} - {%if cfbot_branch.version %} - Patch version: {{ cfbot_branch.version }}, - {%endif%} - Patch count: {{ cfbot_branch.patch_count }}, - First patch: +{{ cfbot_branch.first_additions }}−{{ cfbot_branch.first_deletions }}, - All patches: +{{ cfbot_branch.all_additions }}−{{ cfbot_branch.all_deletions }} - {%else%} - Unknown - {%endif%} -
    Topic{{patch.topic}}
    Created{{patch.created}}
    Last modified{{patch.modified}} ({% cfwhen patch.modified %})
    Latest email{%if patch.lastmail%}{{patch.lastmail}} ({% cfwhen patch.lastmail %}){%endif%}
    Status{%for c in patch_commitfests %} -
    {{c.commitfest}}: {{c.statusstring}}
    - {%endfor%} -
    Target version{%if patch.targetversion%}{{patch.targetversion}}{%endif%}
    Authors{{patch.authors_string}}
    Reviewers{{patch.reviewers_string}}{{is_reviewer|yesno:"Remove from reviewers,Become reviewer"}}
    Committer{%if patch.committer%}{{patch.committer.fullname}}{%endif%} - {%if is_committer%}{{is_this_committer|yesno:"Unclaim as committer,Claim as committer"}}{%endif%} -
    Links
    Emails - {%if user.is_authenticated%} -
    - {%else%} -
    - {%endif%} -
    - {%for t in patch.mailthread_set.all%} -
    {{t.subject}}
    -
    - First at {{t.firstmessage}} by {{t.firstauthor|hidemail}}
    - Latest at {{t.latestmessage}} by {{t.latestauthor|hidemail}}
    - {%for ta in t.mailthreadattachment_set.all%} - {%if forloop.first%} - Latest attachment ({{ta.filename}}) at {{ta.date}} from {{ta.author|hidemail}} -
    - {%endif%} -     Attachment ({{ta.filename}}) at {{ta.date}} from {{ta.author|hidemail}} (Patch: {{ta.ispatch|yesno:"Yes,No,Pending check"}})
    - {%if forloop.last%}
    {%endif%} - {%endfor%} -
    - {%for a in t.mailthreadannotation_set.all%} - {%if forloop.first%} -

    Annotations

    - - - - - - - - - - - {%endif%} - - - - - - - {%if forloop.last%} - -
    WhenWhoMailAnnotation
    {{a.date}}{{a.user_string}}From {{a.mailauthor}}
    at {{a.maildate}}
    {{a.annotationtext}}
    - {%endif%} - {%endfor%} - {%if user.is_authenticated%}{%endif%} -
    -
    + {%if user.is_staff %} + {%include "patch_administrative.inc"%} + {%endif%} + + {%if not user.is_authenticated %} +
    + Read-Only mode. Log in to interact with this patch. +
    + {%endif%} + + {%include "patch_workflow.inc"%} + +
    + + + {%include "patch_tr_email.inc"%} + {%include "patch_tr_cfbot.inc"%} + + + + + + + - - - - - - -
    Links + {%if patch.wikilink%} + Wiki + {%endif%} + {%if patch.gitlink%} + Git + {%endif%} + {%if user.is_authenticated %} + Edit + {%endif%} +
    Status{%for c in patch_commitfests %} +
    + {{c.commitfest}}: + {{c.statusstring}} + ({{c.enterdate}} to {%if c.leavedate%}{{c.leavedate}}{%else%}present{%endif%}) +
    {%endfor%} - -
    History -
    - - - - - - - - - - {%for h in patch.history %} - - - - - - {%endfor%} - -
    WhenWhoWhat
    {{h.date}}{{h.by_string}}{{h.what}}
    -
    - {%if user.is_authenticated%} - {{is_subscribed|yesno:"Unsubscribe from patch update emails,Subscribe to patch update emails"}} - {%endif%} -
    +
    + + +
    + + {%if user.is_authenticated %} + + {%endif%} +
    -
    - {%include "patch_commands.inc"%} +
    +

    History

    + + + + + + + + + + {%for h in patch.history %} + + + + + + {%endfor%} + +
    WhenWhoWhat
    {{h.date}}{{h.by_string}}{{h.what}}
    + {%if user.is_authenticated %} + {%include "patch_table_keyvalue.inc"%} + {%endif%} {%comment%}commit dialog{%endcomment%}