diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..34d7e949 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,35 @@ +name: CI +on: + push: + branches: ["master"] + pull_request: + +jobs: + format: + runs-on: ubuntu-24.04 + name: "Linting and formatting" + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run ruff check + uses: astral-sh/ruff-action@v2 + + - name: Run ruff format --check + uses: astral-sh/ruff-action@v2 + with: + args: "format --check" + + - name: Setup Biome + uses: biomejs/setup-biome@v2 + with: + version: latest + + - name: Run Biome + run: biome ci . + + - name: Install djhtml + run: pip install djhtml + + - name: Run djhtml + run: djhtml pgcommitfest/*/templates/*.html pgcommitfest/*/templates/*.inc --tabwidth=1 --check diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..c0343821 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +format: + ruff format + npx @biomejs/biome format --write + djhtml pgcommitfest/*/templates/*.html pgcommitfest/*/templates/*.inc --tabwidth=1 + +lint: + ruff check + npx @biomejs/biome check + +lint-fix: + ruff check --fix + npx @biomejs/biome check --fix + +lint-fix-unsafe: + ruff check --fix --unsafe-fixes + npx @biomejs/biome check --fix --unsafe + +fix: format lint-fix-unsafe diff --git a/README.md b/README.md index f90bb60f..6502e0a6 100644 --- a/README.md +++ b/README.md @@ -48,17 +48,20 @@ be provided. ``` #### Load data -For a quick start, you can load some dummy data into the database. Here's how you do that: +For a quick start, you can load some dummy data into the database. Here's how +you do that: -``` +```bash ./manage.py loaddata auth_data.json ./manage.py loaddata commitfest_data.json ``` -If you do this, the admin username and password are `admin` and `admin`. +If you do this, the admin username and password are `admin` and `admin`. There +are a few other users as well (`staff`, `normal`, `committer`, +`inactive-committer`), that all have the same password as their username. -On the other hand, if you'd like to start from scratch instead, you can run the following command to create -a super user: +On the other hand, if you'd like to start from scratch instead, you can run the +following command to create a super user: ```bash ./manage.py createsuperuser @@ -76,14 +79,41 @@ admin interface, go back to the main interface. You're now logged in. ## Contributing -Before committing make sure to install the git pre-commit hook to adhere to the -codestyle. +Code formatting and linting is done using [`ruff`], [`biome`], and [`djhtml`]. +You can run formatting using `make format`. Linting can be done using `make +lint` and automatic fixing of linting errors can be done using `make lint-fix` +or `make lint-fix-unsafe` (unsafe fixes can slightly change program behaviour, +but often the fixed behaviour is the one you intended). You can also run both +`make format` and `make lint-fix-unsafe` together by using `make fix`. CI +automatically checks that you adhere to these coding standards. + +You can install the git pre-commit hook to help you adhere to the codestyle: ```bash ln -s ../../tools/githook/pre-commit .git/hooks/ - ``` +[`ruff`]: https://docs.astral.sh/ruff/ +[`biome`]: https://biomejs.dev/ +[`djhtml`]: https://github.com/rtts/djhtml + +### Discord + +If you want to discuss development of a fix/feature over chat. Please join the +`#commitfest-dev` channel on the ["PostgreSQL Hacking" Discord server][1] + +[1]: https://discord.gg/XZy2DXj7Wz + +### Staging server + +The staging server is available at: +User and password for the HTTP authentication popup are both `pgtest`. The +`main` branch is automatically deployed to the staging server. After some time +on the staging server, commits will be merged into the `prod` branch, which +automatically deploys to the production server. + +### Regenerating the database dump files + If you'd like to regenerate the database dump files, you can run the following commands: ``` ./manage.py dumpdata auth --format=json --indent=4 --exclude=auth.permission > pgcommitfest/commitfest/fixtures/auth_data.json diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..1c3d6648 --- /dev/null +++ b/biome.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": true, + "ignore": [], + "include": [ + "media/commitfest/js/commitfest.js", + "media/commitfest/css/commitfest.css", + "biome.json" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/dev_requirements.txt b/dev_requirements.txt index cedd81ce..2b0518b5 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,3 +1,5 @@ -r requirements.txt uwsgi pycodestyle +ruff +djhtml diff --git a/media/commitfest/css/commitfest.css b/media/commitfest/css/commitfest.css index d4ab8e2c..72cae0e9 100644 --- a/media/commitfest/css/commitfest.css +++ b/media/commitfest/css/commitfest.css @@ -4,47 +4,51 @@ /* For close button with float disabled */ .close-nofloat { - float: none !important; + float: none !important; } /* General form styling */ .form-horizontal div.form-group { - margin-bottom: 10px; + margin-bottom: 10px; } div.form-group div.controls ul { - list-style-type: none; - margin: 0px; - padding: 0px; + list-style-type: none; + margin: 0px; + padding: 0px; } div.form-group div.controls ul li { - display: inline; + display: inline; } div.form-group div.controls ul li label { - display: inline; - font-weight: normal; - vertical-align:middle; + display: inline; + font-weight: normal; + vertical-align: middle; } div.form-group div.controls ul li label input { - display: inline; - vertical-align:middle; + display: inline; + vertical-align: middle; } -div.form-group div.controls input[type='checkbox'] { - width: 10px; +div.form-group div.controls input[type="checkbox"] { + width: 10px; + height: unset; + display: inline-block; +} +div.form-group div.controls > div.form-control:has(input[type="checkbox"]) { + display: flex; + gap: 8px; } div.form-group div.controls input.threadpick-input { - width: 80%; - display: inline; + width: 80%; + display: inline; } - - /* * Attach thread dialog */ #attachThreadListWrap.loading { display: block; - background: url('/media/commitfest/spinner.gif') no-repeat center; + background: url("/media/commitfest/spinner.gif") no-repeat center; width: 124px; height: 124px; margin: 0 auto; @@ -57,7 +61,7 @@ div.form-group div.controls input.threadpick-input { * Annotate message dialog */ #annotateMessageBody.loading { display: block; - background: url('/media/commitfest/spinner.gif') no-repeat center; + background: url("/media/commitfest/spinner.gif") no-repeat center; width: 124px; height: 124px; margin: 0 auto; @@ -83,3 +87,7 @@ div.form-group div.controls input.threadpick-input { font-weight: bold; color: red; } + +.search-bar { + display: inline-block; +} diff --git a/media/commitfest/js/commitfest.js b/media/commitfest/js/commitfest.js index 57b34cd5..84932590 100644 --- a/media/commitfest/js/commitfest.js +++ b/media/commitfest/js/commitfest.js @@ -1,314 +1,359 @@ function verify_reject() { - return confirm('Are you sure you want to close this patch as Rejected?\n\nThis should only be done when a patch will never be applied - if more work is needed, it should instead be set to "Returned with Feedback" or "Moved to next CF".\n\nSo - are you sure?'); + return confirm( + 'Are you sure you want to close this patch as Rejected?\n\nThis should only be done when a patch will never be applied - if more work is needed, it should instead be set to "Returned with Feedback" or "Moved to next CF".\n\nSo - are you sure?', + ); } function verify_withdrawn() { - return confirm('Are you sure you want to close this patch as Withdrawn?\n\nThis should only be done when the author voluntarily withdraws the patch.\n\nSo - are you sure?'); + return confirm( + "Are you sure you want to close this patch as Withdrawn?\n\nThis should only be done when the author voluntarily withdraws the patch.\n\nSo - are you sure?", + ); } function verify_returned() { - return confirm('Are you sure you want to close this patch as Returned with Feedback?\n\nThis should be done if the patch is expected to be finished at some future time, but not necessarily in the next commitfest. If work is undergoing and expected in the next commitfest, it should instead be set to "Moved to next CF".\n\nSo - are you sure?'); + return confirm( + 'Are you sure you want to close this patch as Returned with Feedback?\n\nThis should be done if the patch is expected to be finished at some future time, but not necessarily in the next commitfest. If work is undergoing and expected in the next commitfest, it should instead be set to "Moved to next CF".\n\nSo - are you sure?', + ); } function verify_next() { - return confirm('Are you sure you want to move this patch to the next commitfest?\n\nThis means the patch will be marked as closed in this commitfest, but will automatically be moved to the next one. If no further work is expected on this patch, it should be closed with "Rejected" or "Returned with Feedback" instead.\n\nSo - are you sure?'); + return confirm( + 'Are you sure you want to move this patch to the next commitfest?\n\nThis means the patch will be marked as closed in this commitfest, but will automatically be moved to the next one. If no further work is expected on this patch, it should be closed with "Rejected" or "Returned with Feedback" instead.\n\nSo - are you sure?', + ); } function findLatestThreads() { - $('#attachThreadListWrap').addClass('loading'); - $('#attachThreadSearchButton').addClass('disabled'); - $.get('/ajax/getThreads/', { - 's': $('#attachThreadSearchField').val(), - 'a': $('#attachThreadAttachOnly').val(), - }).success(function(data) { - sel = $('#attachThreadList'); - sel.find('option').remove(); - $.each(data, function(m,i) { - sel.append($(''); - $.each(data, function(i,m) { - sel.append(''); - }); - }).always(function() { - $('#annotateMessageBody').removeClass('loading'); - }); + $("#annotateMessageBody").addClass("loading"); + $("#doAnnotateMessageButton").addClass("disabled"); + $.get("/ajax/getMessages", { + t: threadid, + }) + .success((data) => { + sel = $("#annotateMessageList"); + sel.find("option").remove(); + sel.append(''); + $.each(data, (i, m) => { + sel.append( + ``, + ); + }); + }) + .always(() => { + $("#annotateMessageBody").removeClass("loading"); + }); } function addAnnotation(threadid) { - $('#annotateThreadList').find('option').remove(); - $('#annotateMessage').val(''); - $('#annotateMsgId').val(''); - $('#annotateModal').modal(); - $('#annotateThreadList').focus(); + $("#annotateThreadList").find("option").remove(); + $("#annotateMessage").val(""); + $("#annotateMsgId").val(""); + $("#annotateModal").modal(); + $("#annotateThreadList").focus(); updateAnnotationMessages(threadid); - $('#doAnnotateMessageButton').unbind('click'); - $('#doAnnotateMessageButton').click(function() { - var msg = $('#annotateMessage').val(); - if (msg.length >= 500) { - alert('Maximum length for an annotation is 500 characters.\nYou should probably post an actual message in the thread!'); - return; - } - $('#doAnnotateMessageButton').addClass('disabled'); - $('#annotateMessageBody').addClass('loading'); - $.post('/ajax/annotateMessage/', { - 't': threadid, - 'msgid': $.trim($('#annotateMsgId').val()), - 'msg': msg - }).success(function(data) { - if (data != 'OK') { - alert(data); - $('#annotateMessageBody').removeClass('loading'); - } - else { - $('#annotateModal').modal('hide'); - location.reload(); - } - }).fail(function(data) { - alert('Failed to annotate message'); - $('#annotateMessageBody').removeClass('loading'); - }); + $("#doAnnotateMessageButton").unbind("click"); + $("#doAnnotateMessageButton").click(() => { + const msg = $("#annotateMessage").val(); + if (msg.length >= 500) { + alert( + "Maximum length for an annotation is 500 characters.\nYou should probably post an actual message in the thread!", + ); + return; + } + $("#doAnnotateMessageButton").addClass("disabled"); + $("#annotateMessageBody").addClass("loading"); + $.post("/ajax/annotateMessage/", { + t: threadid, + msgid: $.trim($("#annotateMsgId").val()), + msg: msg, + }) + .success((data) => { + if (data !== "OK") { + alert(data); + $("#annotateMessageBody").removeClass("loading"); + } else { + $("#annotateModal").modal("hide"); + location.reload(); + } + }) + .fail((data) => { + alert("Failed to annotate message"); + $("#annotateMessageBody").removeClass("loading"); + }); }); } function annotateMsgPicked() { - var val = $('#annotateMessageList').val(); + const val = $("#annotateMessageList").val(); if (val) { - $('#annotateMsgId').val(val); - annotateChanged(); + $("#annotateMsgId").val(val); + annotateChanged(); } } function annotateChanged() { /* Enable/disable the annotate button */ - if ($('#annotateMessage').val() != '' && $('#annotateMsgId').val()) { - $('#doAnnotateMessageButton').removeClass('disabled'); - } - else { - $('#doAnnotateMessageButton').addClass('disabled'); + if ($("#annotateMessage").val() !== "" && $("#annotateMsgId").val()) { + $("#doAnnotateMessageButton").removeClass("disabled"); + } else { + $("#doAnnotateMessageButton").addClass("disabled"); } } function deleteAnnotation(annid) { - if (confirm('Are you sure you want to delete this annotation?')) { - $.post('/ajax/deleteAnnotation/', { - 'id': annid, - }).success(function(data) { - location.reload(); - }).fail(function(data) { - alert('Failed to delete annotation!'); - }); + if (confirm("Are you sure you want to delete this annotation?")) { + $.post("/ajax/deleteAnnotation/", { + id: annid, + }) + .success((data) => { + location.reload(); + }) + .fail((data) => { + alert("Failed to delete annotation!"); + }); } } function flagCommitted(committer) { - $('#commitModal').modal(); - $('#committerSelect').val(committer); - $('#doCommitButton').unbind('click'); - $('#doCommitButton').click(function() { - var c = $('#committerSelect').val(); - if (!c) { - alert('You need to select a committer before you can mark a patch as committed!'); - return; - } - document.location.href='close/committed/?c=' + c; - }); - return false; + $("#commitModal").modal(); + $("#committerSelect").val(committer); + $("#doCommitButton").unbind("click"); + $("#doCommitButton").click(() => { + const c = $("#committerSelect").val(); + if (!c) { + alert( + "You need to select a committer before you can mark a patch as committed!", + ); + return; + } + document.location.href = `close/committed/?c=${c}`; + }); + return false; } - function sortpatches(sortby) { - if ($('#id_sortkey').val() == sortby) { - $('#id_sortkey').val(0); - } else { - $('#id_sortkey').val(sortby); - } - $('#filterform').submit(); + const sortkey = Number.parseInt($("#id_sortkey").val()); + if (sortkey === sortby) { + $("#id_sortkey").val(-sortby); + } else if (-sortkey === sortby) { + $("#id_sortkey").val(0); + } else { + $("#id_sortkey").val(sortby); + } + $("#filterform").submit(); - return false; + return false; } function toggleButtonCollapse(buttonId, collapseId) { - $('#' + buttonId).button('toggle'); - $('#' + collapseId).toggleClass('in') + $(`#${buttonId}`).button("toggle"); + $(`#${collapseId}`).toggleClass("in"); } function togglePatchFilterButton(buttonId, collapseId) { - /* Figure out if we are collapsing it */ - if ($('#' + collapseId).hasClass('in')) { - /* Go back to ourselves without a querystring to reset the form, unless it's already empty */ - if (document.location.href.indexOf('?') > -1) { - document.location.href = '.'; - return; - } - } + /* Figure out if we are collapsing it */ + if ($(`#${collapseId}`).hasClass("in")) { + /* Go back to ourselves without a querystring to reset the form, unless it's already empty */ + if (document.location.href.indexOf("?") > -1) { + document.location.href = "."; + return; + } + } - toggleButtonCollapse(buttonId, collapseId); + toggleButtonCollapse(buttonId, collapseId); } - /* * Upstream user search dialog */ function search_and_store_user() { - $('#doSelectUserButton').unbind('click'); - $('#doSelectUserButton').click(function() { - if (!$('#searchUserList').val()) { return false; } + $("#doSelectUserButton").unbind("click"); + $("#doSelectUserButton").click(() => { + if (!$("#searchUserList").val()) { + return false; + } - /* Create this user locally */ - $.get('/ajax/importUser/', { - 'u': $('#searchUserList').val(), - }).success(function(data) { - if (data == 'OK') { - alert('User imported!'); - $('#searchUserModal').modal('hide'); - } else { - alert('Failed to import user: ' + data); - } - }).fail(function(data, statustxt) { - alert('Failed to import user: ' + statustxt); - }); + /* Create this user locally */ + $.get("/ajax/importUser/", { + u: $("#searchUserList").val(), + }) + .success((data) => { + if (data === "OK") { + alert("User imported!"); + $("#searchUserModal").modal("hide"); + } else { + alert(`Failed to import user: ${data}`); + } + }) + .fail((data, statustxt) => { + alert(`Failed to import user: ${statustxt}`); + }); - return false; + return false; }); - $('#searchUserModal').modal(); + $("#searchUserModal").modal(); } function findUsers() { - if (!$('#searchUserSearchField').val()) { - alert('No search term specified'); - return false; + if (!$("#searchUserSearchField").val()) { + alert("No search term specified"); + return false; } - $('#searchUserListWrap').addClass('loading'); - $('#searchUserSearchButton').addClass('disabled'); - $.get('/ajax/searchUsers/', { - 's': $('#searchUserSearchField').val(), - }).success(function(data) { - sel = $('#searchUserList'); - sel.find('option').remove(); - $.each(data, function(i,u) { - sel.append(''); + $("#searchUserListWrap").addClass("loading"); + $("#searchUserSearchButton").addClass("disabled"); + $.get("/ajax/searchUsers/", { + s: $("#searchUserSearchField").val(), + }) + .success((data) => { + sel = $("#searchUserList"); + sel.find("option").remove(); + $.each(data, (i, u) => { + sel.append( + ``, + ); + }); + }) + .always(() => { + $("#searchUserListWrap").removeClass("loading"); + $("#searchUserSearchButton").removeClass("disabled"); + searchUserListChanged(); }); - }).always(function() { - $('#searchUserListWrap').removeClass('loading'); - $('#searchUserSearchButton').removeClass('disabled'); - searchUserListChanged(); - }); - return false; + return false; } function searchUserListChanged() { - if ($('#searchUserList').val()) { - $('#doSelectUserButton').removeClass('disabled'); - } - else { - $('#doSelectUserButton').addClass('disabled'); - } + if ($("#searchUserList").val()) { + $("#doSelectUserButton").removeClass("disabled"); + } else { + $("#doSelectUserButton").addClass("disabled"); + } } function addGitCheckoutToClipboard(patchId) { @@ -317,3 +362,22 @@ git fetch commitfest cf/${patchId} git checkout commitfest/cf/${patchId} `); } + +/* Build our button callbacks */ +$(document).ready(() => { + $("button.attachThreadButton").each((i, o) => { + const b = $(o); + b.click(() => { + $("#attachThreadAttachOnly").val("1"); + browseThreads((msgid, subject) => { + b.prev().val(msgid); + const description_field = $("#id_name"); + if (description_field.val() === "") { + description_field.val(subject); + } + return true; + }); + return false; + }); + }); +}); diff --git a/pgcommitfest/auth.py b/pgcommitfest/auth.py index 9343fc0f..af605119 100644 --- a/pgcommitfest/auth.py +++ b/pgcommitfest/auth.py @@ -24,27 +24,27 @@ # directory that's processed before the default django.contrib.admin) # -from django.http import HttpResponse, HttpResponseRedirect -from django.views.decorators.csrf import csrf_exempt -from django.contrib.auth.models import User -from django.contrib.auth.backends import ModelBackend +from django.conf import settings from django.contrib.auth import login as django_login from django.contrib.auth import logout as django_logout -from django.dispatch import Signal +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.models import User from django.db import transaction -from django.conf import settings +from django.dispatch import Signal +from django.http import HttpResponse, HttpResponseRedirect +from django.views.decorators.csrf import csrf_exempt import base64 +import hmac import json import socket -import hmac -from urllib.parse import urlencode, parse_qs +import time +from urllib.parse import parse_qs, urlencode + import requests +from Cryptodome import Random from Cryptodome.Cipher import AES from Cryptodome.Hash import SHA -from Cryptodome import Random -import time - # This signal fires when a user is created based on data from upstream. auth_user_created_from_upstream = Signal() @@ -66,24 +66,32 @@ def authenticate(self, username=None, password=None): # Two regular django views to interact with the login system #### + # Handle login requests by sending them off to the main site def login(request): - if 'next' in request.GET: + if "next" in request.GET: # Put together an url-encoded dict of parameters we're getting back, # including a small nonce at the beginning to make sure it doesn't # encrypt the same way every time. - s = "t=%s&%s" % (int(time.time()), urlencode({'r': request.GET['next']})) + s = "t=%s&%s" % (int(time.time()), urlencode({"r": request.GET["next"]})) # Now encrypt it r = Random.new() iv = r.read(16) - encryptor = AES.new(SHA.new(settings.SECRET_KEY.encode('ascii')).digest()[:16], AES.MODE_CBC, iv) - cipher = encryptor.encrypt(s.encode('ascii') + b' ' * (16 - (len(s) % 16))) # pad to 16 bytes - - return HttpResponseRedirect("%s?d=%s$%s" % ( - settings.PGAUTH_REDIRECT, - base64.b64encode(iv, b"-_").decode('utf8'), - base64.b64encode(cipher, b"-_").decode('utf8'), - )) + encryptor = AES.new( + SHA.new(settings.SECRET_KEY.encode("ascii")).digest()[:16], AES.MODE_CBC, iv + ) + cipher = encryptor.encrypt( + s.encode("ascii") + b" " * (16 - (len(s) % 16)) + ) # pad to 16 bytes + + return HttpResponseRedirect( + "%s?d=%s$%s" + % ( + settings.PGAUTH_REDIRECT, + base64.b64encode(iv, b"-_").decode("utf8"), + base64.b64encode(cipher, b"-_").decode("utf8"), + ) + ) else: return HttpResponseRedirect(settings.PGAUTH_REDIRECT) @@ -99,21 +107,27 @@ def logout(request): # Receive an authentication response from the main website and try # to log the user in. def auth_receive(request): - if 's' in request.GET and request.GET['s'] == "logout": + if "s" in request.GET and request.GET["s"] == "logout": # This was a logout request - return HttpResponseRedirect('/') + return HttpResponseRedirect("/") - if 'i' not in request.GET: + if "i" not in request.GET: return HttpResponse("Missing IV in url!", status=400) - if 'd' not in request.GET: + if "d" not in request.GET: return HttpResponse("Missing data in url!", status=400) # Set up an AES object and decrypt the data we received try: - decryptor = AES.new(base64.b64decode(settings.PGAUTH_KEY), - AES.MODE_CBC, - base64.b64decode(str(request.GET['i']), "-_")) - s = decryptor.decrypt(base64.b64decode(str(request.GET['d']), "-_")).rstrip(b' ').decode('utf8') + decryptor = AES.new( + base64.b64decode(settings.PGAUTH_KEY), + AES.MODE_CBC, + base64.b64decode(str(request.GET["i"]), "-_"), + ) + s = ( + decryptor.decrypt(base64.b64decode(str(request.GET["d"]), "-_")) + .rstrip(b" ") + .decode("utf8") + ) except UnicodeDecodeError: return HttpResponse("Badly encoded data found", 400) except Exception: @@ -126,23 +140,23 @@ def auth_receive(request): return HttpResponse("Invalid encrypted data received.", status=400) # Check the timestamp in the authentication - if (int(data['t'][0]) < time.time() - 10): + if int(data["t"][0]) < time.time() - 10: return HttpResponse("Authentication token too old.", status=400) # Update the user record (if any) try: - user = User.objects.get(username=data['u'][0]) + user = User.objects.get(username=data["u"][0]) # User found, let's see if any important fields have changed changed = [] - if user.first_name != data['f'][0]: - user.first_name = data['f'][0] - changed.append('first_name') - if user.last_name != data['l'][0]: - user.last_name = data['l'][0] - changed.append('last_name') - if user.email != data['e'][0]: - user.email = data['e'][0] - changed.append('email') + if user.first_name != data["f"][0]: + user.first_name = data["f"][0] + changed.append("first_name") + if user.last_name != data["l"][0]: + user.last_name = data["l"][0] + changed.append("last_name") + if user.email != data["e"][0]: + user.email = data["e"][0] + changed.append("email") if changed: user.save(update_fields=changed) except User.DoesNotExist: @@ -152,8 +166,9 @@ def auth_receive(request): # the database with a different userid. Instead of trying to # somehow fix that live, give a proper error message and # have somebody look at it manually. - if User.objects.filter(email=data['e'][0]).exists(): - return HttpResponse("""A user with email %s already exists, but with + if User.objects.filter(email=data["e"][0]).exists(): + return HttpResponse( + """A user with email %s already exists, but with a different username than %s. This is almost certainly caused by some legacy data in our database. @@ -162,26 +177,30 @@ def auth_receive(request): for you. We apologize for the inconvenience. -""" % (data['e'][0], data['u'][0]), content_type='text/plain') - - if getattr(settings, 'PGAUTH_CREATEUSER_CALLBACK', None): - res = getattr(settings, 'PGAUTH_CREATEUSER_CALLBACK')( - data['u'][0], - data['e'][0], - ['f'][0], - data['l'][0], +""" + % (data["e"][0], data["u"][0]), + content_type="text/plain", + ) + + if getattr(settings, "PGAUTH_CREATEUSER_CALLBACK", None): + res = getattr(settings, "PGAUTH_CREATEUSER_CALLBACK")( + data["u"][0], + data["e"][0], + ["f"][0], + data["l"][0], ) # If anything is returned, we'll return that as our result. # If None is returned, it means go ahead and create the user. if res: return res - user = User(username=data['u'][0], - first_name=data['f'][0], - last_name=data['l'][0], - email=data['e'][0], - password='setbypluginnotasha1', - ) + user = User( + username=data["u"][0], + first_name=data["f"][0], + last_name=data["l"][0], + email=data["e"][0], + password="setbypluginnotasha1", + ) user.save() auth_user_created_from_upstream.send(sender=auth_receive, user=user) @@ -193,39 +212,45 @@ def auth_receive(request): django_login(request, user) # Signal that we have information about this user - auth_user_data_received.send(sender=auth_receive, user=user, userdata={ - 'secondaryemails': data['se'][0].split(',') if 'se' in data else [] - }) + auth_user_data_received.send( + sender=auth_receive, + user=user, + userdata={"secondaryemails": data["se"][0].split(",") if "se" in data else []}, + ) # Finally, check of we have a data package that tells us where to # redirect the user. - if 'd' in data: - (ivs, datas) = data['d'][0].split('$') - decryptor = AES.new(SHA.new(settings.SECRET_KEY.encode('ascii')).digest()[:16], - AES.MODE_CBC, - base64.b64decode(ivs, b"-_")) - s = decryptor.decrypt(base64.b64decode(datas, "-_")).rstrip(b' ').decode('utf8') + if "d" in data: + (ivs, datas) = data["d"][0].split("$") + decryptor = AES.new( + SHA.new(settings.SECRET_KEY.encode("ascii")).digest()[:16], + AES.MODE_CBC, + base64.b64decode(ivs, b"-_"), + ) + s = decryptor.decrypt(base64.b64decode(datas, "-_")).rstrip(b" ").decode("utf8") try: rdata = parse_qs(s, strict_parsing=True) except ValueError: return HttpResponse("Invalid encrypted data received.", status=400) - if 'r' in rdata: + if "r" in rdata: # Redirect address - return HttpResponseRedirect(rdata['r'][0]) + return HttpResponseRedirect(rdata["r"][0]) # No redirect specified, see if we have it in our settings - if hasattr(settings, 'PGAUTH_REDIRECT_SUCCESS'): + if hasattr(settings, "PGAUTH_REDIRECT_SUCCESS"): return HttpResponseRedirect(settings.PGAUTH_REDIRECT_SUCCESS) - return HttpResponse("Authentication successful, but don't know where to redirect!", status=500) + return HttpResponse( + "Authentication successful, but don't know where to redirect!", status=500 + ) # Receive API calls from upstream, such as push changes to users @csrf_exempt def auth_api(request): - if 'X-pgauth-sig' not in request.headers: + if "X-pgauth-sig" not in request.headers: return HttpResponse("Missing signature header!", status=400) try: - sig = base64.b64decode(request.headers['X-pgauth-sig']) + sig = base64.b64decode(request.headers["X-pgauth-sig"]) except Exception: return HttpResponse("Invalid signature header!", status=400) @@ -233,7 +258,7 @@ def auth_api(request): h = hmac.digest( base64.b64decode(settings.PGAUTH_KEY), msg=request.body, - digest='sha512', + digest="sha512", ) if not hmac.compare_digest(h, sig): return HttpResponse("Invalid signature!", status=401) @@ -261,26 +286,38 @@ def _conditionally_update_record(rectype, recordkey, structkey, fieldmap, struct return None # Process the received structure - if pushstruct.get('type', None) == 'update': + if pushstruct.get("type", None) == "update": # Process updates! with transaction.atomic(): - for u in pushstruct.get('users', []): + for u in pushstruct.get("users", []): user = _conditionally_update_record( User, - 'username', 'username', + "username", + "username", { - 'firstname': 'first_name', - 'lastname': 'last_name', - 'email': 'email', + "firstname": "first_name", + "lastname": "last_name", + "email": "email", }, u, ) # Signal that we have information about this user (only if it exists) if user: - auth_user_data_received.send(sender=auth_api, user=user, userdata={ - k: u[k] for k in u.keys() if k not in ['firstname', 'lastname', 'email', ] - }) + auth_user_data_received.send( + sender=auth_api, + user=user, + userdata={ + k: u[k] + for k in u.keys() + if k + not in [ + "firstname", + "lastname", + "email", + ] + }, + ) return HttpResponse("OK", status=200) @@ -297,24 +334,24 @@ def user_search(searchterm=None, userid=None): # 10 seconds is already quite long. socket.setdefaulttimeout(10) if userid: - q = {'u': userid} + q = {"u": userid} else: - q = {'s': searchterm} + q = {"s": searchterm} r = requests.get( - '{0}search/'.format(settings.PGAUTH_REDIRECT), + "{0}search/".format(settings.PGAUTH_REDIRECT), params=q, ) if r.status_code != 200: return [] - (ivs, datas) = r.text.encode('utf8').split(b'&') + (ivs, datas) = r.text.encode("utf8").split(b"&") # Decryption time - decryptor = AES.new(base64.b64decode(settings.PGAUTH_KEY), - AES.MODE_CBC, - base64.b64decode(ivs, "-_")) - s = decryptor.decrypt(base64.b64decode(datas, "-_")).rstrip(b' ').decode('utf8') + decryptor = AES.new( + base64.b64decode(settings.PGAUTH_KEY), AES.MODE_CBC, base64.b64decode(ivs, "-_") + ) + s = decryptor.decrypt(base64.b64decode(datas, "-_")).rstrip(b" ").decode("utf8") j = json.loads(s) return j @@ -324,22 +361,24 @@ def user_search(searchterm=None, userid=None): def subscribe_to_user_changes(userid): socket.setdefaulttimeout(10) - body = json.dumps({ - 'u': userid, - }) + body = json.dumps( + { + "u": userid, + } + ) h = hmac.digest( base64.b64decode(settings.PGAUTH_KEY), - msg=bytes(body, 'utf-8'), - digest='sha512', + msg=bytes(body, "utf-8"), + digest="sha512", ) # Ignore the result code, just post it requests.post( - '{0}subscribe/'.format(settings.PGAUTH_REDIRECT), + "{0}subscribe/".format(settings.PGAUTH_REDIRECT), data=body, headers={ - 'X-pgauth-sig': base64.b64encode(h), + "X-pgauth-sig": base64.b64encode(h), }, ) @@ -359,15 +398,15 @@ def user_import(uid): u = u[0] - if User.objects.filter(username=u['u']).exists(): + if User.objects.filter(username=u["u"]).exists(): raise Exception("User already exists") u = User( - username=u['u'], - first_name=u['f'], - last_name=u['l'], - email=u['e'], - password='setbypluginnotsha1', + username=u["u"], + first_name=u["f"], + last_name=u["l"], + email=u["e"], + password="setbypluginnotsha1", ) u.save() diff --git a/pgcommitfest/commitfest/admin.py b/pgcommitfest/commitfest/admin.py index 35e12be3..8c8d62e5 100644 --- a/pgcommitfest/commitfest/admin.py +++ b/pgcommitfest/commitfest/admin.py @@ -1,10 +1,22 @@ from django.contrib import admin -from .models import * +from .models import ( + CfbotBranch, + CfbotTask, + CommitFest, + Committer, + MailThread, + MailThreadAttachment, + Patch, + PatchHistory, + PatchOnCommitFest, + TargetVersion, + Topic, +) class CommitterAdmin(admin.ModelAdmin): - list_display = ('user', 'active') + list_display = ("user", "active") class PatchOnCommitFestInline(admin.TabularInline): @@ -14,11 +26,16 @@ class PatchOnCommitFestInline(admin.TabularInline): class PatchAdmin(admin.ModelAdmin): inlines = (PatchOnCommitFestInline,) - list_display = ('name', ) + list_display = ("name",) class MailThreadAttachmentAdmin(admin.ModelAdmin): - list_display = ('date', 'author', 'messageid', 'mailthread',) + list_display = ( + "date", + "author", + "messageid", + "mailthread", + ) admin.site.register(Committer, CommitterAdmin) diff --git a/pgcommitfest/commitfest/ajax.py b/pgcommitfest/commitfest/ajax.py index 76c260ce..329a83f9 100644 --- a/pgcommitfest/commitfest/ajax.py +++ b/pgcommitfest/commitfest/ajax.py @@ -1,19 +1,27 @@ -from django.shortcuts import get_object_or_404 -from django.http import HttpResponse, Http404 from django.conf import settings -from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.db import transaction +from django.http import Http404, HttpResponse +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt -import requests import json -import textwrap import re +import textwrap + +import requests from pgcommitfest.auth import user_search -from .models import CommitFest, Patch, MailThread, MailThreadAttachment -from .models import MailThreadAnnotation, PatchHistory + +from .models import ( + CommitFest, + MailThread, + MailThreadAnnotation, + MailThreadAttachment, + Patch, + PatchHistory, +) class HttpResponseServiceUnavailable(HttpResponse): @@ -25,34 +33,38 @@ class Http503(Exception): def mockArchivesAPI(path): - with open(settings.MOCK_ARCHIVE_DATA, 'r', encoding='utf-8') as file: + with open(settings.MOCK_ARCHIVE_DATA, "r", encoding="utf-8") as file: data = json.load(file) for message in data: - message['atts'] = [] + message["atts"] = [] message_pattern = re.compile(r"^/message-id\.json/(?P[^/]+)$") message_match = message_pattern.match(path) if message_match: message_id = message_match.group("message_id") - return [message for message in data if message['msgid'] == message_id] + return [message for message in data if message["msgid"] == message_id] else: return data def _archivesAPI(suburl, params=None): - if getattr(settings, 'MOCK_ARCHIVES', False) and getattr(settings, 'MOCK_ARCHIVE_DATA'): + if getattr(settings, "MOCK_ARCHIVES", False) and getattr( + settings, "MOCK_ARCHIVE_DATA" + ): return mockArchivesAPI(suburl) try: resp = requests.get( - "http{0}://{1}:{2}{3}".format(settings.ARCHIVES_PORT == 443 and 's' or '', - settings.ARCHIVES_SERVER, - settings.ARCHIVES_PORT, - suburl), + "http{0}://{1}:{2}{3}".format( + settings.ARCHIVES_PORT == 443 and "s" or "", + settings.ARCHIVES_SERVER, + settings.ARCHIVES_PORT, + suburl, + ), params=params, headers={ - 'Host': settings.ARCHIVES_HOST, + "Host": settings.ARCHIVES_HOST, }, timeout=settings.ARCHIVES_TIMEOUT, ) @@ -69,41 +81,43 @@ def _archivesAPI(suburl, params=None): def getThreads(request): - search = request.GET.get('s', None) - if request.GET.get('a', '0') == '1': + search = request.GET.get("s", None) + if request.GET.get("a", "0") == "1": attachonly = 1 else: attachonly = 0 # Make a JSON api call to the archives server - params = {'n': 100, 'a': attachonly} + params = {"n": 100, "a": attachonly} if search: - params['s'] = search + params["s"] = search - r = _archivesAPI('/list/pgsql-hackers/latest.json', params) - return sorted(r, key=lambda x: x['date'], reverse=True) + r = _archivesAPI("/list/pgsql-hackers/latest.json", params) + return sorted(r, key=lambda x: x["date"], reverse=True) def getMessages(request): - if 't' not in request.GET: + if "t" not in request.GET: raise Http404("Missing parameter") - threadid = request.GET['t'] + threadid = request.GET["t"] thread = MailThread.objects.get(pk=threadid) # Always make a call over to the archives api - r = _archivesAPI('/message-id.json/%s' % thread.messageid) - return sorted(r, key=lambda x: x['date'], reverse=True) + r = _archivesAPI("/message-id.json/%s" % thread.messageid) + return sorted(r, key=lambda x: x["date"], reverse=True) def refresh_single_thread(thread): - r = sorted(_archivesAPI('/message-id.json/%s' % thread.messageid), key=lambda x: x['date']) - if thread.latestmsgid != r[-1]['msgid']: + r = sorted( + _archivesAPI("/message-id.json/%s" % thread.messageid), key=lambda x: x["date"] + ) + if thread.latestmsgid != r[-1]["msgid"]: # There is now a newer mail in the thread! - thread.latestmsgid = r[-1]['msgid'] - thread.latestmessage = r[-1]['date'] - thread.latestauthor = r[-1]['from'] - thread.latestsubject = r[-1]['subj'] + 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 @@ -115,142 +129,163 @@ def refresh_single_thread(thread): @transaction.atomic def annotateMessage(request): - thread = get_object_or_404(MailThread, pk=int(request.POST['t'])) - msgid = request.POST['msgid'] - msg = request.POST['msg'] + thread = get_object_or_404(MailThread, pk=int(request.POST["t"])) + msgid = request.POST["msgid"] + msg = request.POST["msg"] # Get the subject, author and date from the archives # We only have an API call to get the whole thread right now, so # do that, and then find our entry in it. - r = _archivesAPI('/message-id.json/%s' % thread.messageid) + r = _archivesAPI("/message-id.json/%s" % thread.messageid) for m in r: - if m['msgid'] == msgid: - annotation = MailThreadAnnotation(mailthread=thread, - user=request.user, - msgid=msgid, - annotationtext=msg, - mailsubject=m['subj'], - maildate=m['date'], - mailauthor=m['from']) + if m["msgid"] == msgid: + annotation = MailThreadAnnotation( + mailthread=thread, + user=request.user, + msgid=msgid, + annotationtext=msg, + mailsubject=m["subj"], + maildate=m["date"], + mailauthor=m["from"], + ) annotation.save() for p in thread.patches.all(): - PatchHistory(patch=p, by=request.user, what='Added annotation "%s" to %s' % (textwrap.shorten(msg, 100), msgid)).save_and_notify() + PatchHistory( + patch=p, + by=request.user, + what='Added annotation "%s" to %s' + % (textwrap.shorten(msg, 100), msgid), + ).save_and_notify() p.set_modified() p.save() - return 'OK' - return 'Message not found in thread!' + return "OK" + return "Message not found in thread!" @transaction.atomic def deleteAnnotation(request): - annotation = get_object_or_404(MailThreadAnnotation, pk=request.POST['id']) + annotation = get_object_or_404(MailThreadAnnotation, pk=request.POST["id"]) for p in annotation.mailthread.patches.all(): - PatchHistory(patch=p, by=request.user, what='Deleted annotation "%s" from %s' % (annotation.annotationtext, annotation.msgid)).save_and_notify() + PatchHistory( + patch=p, + by=request.user, + what='Deleted annotation "%s" from %s' + % (annotation.annotationtext, annotation.msgid), + ).save_and_notify() p.set_modified() p.save() annotation.delete() - return 'OK' + return "OK" def parse_and_add_attachments(threadinfo, mailthread): for t in threadinfo: - if len(t['atts']): + if len(t["atts"]): # One or more attachments. For now, we're only actually going # to store and process the first one, even though the API gets # us all of them. - MailThreadAttachment.objects.get_or_create(mailthread=mailthread, - messageid=t['msgid'], - defaults={ - 'date': t['date'], - 'author': t['from'], - 'attachmentid': t['atts'][0]['id'], - 'filename': t['atts'][0]['name'], - }) + MailThreadAttachment.objects.get_or_create( + mailthread=mailthread, + messageid=t["msgid"], + defaults={ + "date": t["date"], + "author": t["from"], + "attachmentid": t["atts"][0]["id"], + "filename": t["atts"][0]["name"], + }, + ) # In theory we should remove objects if they don't have an # attachment, but how could that ever happen? Ignore for now. @transaction.atomic def attachThread(request): - cf = get_object_or_404(CommitFest, pk=int(request.POST['cf'])) - patch = get_object_or_404(Patch, pk=int(request.POST['p']), commitfests=cf) - msgid = request.POST['msg'] + cf = get_object_or_404(CommitFest, pk=int(request.POST["cf"])) + patch = get_object_or_404(Patch, pk=int(request.POST["p"]), commitfests=cf) + msgid = request.POST["msg"] return doAttachThread(cf, patch, msgid, request.user) def doAttachThread(cf, patch, msgid, user): # Note! Must be called in an open transaction! - r = sorted(_archivesAPI('/message-id.json/%s' % msgid), key=lambda x: x['date']) + r = sorted(_archivesAPI("/message-id.json/%s" % msgid), key=lambda x: x["date"]) # We have the full thread metadata - using the first and last entry, # construct a new mailthread in our own model. # First, though, check if it's already there. - threads = MailThread.objects.filter(messageid=r[0]['msgid']) + threads = MailThread.objects.filter(messageid=r[0]["msgid"]) if len(threads): thread = threads[0] if thread.patches.filter(id=patch.id).exists(): - return 'This thread is already added to this email' + return "This thread is already added to this email" # We did not exist, so we'd better add ourselves. # While at it, we update the thread entry with the latest data from the # archives. thread.patches.add(patch) - thread.latestmessage = r[-1]['date'] - thread.latestauthor = r[-1]['from'] - thread.latestsubject = r[-1]['subj'] - thread.latestmsgid = r[-1]['msgid'] + thread.latestmessage = r[-1]["date"] + thread.latestauthor = r[-1]["from"] + thread.latestsubject = r[-1]["subj"] + thread.latestmsgid = r[-1]["msgid"] thread.save() else: # No existing thread existed, so create it # Now create a new mailthread entry - m = MailThread(messageid=r[0]['msgid'], - subject=r[0]['subj'], - firstmessage=r[0]['date'], - firstauthor=r[0]['from'], - latestmessage=r[-1]['date'], - latestauthor=r[-1]['from'], - latestsubject=r[-1]['subj'], - latestmsgid=r[-1]['msgid'], - ) + m = MailThread( + messageid=r[0]["msgid"], + subject=r[0]["subj"], + firstmessage=r[0]["date"], + firstauthor=r[0]["from"], + latestmessage=r[-1]["date"], + latestauthor=r[-1]["from"], + latestsubject=r[-1]["subj"], + latestmsgid=r[-1]["msgid"], + ) m.save() m.patches.add(patch) m.save() parse_and_add_attachments(r, m) - PatchHistory(patch=patch, by=user, what='Attached mail thread %s' % r[0]['msgid']).save_and_notify() + PatchHistory( + patch=patch, by=user, what="Attached mail thread %s" % r[0]["msgid"] + ).save_and_notify() patch.update_lastmail() patch.set_modified() patch.save() - return 'OK' + return "OK" @transaction.atomic def detachThread(request): - cf = get_object_or_404(CommitFest, pk=int(request.POST['cf'])) - patch = get_object_or_404(Patch, pk=int(request.POST['p']), commitfests=cf) - thread = get_object_or_404(MailThread, messageid=request.POST['msg']) + cf = get_object_or_404(CommitFest, pk=int(request.POST["cf"])) + patch = get_object_or_404(Patch, pk=int(request.POST["p"]), commitfests=cf) + thread = get_object_or_404(MailThread, messageid=request.POST["msg"]) patch.mailthread_set.remove(thread) - PatchHistory(patch=patch, by=request.user, what='Detached mail thread %s' % request.POST['msg']).save_and_notify() + PatchHistory( + patch=patch, + by=request.user, + what="Detached mail thread %s" % request.POST["msg"], + ).save_and_notify() patch.update_lastmail() patch.set_modified() patch.save() - return 'OK' + return "OK" def searchUsers(request): if not request.user.is_staff: return [] - if request.GET.get('s', ''): - return user_search(request.GET['s']) + if request.GET.get("s", ""): + return user_search(request.GET["s"]) else: return [] @@ -259,35 +294,36 @@ def importUser(request): if not request.user.is_staff: raise Http404() - if request.GET.get('u', ''): - u = user_search(userid=request.GET['u']) + if request.GET.get("u", ""): + u = user_search(userid=request.GET["u"]) if len(u) != 1: return "Internal error, duplicate user found" u = u[0] - if User.objects.filter(username=u['u']).exists(): + if User.objects.filter(username=u["u"]).exists(): return "User already exists" - User(username=u['u'], - first_name=u['f'], - last_name=u['l'], - email=u['e'], - password='setbypluginnotsha1', - ).save() - return 'OK' + User( + username=u["u"], + first_name=u["f"], + last_name=u["l"], + email=u["e"], + password="setbypluginnotsha1", + ).save() + return "OK" else: raise Http404() _ajax_map = { - 'getThreads': getThreads, - 'getMessages': getMessages, - 'attachThread': attachThread, - 'detachThread': detachThread, - 'annotateMessage': annotateMessage, - 'deleteAnnotation': deleteAnnotation, - 'searchUsers': searchUsers, - 'importUser': importUser, + "getThreads": getThreads, + "getMessages": getMessages, + "attachThread": attachThread, + "detachThread": detachThread, + "annotateMessage": annotateMessage, + "deleteAnnotation": deleteAnnotation, + "searchUsers": searchUsers, + "importUser": importUser, } @@ -298,8 +334,8 @@ def main(request, command): if command not in _ajax_map: raise Http404 try: - resp = HttpResponse(content_type='application/json') + resp = HttpResponse(content_type="application/json") json.dump(_ajax_map[command](request), resp) return resp except Http503 as e: - return HttpResponseServiceUnavailable(e, content_type='text/plain') + return HttpResponseServiceUnavailable(e, content_type="text/plain") diff --git a/pgcommitfest/commitfest/apps.py b/pgcommitfest/commitfest/apps.py index e47efed8..7dbe4cb2 100644 --- a/pgcommitfest/commitfest/apps.py +++ b/pgcommitfest/commitfest/apps.py @@ -2,7 +2,7 @@ class CFAppConfig(AppConfig): - name = 'pgcommitfest.commitfest' + name = "pgcommitfest.commitfest" def ready(self): from pgcommitfest.auth import auth_user_data_received diff --git a/pgcommitfest/commitfest/feeds.py b/pgcommitfest/commitfest/feeds.py index aa950fb3..9aff9025 100644 --- a/pgcommitfest/commitfest/feeds.py +++ b/pgcommitfest/commitfest/feeds.py @@ -2,15 +2,17 @@ class ActivityFeed(Feed): - title = description = 'Commitfest Activity Log' - link = 'https://commitfest.postgresql.org/' + title = description = "Commitfest Activity Log" + link = "https://commitfest.postgresql.org/" def __init__(self, activity, cf, *args, **kwargs): super(ActivityFeed, self).__init__(*args, **kwargs) self.activity = activity if cf: self.cfid = cf.id - self.title = self.description = 'PostgreSQL Commitfest {0} Activity Log'.format(cf.name) + self.title = self.description = ( + "PostgreSQL Commitfest {0} Activity Log".format(cf.name) + ) else: self.cfid = None @@ -18,16 +20,22 @@ def items(self): return self.activity def item_title(self, item): - return item['name'] + return item["name"] def item_description(self, item): - return "
Patch: {name}
User: {by}
\n
{what}
".format(**item) + return ( + "
Patch: {name}
User: {by}
\n
{what}
".format( + **item + ) + ) def item_link(self, item): if self.cfid: - return 'https://commitfest.postgresql.org/{0}/{1}/'.format(self.cfid, item['patchid']) + 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/{cfid}/{patchid}/".format(**item) def item_pubdate(self, item): - return item['date'] + return item["date"] diff --git a/pgcommitfest/commitfest/fixtures/auth_data.json b/pgcommitfest/commitfest/fixtures/auth_data.json index bfaf3bfb..88d8c708 100644 --- a/pgcommitfest/commitfest/fixtures/auth_data.json +++ b/pgcommitfest/commitfest/fixtures/auth_data.json @@ -4,15 +4,87 @@ "pk": 1, "fields": { "password": "pbkdf2_sha256$600000$49rgHaLmmFQUm7c663LCrU$i68PFeI493lPmgNx/RHnWNuw4ZRzzvJWNqU4os5VnF4=", - "last_login": "2025-01-26T10:43:07.735", + "last_login": "2025-02-16T22:08:24.849", "is_superuser": true, "username": "admin", + "first_name": "Admin", + "last_name": "Adminus", + "email": "admin@example.com", + "is_staff": true, + "is_active": true, + "date_joined": "2025-01-20T15:47:04", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$600000$bbuE9W0Hu6YOWlLTi0kh9P$YUDGk6vwA4G0YupCfgQXJ6xVIoqcgO+i6Ss671qGTWY=", + "last_login": null, + "is_superuser": false, + "username": "staff", + "first_name": "Staff", + "last_name": "Staffer", + "email": "staff@example.com", + "is_staff": true, + "is_active": true, + "date_joined": "2025-02-16T21:52:04", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$600000$XrYDPbhffDlFYchBg7ex0o$e5NRZ+aVyh2w2d+R8/0eYUj0KEaB7X8eXd5SKjRnntk=", + "last_login": null, + "is_superuser": false, + "username": "normal", + "first_name": "Normie", + "last_name": "Normal", + "email": "normal@example.com", + "is_staff": false, + "is_active": true, + "date_joined": "2025-02-16T21:53:59", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 4, + "fields": { + "password": "pbkdf2_sha256$600000$hKZrucSkU4ETpCUsouasTH$rKrcOQ0Rv18nTRT6jLwj7ZH7XwEhFuwvLA9SqNI8lEs=", + "last_login": null, + "is_superuser": false, + "username": "committer", + "first_name": "Powerful", + "last_name": "Committer", + "email": "committer@example.com", + "is_staff": false, + "is_active": true, + "date_joined": "2025-02-16T22:08:45", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 5, + "fields": { + "password": "pbkdf2_sha256$600000$MYiA9SBvmUXrBXs0g0XxAc$IQOjsMvTMNCX3xTrrnsc+caaScaxdqfVgmxjkqMY1Ps=", + "last_login": null, + "is_superuser": false, + "username": "inactive-committer", "first_name": "", "last_name": "", - "email": "test@test.com", - "is_staff": true, + "email": "", + "is_staff": false, "is_active": true, - "date_joined": "2025-01-20T15:47:04.132", + "date_joined": "2025-02-16T22:09:18.306", "groups": [], "user_permissions": [] } diff --git a/pgcommitfest/commitfest/fixtures/commitfest_data.json b/pgcommitfest/commitfest/fixtures/commitfest_data.json index 7bd54001..6e5b32ff 100644 --- a/pgcommitfest/commitfest/fixtures/commitfest_data.json +++ b/pgcommitfest/commitfest/fixtures/commitfest_data.json @@ -1,4 +1,25 @@ [ +{ + "model": "commitfest.committer", + "pk": 1, + "fields": { + "active": true + } +}, +{ + "model": "commitfest.committer", + "pk": 4, + "fields": { + "active": true + } +}, +{ + "model": "commitfest.committer", + "pk": 5, + "fields": { + "active": false + } +}, { "model": "commitfest.commitfest", "pk": 1, @@ -153,6 +174,69 @@ ] } }, +{ + "model": "commitfest.patch", + "pk": 5, + "fields": { + "name": "Add get_bytes() and set_bytes() functions", + "topic": 3, + "wikilink": "", + "gitlink": "", + "targetversion": null, + "committer": 4, + "created": "2025-02-16T21:59:04.131", + "modified": "2025-02-16T22:03:24.902", + "lastmail": "2025-01-20T14:01:53", + "authors": [], + "reviewers": [], + "subscribers": [], + "mailthread_set": [ + 5 + ] + } +}, +{ + "model": "commitfest.patch", + "pk": 6, + "fields": { + "name": "Add RESPECT/IGNORE NULLS and FROM FIRST/LAST options", + "topic": 3, + "wikilink": "", + "gitlink": "", + "targetversion": null, + "committer": null, + "created": "2025-02-16T22:03:58.476", + "modified": "2025-02-16T22:04:23.180", + "lastmail": "2025-01-19T23:55:17", + "authors": [], + "reviewers": [], + "subscribers": [], + "mailthread_set": [ + 6 + ] + } +}, +{ + "model": "commitfest.patch", + "pk": 7, + "fields": { + "name": "Old BufferDesc refcount in PrintBufferDescs and PrintPinnedBufs", + "topic": 3, + "wikilink": "", + "gitlink": "", + "targetversion": null, + "committer": null, + "created": "2025-03-01T22:27:53.214", + "modified": "2025-03-01T22:27:53.221", + "lastmail": "2025-01-18T07:14:02", + "authors": [], + "reviewers": [], + "subscribers": [], + "mailthread_set": [ + 7 + ] + } +}, { "model": "commitfest.patchoncommitfest", "pk": 1, @@ -208,6 +292,39 @@ "status": 1 } }, +{ + "model": "commitfest.patchoncommitfest", + "pk": 6, + "fields": { + "patch": 5, + "commitfest": 2, + "enterdate": "2025-02-16T21:59:04.131", + "leavedate": "2025-02-16T22:03:24.896", + "status": 4 + } +}, +{ + "model": "commitfest.patchoncommitfest", + "pk": 7, + "fields": { + "patch": 6, + "commitfest": 2, + "enterdate": "2025-02-16T22:03:58.477", + "leavedate": "2025-02-16T22:04:18.213", + "status": 6 + } +}, +{ + "model": "commitfest.patchoncommitfest", + "pk": 8, + "fields": { + "patch": 7, + "commitfest": 2, + "enterdate": "2025-03-01T22:27:53.214", + "leavedate": null, + "status": 1 + } +}, { "model": "commitfest.patchhistory", "pk": 1, @@ -318,6 +435,105 @@ "what": "Attached mail thread example@message-4" } }, +{ + "model": "commitfest.patchhistory", + "pk": 11, + "fields": { + "patch": 5, + "date": "2025-02-16T21:59:04.132", + "by": 1, + "by_cfbot": false, + "what": "Created patch record" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 12, + "fields": { + "patch": 5, + "date": "2025-02-16T21:59:04.134", + "by": 1, + "by_cfbot": false, + "what": "Attached mail thread example@message-1" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 13, + "fields": { + "patch": 5, + "date": "2025-02-16T22:03:24.897", + "by": 1, + "by_cfbot": false, + "what": "Changed committer to committer" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 14, + "fields": { + "patch": 5, + "date": "2025-02-16T22:03:24.903", + "by": 1, + "by_cfbot": false, + "what": "Closed in commitfest Sample In Progress Commitfest with status: Committed" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 15, + "fields": { + "patch": 6, + "date": "2025-02-16T22:03:58.479", + "by": 1, + "by_cfbot": false, + "what": "Created patch record" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 16, + "fields": { + "patch": 6, + "date": "2025-02-16T22:03:58.486", + "by": 1, + "by_cfbot": false, + "what": "Attached mail thread example@message-16" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 17, + "fields": { + "patch": 6, + "date": "2025-02-16T22:04:18.217", + "by": 1, + "by_cfbot": false, + "what": "Closed in commitfest Sample In Progress Commitfest with status: Rejected" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 18, + "fields": { + "patch": 7, + "date": "2025-03-01T22:27:53.215", + "by": 1, + "by_cfbot": false, + "what": "Created patch record" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 19, + "fields": { + "patch": 7, + "date": "2025-03-01T22:27:53.218", + "by": 1, + "by_cfbot": false, + "what": "Attached mail thread example@message-31" + } +}, { "model": "commitfest.mailthread", "pk": 1, @@ -374,6 +590,48 @@ "latestmsgid": "example@message-4" } }, +{ + "model": "commitfest.mailthread", + "pk": 5, + "fields": { + "messageid": "example@message-1", + "subject": "Re: [PATCH] Add get_bytes() and set_bytes() functions", + "firstmessage": "2025-01-20T14:01:53", + "firstauthor": "test@test.com", + "latestmessage": "2025-01-20T14:01:53", + "latestauthor": "test@test.com", + "latestsubject": "Re: [PATCH] Add get_bytes() and set_bytes() functions", + "latestmsgid": "example@message-1" + } +}, +{ + "model": "commitfest.mailthread", + "pk": 6, + "fields": { + "messageid": "example@message-16", + "subject": "Re: Add RESPECT/IGNORE NULLS and FROM FIRST/LAST options", + "firstmessage": "2025-01-19T23:55:17", + "firstauthor": "test@test.com", + "latestmessage": "2025-01-19T23:55:17", + "latestauthor": "test@test.com", + "latestsubject": "Re: Add RESPECT/IGNORE NULLS and FROM FIRST/LAST options", + "latestmsgid": "example@message-16" + } +}, +{ + "model": "commitfest.mailthread", + "pk": 7, + "fields": { + "messageid": "example@message-31", + "subject": "Re: Old BufferDesc refcount in PrintBufferDescs and PrintPinnedBufs", + "firstmessage": "2025-01-18T07:14:02", + "firstauthor": "test@test.com", + "latestmessage": "2025-01-18T07:14:02", + "latestauthor": "test@test.com", + "latestsubject": "Re: Old BufferDesc refcount in PrintBufferDescs and PrintPinnedBufs", + "latestmsgid": "example@message-31" + } +}, { "model": "commitfest.patchstatus", "pk": 1, @@ -448,6 +706,7 @@ "apply_url": "http://cfbot.cputube.org/patch_4573.log", "status": "finished", "needs_rebase_since": null, + "failing_since": null, "created": "2025-01-26T22:06:02.980", "modified": "2025-01-29T22:50:37.805", "version": "", @@ -467,10 +726,11 @@ "commit_id": null, "apply_url": "http://cfbot.cputube.org/patch_4573.log", "status": "failed", - "needs_rebase_since": null, + "needs_rebase_since": "2025-03-01T22:30:42", + "failing_since": "2025-02-01T22:30:42", "created": "2025-01-26T22:11:09.961", - "modified": "2025-01-26T22:20:39.372", - "version": null, + "modified": "2025-03-01T22:59:14.717", + "version": "", "patch_count": null, "first_additions": null, "first_deletions": null, @@ -488,8 +748,9 @@ "apply_url": "http://cfbot.cputube.org/patch_4748.log", "status": "failed", "needs_rebase_since": null, + "failing_since": "2025-03-01T23:18:06", "created": "2025-01-26T22:22:46.602", - "modified": "2025-01-29T22:58:51.032", + "modified": "2025-03-01T23:18:10.856", "version": "", "patch_count": 3, "first_additions": 345, @@ -508,6 +769,7 @@ "apply_url": "http://cfbot.cputube.org/patch_4748.log", "status": "testing", "needs_rebase_since": null, + "failing_since": null, "created": "2025-01-31T13:32:22.017", "modified": "2025-01-31T13:32:22.017", "version": "", @@ -518,6 +780,27 @@ "all_deletions": 14 } }, +{ + "model": "commitfest.cfbotbranch", + "pk": 7, + "fields": { + "branch_id": 12, + "branch_name": "cf/7", + "commit_id": "efg123", + "apply_url": "http://cfbot.cputube.org/patch_7.log", + "status": "timeout", + "needs_rebase_since": null, + "failing_since": "2025-03-01T22:29:07", + "created": "2025-03-01T22:29:25.461", + "modified": "2025-03-01T22:30:14.495", + "version": "", + "patch_count": 1, + "first_additions": 1, + "first_deletions": 2, + "all_additions": 1, + "all_deletions": 2 + } +}, { "model": "commitfest.cfbottask", "pk": 1, diff --git a/pgcommitfest/commitfest/forms.py b/pgcommitfest/commitfest/forms.py index ec0c62ae..c3b9a18d 100644 --- a/pgcommitfest/commitfest/forms.py +++ b/pgcommitfest/commitfest/forms.py @@ -1,59 +1,101 @@ from django import forms +from django.contrib.auth.models import User from django.forms import ValidationError from django.forms.widgets import HiddenInput -from django.db.models import Q -from django.contrib.auth.models import User from django.http import Http404 -from .models import Patch, MailThread, PatchOnCommitFest, TargetVersion -from .widgets import ThreadPickWidget from .ajax import _archivesAPI +from .models import MailThread, Patch, PatchOnCommitFest, TargetVersion +from .widgets import ThreadPickWidget class CommitFestFilterForm(forms.Form): + selectize_fields = { + "author": "/lookups/user", + "reviewer": "/lookups/user", + } + text = forms.CharField(max_length=50, required=False) status = forms.ChoiceField(required=False) targetversion = forms.ChoiceField(required=False) - author = forms.ChoiceField(required=False) - reviewer = forms.ChoiceField(required=False) + author = forms.ChoiceField(required=False, label="Author (type to search)") + reviewer = forms.ChoiceField(required=False, label="Reviewer (type to search)") sortkey = forms.IntegerField(required=False) - def __init__(self, cf, *args, **kwargs): - super(CommitFestFilterForm, self).__init__(*args, **kwargs) + def __init__(self, data, *args, **kwargs): + super(CommitFestFilterForm, self).__init__(data, *args, **kwargs) + + self.fields["sortkey"].widget = forms.HiddenInput() - self.fields['sortkey'].widget = forms.HiddenInput() + c = [(-1, "* All")] + list(PatchOnCommitFest._STATUS_CHOICES) + self.fields["status"] = forms.ChoiceField(choices=c, required=False) - c = [(-1, '* All')] + list(PatchOnCommitFest._STATUS_CHOICES) - self.fields['status'] = forms.ChoiceField(choices=c, required=False) + userchoices = [(-1, "* All"), (-2, "* None"), (-3, "* Yourself")] - q = Q(patch_author__commitfests=cf) | Q(patch_reviewer__commitfests=cf) - userchoices = [(-1, '* All'), (-2, '* None'), (-3, '* Yourself')] + [(u.id, '%s %s (%s)' % (u.first_name, u.last_name, u.username)) for u in User.objects.filter(q).distinct().order_by('first_name', 'last_name')] - self.fields['targetversion'] = forms.ChoiceField(choices=[('-1', '* All'), ('-2', '* None')] + [(v.id, v.version) for v in TargetVersion.objects.all()], required=False, label="Target version") - self.fields['author'] = forms.ChoiceField(choices=userchoices, required=False) - self.fields['reviewer'] = forms.ChoiceField(choices=userchoices, required=False) + selected_user_ids = set() + if data and "author" in data: + try: + selected_user_ids.add(int(data["author"])) + except ValueError: + pass - for f in ('status', 'author', 'reviewer',): - self.fields[f].widget.attrs = {'class': 'input-medium'} + if data and "reviewer" in data: + try: + selected_user_ids.add(int(data["reviewer"])) + except ValueError: + pass + + if selected_user_ids: + userchoices.extend( + (u.id, f"{u.first_name} {u.last_name} ({u.username})") + for u in User.objects.filter(pk__in=selected_user_ids) + ) + + self.fields["targetversion"] = forms.ChoiceField( + choices=[("-1", "* All"), ("-2", "* None")] + + [(v.id, v.version) for v in TargetVersion.objects.all()], + required=False, + label="Target version", + ) + self.fields["author"].choices = userchoices + self.fields["reviewer"].choices = userchoices + + for f in ( + "status", + "author", + "reviewer", + ): + self.fields[f].widget.attrs = {"class": "input-medium"} class PatchForm(forms.ModelForm): - selectize_multiple_fields = { - 'authors': '/lookups/user', - 'reviewers': '/lookups/user', + selectize_fields = { + "authors": "/lookups/user", + "reviewers": "/lookups/user", } class Meta: model = Patch - exclude = ('commitfests', 'mailthread_set', 'modified', 'lastmail', 'subscribers', ) + exclude = ( + "commitfests", + "mailthread_set", + "modified", + "lastmail", + "subscribers", + ) def __init__(self, *args, **kwargs): super(PatchForm, self).__init__(*args, **kwargs) - self.fields['authors'].help_text = 'Enter part of name to see list' - self.fields['reviewers'].help_text = 'Enter part of name to see list' - self.fields['committer'].label_from_instance = lambda x: '%s %s (%s)' % (x.user.first_name, x.user.last_name, x.user.username) + self.fields["authors"].help_text = "Enter part of name to see list" + self.fields["reviewers"].help_text = "Enter part of name to see list" + self.fields["committer"].label_from_instance = lambda x: "%s %s (%s)" % ( + x.user.first_name, + x.user.last_name, + x.user.username, + ) # Selectize multiple fields -- don't pre-populate everything - for field, url in list(self.selectize_multiple_fields.items()): + for field, url in list(self.selectize_fields.items()): # If this is a postback of a selectize field, it may contain ids that are not currently # stored in the field. They must still be among the *allowed* values of course, which # are handled by the existing queryset on the field. @@ -64,89 +106,136 @@ def __init__(self, *args, **kwargs): vals = [o.pk for o in getattr(self.instance, field).all()] else: vals = [] - if 'data' in kwargs and str(field) in kwargs['data']: - vals.extend([x for x in kwargs['data'].getlist(field)]) - self.fields[field].widget.attrs['data-selecturl'] = url - self.fields[field].queryset = self.fields[field].queryset.filter(pk__in=set(vals)) - self.fields[field].label_from_instance = lambda u: '{} ({})'.format(u.username, u.get_full_name()) - + if "data" in kwargs and str(field) in kwargs["data"]: + vals.extend([x for x in kwargs["data"].getlist(field)]) + self.fields[field].widget.attrs["data-selecturl"] = url + self.fields[field].queryset = self.fields[field].queryset.filter( + pk__in=set(vals) + ) + self.fields[field].label_from_instance = ( + lambda u: f"{u.get_full_name()} ({u.username})" + ) + + # Only allow modifying reviewers and committers if the patch is closed. + if ( + self.instance.id is None + or self.instance.current_patch_on_commitfest().is_open + ): + del self.fields["committer"] + del self.fields["reviewers"] + + +class NewPatchForm(PatchForm): + # Put threadmsgid first + field_order = ["threadmsgid"] + + threadmsgid = forms.CharField( + max_length=200, + required=True, + label="Specify thread msgid", + widget=ThreadPickWidget, + ) -class NewPatchForm(forms.ModelForm): - threadmsgid = forms.CharField(max_length=200, required=True, label='Specify thread msgid', widget=ThreadPickWidget) -# patchfile = forms.FileField(allow_empty_file=False, max_length=50000, label='or upload patch file', required=False, help_text='This may be supported sometime in the future, and would then autogenerate a mail to the hackers list. At such a time, the threadmsgid would no longer be required.') + def __init__(self, *args, **kwargs): + request = kwargs.pop("request", None) + super(NewPatchForm, self).__init__(*args, **kwargs) - class Meta: - model = Patch - fields = ('name', 'topic', ) + if request: + self.fields["authors"].queryset = User.objects.filter(pk=request.user.id) + self.fields["authors"].initial = [request.user.id] def clean_threadmsgid(self): try: - _archivesAPI('/message-id.json/%s' % self.cleaned_data['threadmsgid']) + _archivesAPI("/message-id.json/%s" % self.cleaned_data["threadmsgid"]) except Http404: raise ValidationError("Message not found in archives") except Exception: raise ValidationError("Error in API call to validate thread") - return self.cleaned_data['threadmsgid'] + return self.cleaned_data["threadmsgid"] def _fetch_thread_choices(patch): - for mt in patch.mailthread_set.order_by('-latestmessage'): - ti = sorted(_archivesAPI('/message-id.json/%s' % mt.messageid), key=lambda x: x['date'], reverse=True) - yield [mt.subject, - [('%s,%s' % (mt.messageid, t['msgid']), 'From %s at %s' % (t['from'], t['date'])) for t in ti]] + for mt in patch.mailthread_set.order_by("-latestmessage"): + ti = sorted( + _archivesAPI("/message-id.json/%s" % mt.messageid), + key=lambda x: x["date"], + reverse=True, + ) + yield [ + mt.subject, + [ + ( + "%s,%s" % (mt.messageid, t["msgid"]), + "From %s at %s" % (t["from"], t["date"]), + ) + for t in ti + ], + ] review_state_choices = ( - (0, 'Tested'), - (1, 'Passed'), + (0, "Tested"), + (1, "Passed"), ) def reviewfield(label): - return forms.MultipleChoiceField(choices=review_state_choices, label=label, widget=forms.CheckboxSelectMultiple, required=False) + return forms.MultipleChoiceField( + choices=review_state_choices, + label=label, + widget=forms.CheckboxSelectMultiple, + required=False, + ) class CommentForm(forms.Form): - responseto = forms.ChoiceField(choices=[], required=True, label='In response to') + responseto = forms.ChoiceField(choices=[], required=True, label="In response to") # Specific checkbox fields for reviews - review_installcheck = reviewfield('make installcheck-world') - review_implements = reviewfield('Implements feature') - review_spec = reviewfield('Spec compliant') - review_doc = reviewfield('Documentation') + review_installcheck = reviewfield("make installcheck-world") + review_implements = reviewfield("Implements feature") + review_spec = reviewfield("Spec compliant") + review_doc = reviewfield("Documentation") message = forms.CharField(required=True, widget=forms.Textarea) - newstatus = forms.ChoiceField(choices=PatchOnCommitFest.OPEN_STATUS_CHOICES(), label='New status') + newstatus = forms.ChoiceField( + choices=PatchOnCommitFest.OPEN_STATUS_CHOICES(), label="New status" + ) def __init__(self, patch, poc, is_review, *args, **kwargs): super(CommentForm, self).__init__(*args, **kwargs) self.is_review = is_review - self.fields['responseto'].choices = _fetch_thread_choices(patch) - self.fields['newstatus'].initial = poc.status + self.fields["responseto"].choices = _fetch_thread_choices(patch) + self.fields["newstatus"].initial = poc.status if not is_review: - del self.fields['review_installcheck'] - del self.fields['review_implements'] - del self.fields['review_spec'] - del self.fields['review_doc'] + del self.fields["review_installcheck"] + del self.fields["review_implements"] + del self.fields["review_spec"] + del self.fields["review_doc"] def clean_responseto(self): try: - (threadid, respid) = self.cleaned_data['responseto'].split(',') + (threadid, respid) = self.cleaned_data["responseto"].split(",") self.thread = MailThread.objects.get(messageid=threadid) self.respid = respid except MailThread.DoesNotExist: - raise ValidationError('Selected thread appears to no longer exist') + raise ValidationError("Selected thread appears to no longer exist") except Exception: - raise ValidationError('Invalid message selected') - return self.cleaned_data['responseto'] + raise ValidationError("Invalid message selected") + return self.cleaned_data["responseto"] def clean(self): if self.is_review: for fn, f in self.fields.items(): - if fn.startswith('review_') and fn in self.cleaned_data: - if '1' in self.cleaned_data[fn] and '0' not in self.cleaned_data[fn]: - self.errors[fn] = (('Cannot pass a test without performing it!'),) + if fn.startswith("review_") and fn in self.cleaned_data: + if ( + "1" in self.cleaned_data[fn] + and "0" not in self.cleaned_data[fn] + ): + self.errors[fn] = ( + ("Cannot pass a test without performing it!"), + ) return self.cleaned_data @@ -155,7 +244,7 @@ class BulkEmailForm(forms.Form): authors = forms.CharField(required=False, widget=HiddenInput()) subject = forms.CharField(required=True) body = forms.CharField(required=True, widget=forms.Textarea) - confirm = forms.BooleanField(required=True, label='Check to confirm sending') + confirm = forms.BooleanField(required=True, label="Check to confirm sending") def __init__(self, *args, **kwargs): super(BulkEmailForm, self).__init__(*args, **kwargs) diff --git a/pgcommitfest/commitfest/lookups.py b/pgcommitfest/commitfest/lookups.py index 229459c6..949f4962 100644 --- a/pgcommitfest/commitfest/lookups.py +++ b/pgcommitfest/commitfest/lookups.py @@ -1,22 +1,35 @@ -from django.http import HttpResponse, Http404 -from django.db.models import Q from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User +from django.db.models import Q +from django.http import Http404, HttpResponse import json @login_required def userlookup(request): - query = request.GET.get('query', None) + query = request.GET.get("query", None) if not query: raise Http404() users = User.objects.filter( Q(is_active=True), - Q(username__icontains=query) | Q(first_name__icontains=query) | Q(last_name__icontains=query), + Q(username__icontains=query) + | Q(first_name__icontains=query) + | Q(last_name__icontains=query), ) - return HttpResponse(json.dumps({ - 'values': [{'id': u.id, 'value': '{} ({})'.format(u.username, u.get_full_name())} for u in users], - }), content_type='application/json') + return HttpResponse( + json.dumps( + { + "values": [ + { + "id": u.id, + "value": f"{u.get_full_name()} ({u.username})", + } + for u in users + ], + } + ), + content_type="application/json", + ) diff --git a/pgcommitfest/commitfest/management/commands/send_notifications.py b/pgcommitfest/commitfest/management/commands/send_notifications.py index cb2ef143..728c7f99 100644 --- a/pgcommitfest/commitfest/management/commands/send_notifications.py +++ b/pgcommitfest/commitfest/management/commands/send_notifications.py @@ -1,12 +1,10 @@ +from django.conf import settings from django.core.management.base import BaseCommand from django.db import transaction -from django.conf import settings - -from io import StringIO from pgcommitfest.commitfest.models import PendingNotification -from pgcommitfest.userprofile.models import UserProfile from pgcommitfest.mailqueue.util import send_template_mail +from pgcommitfest.userprofile.models import UserProfile class Command(BaseCommand): @@ -17,17 +15,24 @@ def handle(self, *args, **options): # Django doesn't do proper group by in the ORM, so we have to # build our own. matches = {} - for n in PendingNotification.objects.all().order_by('user', 'history__patch__id', 'history__id'): + for n in PendingNotification.objects.all().order_by( + "user", "history__patch__id", "history__id" + ): if n.user.id not in matches: - matches[n.user.id] = {'user': n.user, 'patches': {}} - if n.history.patch.id not in matches[n.user.id]['patches']: - matches[n.user.id]['patches'][n.history.patch.id] = {'patch': n.history.patch, 'entries': []} - matches[n.user.id]['patches'][n.history.patch.id]['entries'].append(n.history) + matches[n.user.id] = {"user": n.user, "patches": {}} + if n.history.patch.id not in matches[n.user.id]["patches"]: + matches[n.user.id]["patches"][n.history.patch.id] = { + "patch": n.history.patch, + "entries": [], + } + matches[n.user.id]["patches"][n.history.patch.id]["entries"].append( + n.history + ) n.delete() # Ok, now let's build emails from this for v in matches.values(): - user = v['user'] + user = v["user"] email = user.email try: if user.userprofile and user.userprofile.notifyemail: @@ -35,13 +40,14 @@ def handle(self, *args, **options): except UserProfile.DoesNotExist: pass - send_template_mail(settings.NOTIFICATION_FROM, - None, - email, - "PostgreSQL commitfest updates", - 'mail/patch_notify.txt', - { - 'user': user, - 'patches': v['patches'], - }, - ) + send_template_mail( + settings.NOTIFICATION_FROM, + None, + email, + "PostgreSQL commitfest updates", + "mail/patch_notify.txt", + { + "user": user, + "patches": v["patches"], + }, + ) diff --git a/pgcommitfest/commitfest/migrations/0001_initial.py b/pgcommitfest/commitfest/migrations/0001_initial.py index a58a5e18..3577d436 100644 --- a/pgcommitfest/commitfest/migrations/0001_initial.py +++ b/pgcommitfest/commitfest/migrations/0001_initial.py @@ -1,183 +1,327 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import migrations, models from django.conf import settings +from django.db import migrations, models + import pgcommitfest.commitfest.util class Migration(migrations.Migration): - dependencies = [ - ('auth', '0006_require_contenttypes_0002'), + ("auth", "0006_require_contenttypes_0002"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='CommitFest', + name="CommitFest", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(unique=True, max_length=100)), - ('status', models.IntegerField(default=1, choices=[(1, 'Future'), (2, 'Open'), (3, 'In Progress'), (4, 'Closed')])), - ('startdate', models.DateField(null=True, blank=True)), - ('enddate', models.DateField(null=True, blank=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("name", models.CharField(unique=True, max_length=100)), + ( + "status", + models.IntegerField( + default=1, + choices=[ + (1, "Future"), + (2, "Open"), + (3, "In Progress"), + (4, "Closed"), + ], + ), + ), + ("startdate", models.DateField(null=True, blank=True)), + ("enddate", models.DateField(null=True, blank=True)), ], options={ - 'ordering': ('-startdate',), - 'verbose_name_plural': 'Commitfests', + "ordering": ("-startdate",), + "verbose_name_plural": "Commitfests", }, ), migrations.CreateModel( - name='Committer', + name="Committer", fields=[ - ('user', models.OneToOneField(primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), - ('active', models.BooleanField(default=True)), + ( + "user", + models.OneToOneField( + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ), + ), + ("active", models.BooleanField(default=True)), ], options={ - 'ordering': ('user__last_name', 'user__first_name'), + "ordering": ("user__last_name", "user__first_name"), }, ), migrations.CreateModel( - name='MailThread', + name="MailThread", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('messageid', models.CharField(unique=True, max_length=1000)), - ('subject', models.CharField(max_length=500)), - ('firstmessage', models.DateTimeField()), - ('firstauthor', models.CharField(max_length=500)), - ('latestmessage', models.DateTimeField()), - ('latestauthor', models.CharField(max_length=500)), - ('latestsubject', models.CharField(max_length=500)), - ('latestmsgid', models.CharField(max_length=1000)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("messageid", models.CharField(unique=True, max_length=1000)), + ("subject", models.CharField(max_length=500)), + ("firstmessage", models.DateTimeField()), + ("firstauthor", models.CharField(max_length=500)), + ("latestmessage", models.DateTimeField()), + ("latestauthor", models.CharField(max_length=500)), + ("latestsubject", models.CharField(max_length=500)), + ("latestmsgid", models.CharField(max_length=1000)), ], options={ - 'ordering': ('firstmessage',), + "ordering": ("firstmessage",), }, ), migrations.CreateModel( - name='MailThreadAnnotation', + name="MailThreadAnnotation", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('date', models.DateTimeField(auto_now_add=True)), - ('msgid', models.CharField(max_length=1000)), - ('annotationtext', models.TextField(max_length=2000)), - ('mailsubject', models.CharField(max_length=500)), - ('maildate', models.DateTimeField()), - ('mailauthor', models.CharField(max_length=500)), - ('mailthread', models.ForeignKey(to='commitfest.MailThread', on_delete=models.CASCADE)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("date", models.DateTimeField(auto_now_add=True)), + ("msgid", models.CharField(max_length=1000)), + ("annotationtext", models.TextField(max_length=2000)), + ("mailsubject", models.CharField(max_length=500)), + ("maildate", models.DateTimeField()), + ("mailauthor", models.CharField(max_length=500)), + ( + "mailthread", + models.ForeignKey( + to="commitfest.MailThread", on_delete=models.CASCADE + ), + ), + ( + "user", + models.ForeignKey( + to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE + ), + ), ], options={ - 'ordering': ('date',), + "ordering": ("date",), }, ), migrations.CreateModel( - name='MailThreadAttachment', + name="MailThreadAttachment", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('messageid', models.CharField(max_length=1000)), - ('attachmentid', models.IntegerField()), - ('filename', models.CharField(max_length=1000, blank=True)), - ('date', models.DateTimeField()), - ('author', models.CharField(max_length=500)), - ('ispatch', models.BooleanField(null=True)), - ('mailthread', models.ForeignKey(to='commitfest.MailThread', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("messageid", models.CharField(max_length=1000)), + ("attachmentid", models.IntegerField()), + ("filename", models.CharField(max_length=1000, blank=True)), + ("date", models.DateTimeField()), + ("author", models.CharField(max_length=500)), + ("ispatch", models.BooleanField(null=True)), + ( + "mailthread", + models.ForeignKey( + to="commitfest.MailThread", on_delete=models.CASCADE + ), + ), ], options={ - 'ordering': ('-date',), + "ordering": ("-date",), }, ), migrations.CreateModel( - name='Patch', + name="Patch", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=500, verbose_name='Description')), - ('wikilink', models.URLField(default='', null=False, blank=True)), - ('gitlink', models.URLField(default='', null=False, blank=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('modified', models.DateTimeField()), - ('lastmail', models.DateTimeField(null=True, blank=True)), - ('authors', models.ManyToManyField(related_name='patch_author', to=settings.AUTH_USER_MODEL, blank=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("name", models.CharField(max_length=500, verbose_name="Description")), + ("wikilink", models.URLField(default="", null=False, blank=True)), + ("gitlink", models.URLField(default="", null=False, blank=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now_add=True)), + ("lastmail", models.DateTimeField(null=True, blank=True)), + ( + "authors", + models.ManyToManyField( + related_name="patch_author", + to=settings.AUTH_USER_MODEL, + blank=True, + ), + ), ], options={ - 'verbose_name_plural': 'patches', + "verbose_name_plural": "patches", }, bases=(models.Model, pgcommitfest.commitfest.util.DiffableModel), ), migrations.CreateModel( - name='PatchHistory', + name="PatchHistory", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('date', models.DateTimeField(auto_now_add=True)), - ('what', models.CharField(max_length=500)), - ('by', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), - ('patch', models.ForeignKey(to='commitfest.Patch', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("date", models.DateTimeField(auto_now_add=True)), + ("what", models.CharField(max_length=500)), + ( + "by", + models.ForeignKey( + to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE + ), + ), + ( + "patch", + models.ForeignKey(to="commitfest.Patch", on_delete=models.CASCADE), + ), ], options={ - 'ordering': ('-date',), + "ordering": ("-date",), }, ), migrations.CreateModel( - name='PatchOnCommitFest', + name="PatchOnCommitFest", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('enterdate', models.DateTimeField()), - ('leavedate', models.DateTimeField(null=True, blank=True)), - ('status', models.IntegerField(default=1, choices=[(1, 'Needs review'), (2, 'Waiting on Author'), (3, 'Ready for Committer'), (4, 'Committed'), (5, 'Moved to next CF'), (6, 'Rejected'), (7, 'Returned with feedback')])), - ('commitfest', models.ForeignKey(to='commitfest.CommitFest', on_delete=models.CASCADE)), - ('patch', models.ForeignKey(to='commitfest.Patch', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("enterdate", models.DateTimeField()), + ("leavedate", models.DateTimeField(null=True, blank=True)), + ( + "status", + models.IntegerField( + default=1, + choices=[ + (1, "Needs review"), + (2, "Waiting on Author"), + (3, "Ready for Committer"), + (4, "Committed"), + (5, "Moved to next CF"), + (6, "Rejected"), + (7, "Returned with feedback"), + ], + ), + ), + ( + "commitfest", + models.ForeignKey( + to="commitfest.CommitFest", on_delete=models.CASCADE + ), + ), + ( + "patch", + models.ForeignKey(to="commitfest.Patch", on_delete=models.CASCADE), + ), ], options={ - 'ordering': ('-commitfest__startdate',), + "ordering": ("-commitfest__startdate",), }, ), migrations.CreateModel( - name='PatchStatus', + name="PatchStatus", fields=[ - ('status', models.IntegerField(serialize=False, primary_key=True)), - ('statusstring', models.TextField(max_length=50)), - ('sortkey', models.IntegerField(default=10)), + ("status", models.IntegerField(serialize=False, primary_key=True)), + ("statusstring", models.TextField(max_length=50)), + ("sortkey", models.IntegerField(default=10)), ], ), migrations.CreateModel( - name='Topic', + name="Topic", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('topic', models.CharField(max_length=100)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("topic", models.CharField(max_length=100)), ], ), migrations.AddField( - model_name='patch', - name='commitfests', - field=models.ManyToManyField(to='commitfest.CommitFest', through='commitfest.PatchOnCommitFest'), + model_name="patch", + name="commitfests", + field=models.ManyToManyField( + to="commitfest.CommitFest", through="commitfest.PatchOnCommitFest" + ), ), migrations.AddField( - model_name='patch', - name='committer', - field=models.ForeignKey(blank=True, to='commitfest.Committer', null=True, on_delete=models.CASCADE), + model_name="patch", + name="committer", + field=models.ForeignKey( + blank=True, + to="commitfest.Committer", + null=True, + on_delete=models.CASCADE, + ), ), migrations.AddField( - model_name='patch', - name='reviewers', - field=models.ManyToManyField(related_name='patch_reviewer', to=settings.AUTH_USER_MODEL, blank=True), + model_name="patch", + name="reviewers", + field=models.ManyToManyField( + related_name="patch_reviewer", to=settings.AUTH_USER_MODEL, blank=True + ), ), migrations.AddField( - model_name='patch', - name='topic', - field=models.ForeignKey(to='commitfest.Topic', on_delete=models.CASCADE), + model_name="patch", + name="topic", + field=models.ForeignKey(to="commitfest.Topic", on_delete=models.CASCADE), ), migrations.AddField( - model_name='mailthread', - name='patches', - field=models.ManyToManyField(to='commitfest.Patch'), + model_name="mailthread", + name="patches", + field=models.ManyToManyField(to="commitfest.Patch"), ), migrations.AlterUniqueTogether( - name='patchoncommitfest', - unique_together=set([('patch', 'commitfest')]), + name="patchoncommitfest", + unique_together=set([("patch", "commitfest")]), ), migrations.AlterUniqueTogether( - name='mailthreadattachment', - unique_together=set([('mailthread', 'messageid')]), + name="mailthreadattachment", + unique_together=set([("mailthread", "messageid")]), ), ] diff --git a/pgcommitfest/commitfest/migrations/0002_notifications.py b/pgcommitfest/commitfest/migrations/0002_notifications.py index 7fc2396e..450ddfbe 100644 --- a/pgcommitfest/commitfest/migrations/0002_notifications.py +++ b/pgcommitfest/commitfest/migrations/0002_notifications.py @@ -1,29 +1,48 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import migrations, models from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('commitfest', '0001_initial'), + ("commitfest", "0001_initial"), ] operations = [ migrations.CreateModel( - name='PendingNotification', + name="PendingNotification", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('history', models.ForeignKey(to='commitfest.PatchHistory', on_delete=models.CASCADE)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ( + "history", + models.ForeignKey( + to="commitfest.PatchHistory", on_delete=models.CASCADE + ), + ), + ( + "user", + models.ForeignKey( + to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE + ), + ), ], ), migrations.AddField( - model_name='patch', - name='subscribers', - field=models.ManyToManyField(related_name='patch_subscriber', to=settings.AUTH_USER_MODEL, blank=True), + model_name="patch", + name="subscribers", + field=models.ManyToManyField( + related_name="patch_subscriber", to=settings.AUTH_USER_MODEL, blank=True + ), ), ] diff --git a/pgcommitfest/commitfest/migrations/0003_withdrawn_status.py b/pgcommitfest/commitfest/migrations/0003_withdrawn_status.py index e6cdea95..2f6a5f7d 100644 --- a/pgcommitfest/commitfest/migrations/0003_withdrawn_status.py +++ b/pgcommitfest/commitfest/migrations/0003_withdrawn_status.py @@ -5,16 +5,27 @@ class Migration(migrations.Migration): - dependencies = [ - ('commitfest', '0002_notifications'), + ("commitfest", "0002_notifications"), ] operations = [ migrations.AlterField( - model_name='patchoncommitfest', - name='status', - field=models.IntegerField(default=1, choices=[(1, 'Needs review'), (2, 'Waiting on Author'), (3, 'Ready for Committer'), (4, 'Committed'), (5, 'Moved to next CF'), (6, 'Rejected'), (7, 'Returned with feedback'), (8, 'Withdrawn')]), + model_name="patchoncommitfest", + name="status", + field=models.IntegerField( + default=1, + choices=[ + (1, "Needs review"), + (2, "Waiting on Author"), + (3, "Ready for Committer"), + (4, "Committed"), + (5, "Moved to next CF"), + (6, "Rejected"), + (7, "Returned with feedback"), + (8, "Withdrawn"), + ], + ), ), migrations.RunSQL(""" INSERT INTO commitfest_patchstatus (status, statusstring, sortkey) VALUES @@ -28,5 +39,7 @@ class Migration(migrations.Migration): (8,'Withdrawn', 50) ON CONFLICT (status) DO UPDATE SET statusstring=excluded.statusstring, sortkey=excluded.sortkey; """), - migrations.RunSQL("DELETE FROM commitfest_patchstatus WHERE status < 1 OR status > 8"), + migrations.RunSQL( + "DELETE FROM commitfest_patchstatus WHERE status < 1 OR status > 8" + ), ] diff --git a/pgcommitfest/commitfest/migrations/0004_target_version.py b/pgcommitfest/commitfest/migrations/0004_target_version.py index b307883d..ad546109 100644 --- a/pgcommitfest/commitfest/migrations/0004_target_version.py +++ b/pgcommitfest/commitfest/migrations/0004_target_version.py @@ -2,30 +2,45 @@ # Generated by Django 1.11.17 on 2019-02-06 19:43 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('commitfest', '0003_withdrawn_status'), + ("commitfest", "0003_withdrawn_status"), ] operations = [ migrations.CreateModel( - name='TargetVersion', + name="TargetVersion", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('version', models.CharField(max_length=8, unique=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("version", models.CharField(max_length=8, unique=True)), ], options={ - 'ordering': ['-version', ], + "ordering": [ + "-version", + ], }, ), migrations.AddField( - model_name='patch', - name='targetversion', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='commitfest.TargetVersion', verbose_name='Target version'), + model_name="patch", + name="targetversion", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="commitfest.TargetVersion", + verbose_name="Target version", + ), ), ] diff --git a/pgcommitfest/commitfest/migrations/0005_history_dateindex.py b/pgcommitfest/commitfest/migrations/0005_history_dateindex.py index c7be8fcc..4316f212 100644 --- a/pgcommitfest/commitfest/migrations/0005_history_dateindex.py +++ b/pgcommitfest/commitfest/migrations/0005_history_dateindex.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('commitfest', '0004_target_version'), + ("commitfest", "0004_target_version"), ] operations = [ migrations.AlterField( - model_name='patchhistory', - name='date', + model_name="patchhistory", + name="date", field=models.DateTimeField(auto_now_add=True, db_index=True), ), ] diff --git a/pgcommitfest/commitfest/migrations/0006_cfbot_integration.py b/pgcommitfest/commitfest/migrations/0006_cfbot_integration.py index ac84969a..0a1ee6b8 100644 --- a/pgcommitfest/commitfest/migrations/0006_cfbot_integration.py +++ b/pgcommitfest/commitfest/migrations/0006_cfbot_integration.py @@ -1,41 +1,81 @@ # Generated by Django 4.2.17 on 2024-12-21 14:15 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('commitfest', '0005_history_dateindex'), + ("commitfest", "0005_history_dateindex"), ] operations = [ migrations.CreateModel( - name='CfbotBranch', + name="CfbotBranch", fields=[ - ('patch', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='cfbot_branch', serialize=False, to='commitfest.patch')), - ('branch_id', models.IntegerField()), - ('branch_name', models.TextField()), - ('commit_id', models.TextField(blank=True, null=True)), - ('apply_url', models.TextField()), - ('status', models.TextField(choices=[('testing', 'Testing'), ('finished', 'Finished'), ('failed', 'Failed'), ('timeout', 'Timeout')])), - ('created', models.DateTimeField(auto_now_add=True)), - ('modified', models.DateTimeField(auto_now=True)), + ( + "patch", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="cfbot_branch", + serialize=False, + to="commitfest.patch", + ), + ), + ("branch_id", models.IntegerField()), + ("branch_name", models.TextField()), + ("commit_id", models.TextField(blank=True, null=True)), + ("apply_url", models.TextField()), + ( + "status", + models.TextField( + choices=[ + ("testing", "Testing"), + ("finished", "Finished"), + ("failed", "Failed"), + ("timeout", "Timeout"), + ] + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), ], ), migrations.CreateModel( - name='CfbotTask', + name="CfbotTask", fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('task_id', models.TextField(unique=True)), - ('task_name', models.TextField()), - ('branch_id', models.IntegerField()), - ('position', models.IntegerField()), - ('status', models.TextField(choices=[('CREATED', 'Created'), ('NEEDS_APPROVAL', 'Needs Approval'), ('TRIGGERED', 'Triggered'), ('EXECUTING', 'Executing'), ('FAILED', 'Failed'), ('COMPLETED', 'Completed'), ('SCHEDULED', 'Scheduled'), ('ABORTED', 'Aborted'), ('ERRORED', 'Errored')])), - ('created', models.DateTimeField(auto_now_add=True)), - ('modified', models.DateTimeField(auto_now=True)), - ('patch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cfbot_tasks', to='commitfest.patch')), + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ("task_id", models.TextField(unique=True)), + ("task_name", models.TextField()), + ("branch_id", models.IntegerField()), + ("position", models.IntegerField()), + ( + "status", + models.TextField( + choices=[ + ("CREATED", "Created"), + ("NEEDS_APPROVAL", "Needs Approval"), + ("TRIGGERED", "Triggered"), + ("EXECUTING", "Executing"), + ("FAILED", "Failed"), + ("COMPLETED", "Completed"), + ("SCHEDULED", "Scheduled"), + ("ABORTED", "Aborted"), + ("ERRORED", "Errored"), + ] + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ( + "patch", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cfbot_tasks", + to="commitfest.patch", + ), + ), ], ), migrations.RunSQL( diff --git a/pgcommitfest/commitfest/migrations/0007_needs_rebase_emails.py b/pgcommitfest/commitfest/migrations/0007_needs_rebase_emails.py index 42740aa4..cd3d291d 100644 --- a/pgcommitfest/commitfest/migrations/0007_needs_rebase_emails.py +++ b/pgcommitfest/commitfest/migrations/0007_needs_rebase_emails.py @@ -1,12 +1,11 @@ # Generated by Django 4.2.17 on 2024-12-25 11:17 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("commitfest", "0006_cfbot_integration"), diff --git a/pgcommitfest/commitfest/migrations/0008_move_mail_thread_many_to_many.py b/pgcommitfest/commitfest/migrations/0008_move_mail_thread_many_to_many.py index 72d9d425..de8af8c7 100644 --- a/pgcommitfest/commitfest/migrations/0008_move_mail_thread_many_to_many.py +++ b/pgcommitfest/commitfest/migrations/0008_move_mail_thread_many_to_many.py @@ -4,9 +4,8 @@ class Migration(migrations.Migration): - dependencies = [ - ('commitfest', '0007_needs_rebase_emails'), + ("commitfest", "0007_needs_rebase_emails"), ] operations = [ @@ -15,14 +14,18 @@ class Migration(migrations.Migration): reverse_sql=migrations.RunSQL.noop, state_operations=[ migrations.RemoveField( - model_name='mailthread', - name='patches', + model_name="mailthread", + name="patches", ), migrations.AddField( - model_name='patch', - name='mailthread_set', - field=models.ManyToManyField(db_table='commitfest_mailthread_patches', related_name='patches', to='commitfest.mailthread'), + model_name="patch", + name="mailthread_set", + field=models.ManyToManyField( + db_table="commitfest_mailthread_patches", + related_name="patches", + to="commitfest.mailthread", + ), ), - ] + ], ) ] diff --git a/pgcommitfest/commitfest/migrations/0009_extra_branch_fields.py b/pgcommitfest/commitfest/migrations/0009_extra_branch_fields.py index 3477b52e..7e53dd3a 100644 --- a/pgcommitfest/commitfest/migrations/0009_extra_branch_fields.py +++ b/pgcommitfest/commitfest/migrations/0009_extra_branch_fields.py @@ -4,40 +4,39 @@ class Migration(migrations.Migration): - dependencies = [ ("commitfest", "0008_move_mail_thread_many_to_many"), ] operations = [ migrations.AddField( - model_name='cfbotbranch', - name='all_additions', + model_name="cfbotbranch", + name="all_additions", field=models.IntegerField(blank=True, null=True), ), migrations.AddField( - model_name='cfbotbranch', - name='all_deletions', + model_name="cfbotbranch", + name="all_deletions", field=models.IntegerField(blank=True, null=True), ), migrations.AddField( - model_name='cfbotbranch', - name='first_additions', + model_name="cfbotbranch", + name="first_additions", field=models.IntegerField(blank=True, null=True), ), migrations.AddField( - model_name='cfbotbranch', - name='first_deletions', + model_name="cfbotbranch", + name="first_deletions", field=models.IntegerField(blank=True, null=True), ), migrations.AddField( - model_name='cfbotbranch', - name='patch_count', + model_name="cfbotbranch", + name="patch_count", field=models.IntegerField(blank=True, null=True), ), migrations.AddField( - model_name='cfbotbranch', - name='version', + model_name="cfbotbranch", + name="version", field=models.TextField(blank=True, null=True), ), ] diff --git a/pgcommitfest/commitfest/migrations/0010_add_failing_since_column.py b/pgcommitfest/commitfest/migrations/0010_add_failing_since_column.py new file mode 100644 index 00000000..ff5017b4 --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0010_add_failing_since_column.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.19 on 2025-03-01 13:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("commitfest", "0009_extra_branch_fields"), + ] + + operations = [ + migrations.AddField( + model_name="cfbotbranch", + name="failing_since", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index c0617716..fcd9edb9 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -1,18 +1,21 @@ -from django.db import models from django.contrib.auth.models import User +from django.db import models +from django.shortcuts import get_object_or_404 from datetime import datetime -from .util import DiffableModel - from pgcommitfest.userprofile.models import UserProfile +from .util import DiffableModel + # We have few enough of these, and it's really the only thing we # need to extend from the user model, so just create a separate # class. class Committer(models.Model): - user = models.OneToOneField(User, null=False, blank=False, primary_key=True, on_delete=models.CASCADE) + user = models.OneToOneField( + User, null=False, blank=False, primary_key=True, on_delete=models.CASCADE + ) active = models.BooleanField(null=False, blank=False, default=True) def __str__(self): @@ -20,10 +23,14 @@ def __str__(self): @property def fullname(self): - return "%s %s (%s)" % (self.user.first_name, self.user.last_name, self.user.username) + return "%s %s (%s)" % ( + self.user.first_name, + self.user.last_name, + self.user.username, + ) class Meta: - ordering = ('user__last_name', 'user__first_name') + ordering = ("user__last_name", "user__first_name") class CommitFest(models.Model): @@ -32,13 +39,21 @@ class CommitFest(models.Model): STATUS_INPROGRESS = 3 STATUS_CLOSED = 4 _STATUS_CHOICES = ( - (STATUS_FUTURE, 'Future'), - (STATUS_OPEN, 'Open'), - (STATUS_INPROGRESS, 'In Progress'), - (STATUS_CLOSED, 'Closed'), + (STATUS_FUTURE, "Future"), + (STATUS_OPEN, "Open"), + (STATUS_INPROGRESS, "In Progress"), + (STATUS_CLOSED, "Closed"), + ) + _STATUS_LABELS = ( + (STATUS_FUTURE, "default"), + (STATUS_OPEN, "info"), + (STATUS_INPROGRESS, "success"), + (STATUS_CLOSED, "danger"), ) name = models.CharField(max_length=100, blank=False, null=False, unique=True) - status = models.IntegerField(null=False, blank=False, default=1, choices=_STATUS_CHOICES) + status = models.IntegerField( + null=False, blank=False, default=1, choices=_STATUS_CHOICES + ) startdate = models.DateField(blank=True, null=True) enddate = models.DateField(blank=True, null=True) @@ -64,8 +79,8 @@ def __str__(self): return self.name class Meta: - verbose_name_plural = 'Commitfests' - ordering = ('-startdate',) + verbose_name_plural = "Commitfests" + ordering = ("-startdate",) class Topic(models.Model): @@ -79,68 +94,101 @@ class TargetVersion(models.Model): version = models.CharField(max_length=8, blank=False, null=False, unique=True) class Meta: - ordering = ['-version', ] + ordering = [ + "-version", + ] def __str__(self): return self.version class Patch(models.Model, DiffableModel): - name = models.CharField(max_length=500, blank=False, null=False, verbose_name='Description') + 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) # One patch can be in multiple commitfests, if it has history - commitfests = models.ManyToManyField(CommitFest, through='PatchOnCommitFest') + commitfests = models.ManyToManyField(CommitFest, through="PatchOnCommitFest") # If there is a wiki page discussing this patch - wikilink = models.URLField(blank=True, null=False, default='') + wikilink = models.URLField(blank=True, null=False, default="") # If there is a git repo about this patch - gitlink = models.URLField(blank=True, null=False, default='') + gitlink = models.URLField(blank=True, null=False, default="") # Version targeted by this patch - targetversion = models.ForeignKey(TargetVersion, blank=True, null=True, verbose_name="Target version", on_delete=models.CASCADE) + targetversion = models.ForeignKey( + TargetVersion, + blank=True, + null=True, + verbose_name="Target version", + on_delete=models.CASCADE, + ) - authors = models.ManyToManyField(User, related_name='patch_author', blank=True) - reviewers = models.ManyToManyField(User, related_name='patch_reviewer', blank=True) + authors = models.ManyToManyField(User, related_name="patch_author", blank=True) + reviewers = models.ManyToManyField(User, related_name="patch_reviewer", blank=True) - committer = models.ForeignKey(Committer, blank=True, null=True, on_delete=models.CASCADE) + committer = models.ForeignKey( + Committer, blank=True, null=True, on_delete=models.CASCADE + ) # Users to be notified when something happens - subscribers = models.ManyToManyField(User, related_name='patch_subscriber', blank=True) + subscribers = models.ManyToManyField( + User, related_name="patch_subscriber", blank=True + ) - mailthread_set = models.ManyToManyField("MailThread", related_name="patches", blank=False, db_table="commitfest_mailthread_patches") + mailthread_set = models.ManyToManyField( + "MailThread", + related_name="patches", + blank=False, + db_table="commitfest_mailthread_patches", + ) # Datestamps for tracking activity created = models.DateTimeField(blank=False, null=False, auto_now_add=True) - modified = models.DateTimeField(blank=False, null=False) + modified = models.DateTimeField(blank=False, null=False, auto_now_add=True) # Materialize the last time an email was sent on any of the threads # that's attached to this message. lastmail = models.DateTimeField(blank=True, null=True) map_manytomany_for_diff = { - 'authors': 'authors_string', - 'reviewers': 'reviewers_string', + "authors": "authors_string", + "reviewers": "reviewers_string", } def current_commitfest(self): - return self.commitfests.order_by('-startdate').first() + return self.commitfests.order_by("-startdate").first() + + def current_patch_on_commitfest(self): + cf = self.current_commitfest() + return get_object_or_404(PatchOnCommitFest, patch=self, commitfest=cf) # Some accessors @property def authors_string(self): - return ", ".join(["%s %s (%s)" % (a.first_name, a.last_name, a.username) for a in self.authors.all()]) + return ", ".join( + [ + "%s %s (%s)" % (a.first_name, a.last_name, a.username) + for a in self.authors.all() + ] + ) @property def reviewers_string(self): - return ", ".join(["%s %s (%s)" % (a.first_name, a.last_name, a.username) for a in self.reviewers.all()]) + return ", ".join( + [ + "%s %s (%s)" % (a.first_name, a.last_name, a.username) + for a in self.reviewers.all() + ] + ) @property def history(self): # Need to wrap this in a function to make sure it calls # select_related() and doesn't generate a bazillion queries - return self.patchhistory_set.select_related('by').all() + return self.patchhistory_set.select_related("by").all() def set_modified(self, newmod=None): # Set the modified date to newmod, but only if that's newer than @@ -164,7 +212,7 @@ def __str__(self): return self.name class Meta: - verbose_name_plural = 'patches' + verbose_name_plural = "patches" class PatchOnCommitFest(models.Model): @@ -181,24 +229,24 @@ class PatchOnCommitFest(models.Model): STATUS_RETURNED = 7 STATUS_WITHDRAWN = 8 _STATUS_CHOICES = ( - (STATUS_REVIEW, 'Needs review'), - (STATUS_AUTHOR, 'Waiting on Author'), - (STATUS_COMMITTER, 'Ready for Committer'), - (STATUS_COMMITTED, 'Committed'), - (STATUS_NEXT, 'Moved to next CF'), - (STATUS_REJECTED, 'Rejected'), - (STATUS_RETURNED, 'Returned with feedback'), - (STATUS_WITHDRAWN, 'Withdrawn'), + (STATUS_REVIEW, "Needs review"), + (STATUS_AUTHOR, "Waiting on Author"), + (STATUS_COMMITTER, "Ready for Committer"), + (STATUS_COMMITTED, "Committed"), + (STATUS_NEXT, "Moved to next CF"), + (STATUS_REJECTED, "Rejected"), + (STATUS_RETURNED, "Returned with feedback"), + (STATUS_WITHDRAWN, "Withdrawn"), ) _STATUS_LABELS = ( - (STATUS_REVIEW, 'default'), - (STATUS_AUTHOR, 'primary'), - (STATUS_COMMITTER, 'info'), - (STATUS_COMMITTED, 'success'), - (STATUS_NEXT, 'warning'), - (STATUS_REJECTED, 'danger'), - (STATUS_RETURNED, 'danger'), - (STATUS_WITHDRAWN, 'danger'), + (STATUS_REVIEW, "default"), + (STATUS_AUTHOR, "primary"), + (STATUS_COMMITTER, "info"), + (STATUS_COMMITTED, "success"), + (STATUS_NEXT, "warning"), + (STATUS_REJECTED, "danger"), + (STATUS_RETURNED, "danger"), + (STATUS_WITHDRAWN, "danger"), ) OPEN_STATUSES = [STATUS_REVIEW, STATUS_AUTHOR, STATUS_COMMITTER] @@ -207,35 +255,50 @@ def OPEN_STATUS_CHOICES(cls): return [x for x in cls._STATUS_CHOICES if x[0] in cls.OPEN_STATUSES] patch = models.ForeignKey(Patch, blank=False, null=False, on_delete=models.CASCADE) - commitfest = models.ForeignKey(CommitFest, blank=False, null=False, on_delete=models.CASCADE) + commitfest = models.ForeignKey( + CommitFest, blank=False, null=False, on_delete=models.CASCADE + ) enterdate = models.DateTimeField(blank=False, null=False) leavedate = models.DateTimeField(blank=True, null=True) - status = models.IntegerField(blank=False, null=False, default=STATUS_REVIEW, choices=_STATUS_CHOICES) + status = models.IntegerField( + blank=False, null=False, default=STATUS_REVIEW, choices=_STATUS_CHOICES + ) @property def is_closed(self): return self.status not in self.OPEN_STATUSES + @property + def is_open(self): + return not self.is_closed + @property def statusstring(self): return [v for k, v in self._STATUS_CHOICES if k == self.status][0] class Meta: - unique_together = (('patch', 'commitfest',),) - ordering = ('-commitfest__startdate', ) + unique_together = ( + ( + "patch", + "commitfest", + ), + ) + ordering = ("-commitfest__startdate",) class PatchHistory(models.Model): patch = models.ForeignKey(Patch, blank=False, null=False, on_delete=models.CASCADE) - date = models.DateTimeField(blank=False, null=False, auto_now_add=True, db_index=True) + date = models.DateTimeField( + blank=False, null=False, auto_now_add=True, db_index=True + ) by = models.ForeignKey(User, blank=True, null=True, on_delete=models.CASCADE) by_cfbot = models.BooleanField(null=False, blank=False, default=False) what = models.CharField(max_length=500, null=False, blank=False) @property def by_string(self): - if (self.by_cfbot): + if self.by_cfbot: return "CFbot" return "%s %s (%s)" % (self.by.first_name, self.by.last_name, self.by.username) @@ -244,20 +307,22 @@ def __str__(self): return "%s - %s" % (self.patch.name, self.date) class Meta: - ordering = ('-date', ) + ordering = ("-date",) constraints = [ models.CheckConstraint( - check=( - models.Q(by_cfbot=True) & models.Q(by__isnull=True) - ) | ( - models.Q(by_cfbot=False) & models.Q(by__isnull=False) - ), - name='check_by', + check=(models.Q(by_cfbot=True) & models.Q(by__isnull=True)) + | (models.Q(by_cfbot=False) & models.Q(by__isnull=False)), + name="check_by", ), ] - def save_and_notify(self, prevcommitter=None, - prevreviewers=None, prevauthors=None, authors_only=False): + def save_and_notify( + self, + prevcommitter=None, + prevreviewers=None, + prevauthors=None, + authors_only=False, + ): # Save this model, and then trigger notifications if there are any. There are # many different things that can trigger notifications, so try them all. self.save() @@ -268,25 +333,40 @@ def save_and_notify(self, prevcommitter=None, # Current or previous committer wants all notifications try: - if self.patch.committer and self.patch.committer.user.userprofile.notify_all_committer: + if ( + self.patch.committer + and self.patch.committer.user.userprofile.notify_all_committer + ): recipients.append(self.patch.committer.user) except UserProfile.DoesNotExist: pass try: - if prevcommitter and prevcommitter.user.userprofile.notify_all_committer: + if ( + prevcommitter + and prevcommitter.user.userprofile.notify_all_committer + ): recipients.append(prevcommitter.user) except UserProfile.DoesNotExist: pass # Current or previous reviewers wants all notifications - recipients.extend(self.patch.reviewers.filter(userprofile__notify_all_reviewer=True)) + recipients.extend( + self.patch.reviewers.filter(userprofile__notify_all_reviewer=True) + ) if prevreviewers: # prevreviewers is a list - recipients.extend(User.objects.filter(id__in=[p.id for p in prevreviewers], userprofile__notify_all_reviewer=True)) + recipients.extend( + User.objects.filter( + id__in=[p.id for p in prevreviewers], + userprofile__notify_all_reviewer=True, + ) + ) # Current or previous authors wants all notifications - recipients.extend(self.patch.authors.filter(userprofile__notify_all_author=True)) + recipients.extend( + self.patch.authors.filter(userprofile__notify_all_author=True) + ) for u in set(recipients): if u != self.by: # Don't notify for changes we make ourselves @@ -316,11 +396,13 @@ def __str__(self): return self.subject class Meta: - ordering = ('firstmessage', ) + ordering = ("firstmessage",) class MailThreadAttachment(models.Model): - mailthread = models.ForeignKey(MailThread, null=False, blank=False, on_delete=models.CASCADE) + mailthread = models.ForeignKey( + MailThread, null=False, blank=False, on_delete=models.CASCADE + ) messageid = models.CharField(max_length=1000, null=False, blank=False) attachmentid = models.IntegerField(null=False, blank=False) filename = models.CharField(max_length=1000, null=False, blank=True) @@ -329,12 +411,19 @@ class MailThreadAttachment(models.Model): ispatch = models.BooleanField(null=True) class Meta: - ordering = ('-date',) - unique_together = (('mailthread', 'messageid',), ) + ordering = ("-date",) + unique_together = ( + ( + "mailthread", + "messageid", + ), + ) class MailThreadAnnotation(models.Model): - mailthread = models.ForeignKey(MailThread, null=False, blank=False, on_delete=models.CASCADE) + mailthread = models.ForeignKey( + MailThread, null=False, blank=False, on_delete=models.CASCADE + ) date = models.DateTimeField(null=False, blank=False, auto_now_add=True) user = models.ForeignKey(User, null=False, blank=False, on_delete=models.CASCADE) msgid = models.CharField(max_length=1000, null=False, blank=False) @@ -345,10 +434,14 @@ class MailThreadAnnotation(models.Model): @property def user_string(self): - return "%s %s (%s)" % (self.user.first_name, self.user.last_name, self.user.username) + return "%s %s (%s)" % ( + self.user.first_name, + self.user.last_name, + self.user.username, + ) class Meta: - ordering = ('date', ) + ordering = ("date",) class PatchStatus(models.Model): @@ -358,19 +451,23 @@ class PatchStatus(models.Model): class PendingNotification(models.Model): - history = models.ForeignKey(PatchHistory, blank=False, null=False, on_delete=models.CASCADE) + history = models.ForeignKey( + PatchHistory, blank=False, null=False, on_delete=models.CASCADE + ) user = models.ForeignKey(User, blank=False, null=False, on_delete=models.CASCADE) class CfbotBranch(models.Model): STATUS_CHOICES = [ - ('testing', 'Testing'), - ('finished', 'Finished'), - ('failed', 'Failed'), - ('timeout', 'Timeout'), + ("testing", "Testing"), + ("finished", "Finished"), + ("failed", "Failed"), + ("timeout", "Timeout"), ] - patch = models.OneToOneField(Patch, on_delete=models.CASCADE, related_name="cfbot_branch", primary_key=True) + patch = models.OneToOneField( + Patch, on_delete=models.CASCADE, related_name="cfbot_branch", primary_key=True + ) branch_id = models.IntegerField(null=False) branch_name = models.TextField(null=False) commit_id = models.TextField(null=True, blank=True) @@ -378,6 +475,7 @@ class CfbotBranch(models.Model): # Actually a postgres enum column status = models.TextField(choices=STATUS_CHOICES, null=False) needs_rebase_since = models.DateTimeField(null=True, blank=True) + failing_since = models.DateTimeField(null=True, blank=True) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) version = models.TextField(null=True, blank=True) @@ -400,15 +498,15 @@ def save(self, *args, **kwargs): class CfbotTask(models.Model): STATUS_CHOICES = [ - ('CREATED', 'Created'), - ('NEEDS_APPROVAL', 'Needs Approval'), - ('TRIGGERED', 'Triggered'), - ('EXECUTING', 'Executing'), - ('FAILED', 'Failed'), - ('COMPLETED', 'Completed'), - ('SCHEDULED', 'Scheduled'), - ('ABORTED', 'Aborted'), - ('ERRORED', 'Errored'), + ("CREATED", "Created"), + ("NEEDS_APPROVAL", "Needs Approval"), + ("TRIGGERED", "Triggered"), + ("EXECUTING", "Executing"), + ("FAILED", "Failed"), + ("COMPLETED", "Completed"), + ("SCHEDULED", "Scheduled"), + ("ABORTED", "Aborted"), + ("ERRORED", "Errored"), ] # This id is only used by Django. Using text type for primary keys, has @@ -421,7 +519,9 @@ class CfbotTask(models.Model): # ID opaque and store it as text. task_id = models.TextField(unique=True) task_name = models.TextField(null=False) - patch = models.ForeignKey(Patch, on_delete=models.CASCADE, related_name="cfbot_tasks") + patch = models.ForeignKey( + Patch, on_delete=models.CASCADE, related_name="cfbot_tasks" + ) branch_id = models.IntegerField(null=False) position = models.IntegerField(null=False) # Actually a postgres enum column diff --git a/pgcommitfest/commitfest/reports.py b/pgcommitfest/commitfest/reports.py index 88f51a9f..e4191e16 100644 --- a/pgcommitfest/commitfest/reports.py +++ b/pgcommitfest/commitfest/reports.py @@ -1,8 +1,7 @@ -from django.shortcuts import render, get_object_or_404 -from django.http import Http404 -from django.template import RequestContext from django.contrib.auth.decorators import login_required from django.db import connection +from django.http import Http404 +from django.shortcuts import get_object_or_404, render from .models import CommitFest @@ -14,7 +13,8 @@ def authorstats(request, cfid): raise Http404("Only CF Managers can do that.") cursor = connection.cursor() - cursor.execute(""" + cursor.execute( + """ WITH patches(id,name) AS ( SELECT p.id, name FROM commitfest_patch p @@ -37,13 +37,20 @@ def authorstats(request, cfid): INNER JOIN auth_user u ON u.id=COALESCE(authors.userid, reviewers.userid) ORDER BY last_name, first_name """, - { - 'cid': cf.id, - }) + { + "cid": cf.id, + }, + ) - return render(request, 'report_authors.html', { - 'cf': cf, - 'report': cursor.fetchall(), - 'title': 'Author stats', - 'breadcrumbs': [{'title': cf.title, 'href': '/%s/' % cf.pk}, ], - }) + return render( + request, + "report_authors.html", + { + "cf": cf, + "report": cursor.fetchall(), + "title": "Author stats", + "breadcrumbs": [ + {"title": cf.title, "href": "/%s/" % cf.pk}, + ], + }, + ) diff --git a/pgcommitfest/commitfest/templates/404.html b/pgcommitfest/commitfest/templates/404.html index ccf5146c..73a24c02 100644 --- a/pgcommitfest/commitfest/templates/404.html +++ b/pgcommitfest/commitfest/templates/404.html @@ -1,7 +1,7 @@ {%extends "base.html" %} {%block title%}Not found{%endblock%} {%block contents%} -

Not found

-

The specified URL was not found.

+

Not found

+

The specified URL was not found.

{%endblock%} diff --git a/pgcommitfest/commitfest/templates/activity.html b/pgcommitfest/commitfest/templates/activity.html index 1d5a060f..621155ee 100644 --- a/pgcommitfest/commitfest/templates/activity.html +++ b/pgcommitfest/commitfest/templates/activity.html @@ -2,25 +2,25 @@ {%load commitfest %} {%block contents%} - - - - - - - - - - -{%for a in activity %} - - - - - - -{%endfor%} - -
TimeUserPatchActivity
{{a.date}}{{a.by}}{{a.name}}{{a.what}}
+ + + + + + + + + + + {%for a in activity %} + + + + + + + {%endfor%} + +
TimeUserPatchActivity
{{a.date}}{{a.by}}{{a.name}}{{a.what}}
{%endblock%} diff --git a/pgcommitfest/commitfest/templates/archive.html b/pgcommitfest/commitfest/templates/archive.html new file mode 100644 index 00000000..f22c6161 --- /dev/null +++ b/pgcommitfest/commitfest/templates/archive.html @@ -0,0 +1,9 @@ +{%extends "base.html"%} +{%block contents%} + +{%endblock%} + diff --git a/pgcommitfest/commitfest/templates/base.html b/pgcommitfest/commitfest/templates/base.html index 382c43bc..c70a7f77 100644 --- a/pgcommitfest/commitfest/templates/base.html +++ b/pgcommitfest/commitfest/templates/base.html @@ -1,48 +1,54 @@ {%load commitfest%} - - {{title}} + + {{title|default:'Commitfest' }} -{%block extrahead%}{%endblock%} -{%if rss_alternate%} {%endif%} - - -
- + {%block extrahead%}{%endblock%} + {%if rss_alternate%} {%endif%} + + +
+ -

{{title}}

+ {%if title %} +

{{title}}

+ {%endif%} -{%if messages%} - {%for m in messages%} -
{{m}}
- {%endfor%} -{%endif%} + {%if messages%} + {%for m in messages%} +
{{m}}
+ {%endfor%} + {%endif%} -{%block contents%} -{%endblock%} -
- - - - -{%block morescript%}{%endblock%} - + {%block contents%} + {%endblock%} +
+ + + + + {%block morescript%}{%endblock%} + diff --git a/pgcommitfest/commitfest/templates/base_form.html b/pgcommitfest/commitfest/templates/base_form.html index 91f2e91e..82e827d8 100644 --- a/pgcommitfest/commitfest/templates/base_form.html +++ b/pgcommitfest/commitfest/templates/base_form.html @@ -2,128 +2,91 @@ {%load commitfest%} {%block contents%} -
{%csrf_token%} -{%if form.errors%} -
Please correct the errors below, and re-submit the form.
-{%endif%} -{%if form.non_field_errors%} -
{{form.non_field_errors}}
-{%endif%} -{%if note%} -
{{note|safe}}
-{%endif%} - {%for field in form%} - {%if not field.is_hidden%} -
- {{field|label_class:"control-label col-lg-1"}} -
- {%if field.errors %} - {%for e in field.errors%} -
{{e}}
- {%endfor%} + {%csrf_token%} + {%if form.errors%} +
Please correct the errors below, and re-submit the form.
{%endif%} -{%if not field.name in form.selectize_multiple_fields%}{{field|field_class:"form-control"}}{%else%}{{field}}{%endif%} -{%if field.help_text%}
{{field.help_text|safe}}{%endif%}
-
- {%else%} -{{field}} - {%endif%} -{%endfor%} -
-
-
+ {%if form.non_field_errors%} +
{{form.non_field_errors}}
+ {%endif%} + {%if note%} +
{{note|safe}}
+ {%endif%} + {%for field in form%} + {%if not field.is_hidden%} +
+ {{field|label_class:"control-label col-lg-1"}} +
+ {%if field.errors %} + {%for e in field.errors%} +
{{e}}
+ {%endfor%} + {%endif%} + {%if not field.name in form.selectize_fields%}{{field|field_class:"form-control"}}{%else%}{{field}}{%endif%} + {%if field.help_text%}
{{field.help_text|safe}}{%endif%}
+
+ {%else%} + {{field}} + {%endif%} + {%endfor%} +
+
+
+
-
- + -{%if threadbrowse %} -{%include "thread_attach.inc" %} -{%endif%} + {%if threadbrowse %} + {%include "thread_attach.inc" %} + {%endif%} -{%if user.is_staff%} -