From 144fd1f7aa171fefab09a3ef01831e993e0cfc53 Mon Sep 17 00:00:00 2001 From: Lukas Fleischer Date: Wed, 11 Nov 2026 15:42:55 -0500 Subject: [PATCH 1/4] todolists: warn about package names missing from the list Prior to this commit, misspelled or incorrect package names are silently dropped. Show a warning below the todo list description. --- sitestatic/archweb.css | 9 +++++++++ templates/todolists/view.html | 11 +++++++++++ todolists/models.py | 7 +++++++ todolists/tests/test_models.py | 10 ++++++++++ todolists/tests/test_views.py | 16 ++++++++++++++++ 5 files changed, 53 insertions(+) diff --git a/sitestatic/archweb.css b/sitestatic/archweb.css index 1d8d50f3..e344b29d 100644 --- a/sitestatic/archweb.css +++ b/sitestatic/archweb.css @@ -1094,6 +1094,15 @@ ul.admin-actions { border-top: 1px dotted #bbb; } +.todo-warning { + margin: 1em 0; + padding: 4px 6px; + + color: #8a6d3b; + background: #fcf8e3; + border: 1px solid #faebcc; +} + .todo-list h4 { margin-top: 0; margin-bottom: 0.4em; diff --git a/templates/todolists/view.html b/templates/todolists/view.html index 873213c4..4125ba44 100644 --- a/templates/todolists/view.html +++ b/templates/todolists/view.html @@ -31,6 +31,17 @@

{{ list.kind_str | title }} Todo List {{ list.name }}

{{list.stripped_description|default:'(no description)'|urlize|linebreaks}} + {% with missing=list.missing_package_names %} + {% if missing %} +
+ Warning: The following package name{{ missing|pluralize }} + {{ missing|length|pluralize:"was,were" }} not found in any stable repository and + {{ missing|length|pluralize:"is,are" }} not part of this list: + {{ missing|join:", " }} +
+ {% endif %} + {% endwith %} +

List of pkgbase values

diff --git a/todolists/models.py b/todolists/models.py index c5328cc2..c991b329 100644 --- a/todolists/models.py +++ b/todolists/models.py @@ -54,6 +54,13 @@ def packages(self): 'pkgname', 'arch') return self._packages + def missing_package_names(self): + '''Names from the raw package list that did not end up in the list, + e.g. because they are not found in any repository.''' + present = {tp.pkgname for tp in self.packages()} + names = dict.fromkeys(s.strip() for s in self.raw.splitlines()) + return [name for name in names if name and name not in present] + @property def kind_str(self): '''Todo list kind as str''' diff --git a/todolists/tests/test_models.py b/todolists/tests/test_models.py index 8f9fb6b9..9fc27ec9 100644 --- a/todolists/tests/test_models.py +++ b/todolists/tests/test_models.py @@ -53,3 +53,13 @@ def test_todolist_complete(admin_user, todolist, todolistpackage, mailoutbox): todolistpackage.save() assert len(mailoutbox) == 1 todopkg.delete() + + +def test_missing_package_names(admin_user, todolist, todolistpackage): + todolist.raw = f'{todolistpackage.pkgname}\r\nnonexistent\n\n nonexistent \nother\n' + assert todolist.missing_package_names() == ['nonexistent', 'other'] + + +def test_missing_package_names_none(admin_user, todolist, todolistpackage): + todolist.raw = todolistpackage.pkgname + assert todolist.missing_package_names() == [] diff --git a/todolists/tests/test_views.py b/todolists/tests/test_views.py index fa53f2f0..c97559ce 100644 --- a/todolists/tests/test_views.py +++ b/todolists/tests/test_views.py @@ -105,3 +105,19 @@ def test_add_view(developer_client): def test_edit_view(developer_client, todolist): response = developer_client.get(f'/todo/{todolist.slug}/edit/') assert response.status_code == 200 + + +def test_detail_missing_packages_warning(developer_client, todolist, todolistpackage): + todolist.raw = f'{todolistpackage.pkgname}\nnonexistent-pkg' + todolist.save() + response = developer_client.get(todolist.get_absolute_url()) + content = response.content.decode() + assert 'todo-warning' in content + assert 'nonexistent-pkg' in content + + +def test_detail_no_missing_packages_warning(developer_client, todolist, todolistpackage): + todolist.raw = todolistpackage.pkgname + todolist.save() + response = developer_client.get(todolist.get_absolute_url()) + assert 'todo-warning' not in response.content.decode() From 128b44364e4d14fe88955ed76f57955eb943fa54 Mon Sep 17 00:00:00 2001 From: Lukas Fleischer Date: Wed, 11 Nov 2026 16:24:53 -0500 Subject: [PATCH 2/4] todolists: add stages Each todo list gets a fixed set of stages chosen at creation time, defaulting to Draft, Rebuild in staging and Test in testing for rebuilds and Draft and In progress for tasks. The list proceeds through its stages manually; stages asking for per-package completion can only be left once all packages are marked as complete. Package maintainers are notified when the list reaches a stage asking for per-package completion, the creator once all packages completed it. The per-package status is replaced by a progress counter. Existing lists are migrated to a single In progress stage. --- devel/views.py | 9 +- sitestatic/archweb.css | 21 ++ sitestatic/archweb.js | 1 + templates/devel/index.html | 2 +- templates/general_form.html | 4 +- .../todolists/complete_email_notification.txt | 2 +- templates/todolists/email_notification.txt | 7 +- templates/todolists/form.html | 19 ++ templates/todolists/list.html | 6 +- templates/todolists/view.html | 54 +++- todolists/migrations/0003_stages.py | 48 +++ todolists/models.py | 88 +++--- todolists/stages.py | 39 +++ todolists/tests/conftest.py | 3 +- todolists/tests/test_models.py | 72 ++++- todolists/tests/test_views.py | 278 +++++++++++++++++- todolists/urls.py | 6 + todolists/utils.py | 20 +- todolists/views.py | 113 ++++++- 19 files changed, 689 insertions(+), 103 deletions(-) create mode 100644 templates/todolists/form.html create mode 100644 todolists/migrations/0003_stages.py create mode 100644 todolists/stages.py diff --git a/devel/views.py b/devel/views.py index 2a04a370..f9ac8302 100644 --- a/devel/views.py +++ b/devel/views.py @@ -17,7 +17,7 @@ from django.core.cache import cache from django.core.cache.utils import make_template_fragment_key from django.db import transaction -from django.db.models import Count, Max +from django.db.models import Count, F, Max from django.http import Http404, HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.utils.encoding import force_str @@ -65,10 +65,13 @@ def index(request): group.sort(key=lambda p: (p.pkgname != p.pkgbase, p.pkgname)) todopkgs = TodolistPackage.objects.select_related( - 'todolist', 'pkg', 'arch', 'repo').exclude( - status=TodolistPackage.COMPLETE).filter(removed__isnull=True) + 'todolist', 'pkg', 'arch', 'repo').filter( + removed__isnull=True, progress__lte=F('todolist__progress')) todopkgs = todopkgs.filter(pkgbase__in=inner_q).order_by('todolist__name', 'pkgname') + # only stages asking for per-package completion need action + todopkgs = [tp for tp in todopkgs + if tp.todolist.current_stage and tp.todolist.current_stage.per_package] todolists = get_annotated_todolists(incomplete_only=True) diff --git a/sitestatic/archweb.css b/sitestatic/archweb.css index e344b29d..67817283 100644 --- a/sitestatic/archweb.css +++ b/sitestatic/archweb.css @@ -311,6 +311,19 @@ form.general-form textarea { width: 45%; } +form.general-form div.form-field { + margin: .33em 0 1em; +} + +form.general-form div.checkbox-list { + display: inline-block; + vertical-align: top; +} + + form.general-form div.checkbox-list label { + width: auto; + } + /* archdev navbar */ #archdev-navbar { margin: 1.5em 0; @@ -1094,6 +1107,14 @@ ul.admin-actions { border-top: 1px dotted #bbb; } +.todo-stages { + margin-bottom: 1em; +} + +.todo-stages form { + display: inline; +} + .todo-warning { margin: 1em 0; padding: 4px 6px; diff --git a/sitestatic/archweb.js b/sitestatic/archweb.js index b6de30d9..f6913b82 100644 --- a/sitestatic/archweb.js +++ b/sitestatic/archweb.js @@ -188,6 +188,7 @@ function todolist_flag() { data.css_class.toLowerCase()); /* let tablesorter know the cell value has changed */ $('.results').trigger('updateCell', [$(link).closest('td')[0], false, null]); + $('#todo-advance').prop('disabled', $('a.status-link.incomplete').length > 0); }); return false; } diff --git a/templates/devel/index.html b/templates/devel/index.html index 17174ee8..990b4f47 100644 --- a/templates/devel/index.html +++ b/templates/devel/index.html @@ -148,7 +148,7 @@

Package Todo Lists

{{ todo.created|date:"Y-m-d" }} {{ todo.creator.get_full_name }} {{ todo.pkg_count }} - {{ todo.incomplete_count }} + {% if todo.current_stage.per_package %}{{ todo.incomplete_count }}{% endif %} {{ todo.kind_str }} {% empty %} diff --git a/templates/general_form.html b/templates/general_form.html index 1fa33513..8644d3d0 100644 --- a/templates/general_form.html +++ b/templates/general_form.html @@ -13,11 +13,11 @@

{{title}}

{% for field in form %} {{field.errors}} -

+

{% if field.help_text %}
{{field.help_text}}{% endif %} {{field}} {% if field.field.required %}*{% endif %} -

+
{% endfor %}

diff --git a/templates/todolists/complete_email_notification.txt b/templates/todolists/complete_email_notification.txt index e8e5a90f..3dbf44f3 100644 --- a/templates/todolists/complete_email_notification.txt +++ b/templates/todolists/complete_email_notification.txt @@ -1,4 +1,4 @@ -{% autoescape off %}The todo list "{{ todolist.name }}" is complete. +{% autoescape off %}All packages on the todo list "{{ todolist.name }}" have completed the stage "{{ stage.name }}". Use "{{ stage.caption }}" on the todo list page to proceed. Todo list information: Name: {{ todolist.name }} diff --git a/templates/todolists/email_notification.txt b/templates/todolists/email_notification.txt index e454ec79..22871c3d 100644 --- a/templates/todolists/email_notification.txt +++ b/templates/todolists/email_notification.txt @@ -1,8 +1,11 @@ -{% autoescape off %}The todo list "{{ todolist.name }}" has had the following packages added to it for which you are a maintainer: +{% autoescape off %}{% if stage %}The todo list "{{ todolist.name }}" has reached the stage "{{ stage.name }}". The following packages for which you are a maintainer need to be handled:{% else %}The todo list "{{ todolist.name }}" has had the following packages added to it for which you are a maintainer:{% endif %} {% for tpkg in todo_packages %} * {{ tpkg.repo.name|lower }}/{{ tpkg.pkgname }} ({{ tpkg.arch.name }}) - {{ tpkg.pkg.get_full_url }}{% endfor %} - +{% if stage %} +Stage information: +{{ stage.description|wordwrap:78 }} +{% endif %} Todo list information: Name: {{ todolist.name }} URL: {{ todolist.get_full_url }} diff --git a/templates/todolists/form.html b/templates/todolists/form.html new file mode 100644 index 00000000..6d8dd06a --- /dev/null +++ b/templates/todolists/form.html @@ -0,0 +1,19 @@ +{% extends "general_form.html" %} + +{% block script_block %} +{% if default_stages %} +{{ default_stages|json_script:"default-stages" }} + +{% endif %} +{% endblock %} diff --git a/templates/todolists/list.html b/templates/todolists/list.html index ef43f66b..89ef2778 100644 --- a/templates/todolists/list.html +++ b/templates/todolists/list.html @@ -40,10 +40,10 @@

Package Todo Lists

{{ list.created|date:"Y-m-d" }} {{ list.creator.get_full_name }} {{ list.pkg_count }} - {{ list.incomplete_count }} + {% if list.current_stage.per_package %}{{ list.incomplete_count }}{% endif %} {{ list.kind_str }} - {% if list.incomplete_count == 0 %}Complete - {% else %}Incomplete{% endif %} + {% if list.finished %}Complete + {% else %}{{ list.current_stage.name }}{% endif %} {% endfor %} diff --git a/templates/todolists/view.html b/templates/todolists/view.html index 4125ba44..724b545b 100644 --- a/templates/todolists/view.html +++ b/templates/todolists/view.html @@ -31,16 +31,38 @@

{{ list.kind_str | title }} Todo List {{ list.name }}

{{list.stripped_description|default:'(no description)'|urlize|linebreaks}} - {% with missing=list.missing_package_names %} - {% if missing %} -
- Warning: The following package name{{ missing|pluralize }} - {{ missing|length|pluralize:"was,were" }} not found in any stable repository and - {{ missing|length|pluralize:"is,are" }} not part of this list: - {{ missing|join:", " }} +
+ {% with stage=list.current_stage %} + {% if stage %} +

Current stage: {{ stage.name }} – {{ stage.description }}

+ {% else %} +

This todo list is complete.

+ {% endif %} + {% with missing=list.missing_package_names %} + {% if missing %} +
+ Warning: The following package name{{ missing|pluralize }} + {{ missing|length|pluralize:"was,were" }} not found in any stable repository and + {{ missing|length|pluralize:"is,are" }} not part of this list: + {{ missing|join:", " }} +
+ {% endif %} + {% endwith %} + {% if perms.todolists.change_todolist %} + {% if list.progress > 0 %} +
{% csrf_token %} + +
+ {% endif %} + {% if stage %} +
{% csrf_token %} + +
+ {% endif %} + {% endif %} + {% endwith %}
- {% endif %} - {% endwith %}

List of pkgbase values

@@ -63,8 +85,10 @@

Filter Todo List Packages

{% endif %} + {% if per_package %}
+ {% endif %}
@@ -84,7 +108,7 @@

Filter Todo List Packages

Current Version Staging Version Maintainers - Status + {% if per_package %}Status{% endif %} Last Touched By @@ -105,14 +129,16 @@

Filter Todo List Packages

{% if staging %}{% pkg_details_link staging staging.full_version %}{% endif %} {% endwith %} {{ pkg.maintainers|join:', ' }} + {% if per_package %} {% if perms.todolists.change_todolistpackage %} {{ pkg.get_status_display }} + class="status-link {{ pkg.status_css_class }}" title="Toggle completion status">{{ pkg.status_str }} {% else %} - {{ pkg.get_status_display }} + {{ pkg.status_str }} {% endif %} + {% endif %} {% if pkg.user %} {% if user.is_authenticated %} @@ -136,8 +162,8 @@

Filter Todo List Packages

$(document).ready(function() { $(".results").tablesorter({ widgets: ['zebra'], - sortList: [[2,0], [0,0]], - headers: { 6: { sorter: 'todostatus' } } + sortList: [[2,0], [0,0]]{% if per_package %}, + headers: { 6: { sorter: 'todostatus' } }{% endif %} }); }); $(document).ready(function() { diff --git a/todolists/migrations/0003_stages.py b/todolists/migrations/0003_stages.py new file mode 100644 index 00000000..04866f5b --- /dev/null +++ b/todolists/migrations/0003_stages.py @@ -0,0 +1,48 @@ +# Generated by Django 6.0.9 on 2026-11-11 21:19 + +from django.db import migrations, models + +COMPLETE = 1 + + +def migrate_status(apps, schema_editor): + '''Existing todo lists get a single "In progress" stage which packages have + completed if they were marked as complete.''' + Todolist = apps.get_model('todolists', 'Todolist') + TodolistPackage = apps.get_model('todolists', 'TodolistPackage') + + TodolistPackage.objects.filter(status=COMPLETE).update(progress=1) + Todolist.objects.update(stages=['in_progress'], progress=1) + incomplete = TodolistPackage.objects.filter( + removed__isnull=True, progress=0).values('todolist_id') + Todolist.objects.filter(id__in=incomplete).update(progress=0) + + +class Migration(migrations.Migration): + + dependencies = [ + ("todolists", "0002_todolist_kind"), + ] + + operations = [ + migrations.AddField( + model_name="todolist", + name="progress", + field=models.PositiveSmallIntegerField(default=0, editable=False), + ), + migrations.AddField( + model_name="todolist", + name="stages", + field=models.JSONField(default=list, editable=False), + ), + migrations.AddField( + model_name="todolistpackage", + name="progress", + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.RunPython(migrate_status, migrations.RunPython.noop), + migrations.RemoveField( + model_name="todolistpackage", + name="status", + ), + ] diff --git a/todolists/models.py b/todolists/models.py index c991b329..b6914451 100644 --- a/todolists/models.py +++ b/todolists/models.py @@ -1,13 +1,13 @@ from django.contrib.auth.models import User from django.contrib.sites.models import Site -from django.core.mail import EmailMessage from django.db import models -from django.db.models.signals import post_save, pre_save -from django.template import loader +from django.db.models.signals import pre_save from main.models import Arch, Package, Repo from main.utils import set_created_field +from .stages import STAGES + class Todolist(models.Model): REBUILD = 0 @@ -18,6 +18,11 @@ class Todolist(models.Model): (TASK, 'Task'), ) + DEFAULT_STAGES = { + REBUILD: ['draft', 'rebuild_staging', 'test_testing'], + TASK: ['draft', 'in_progress'], + } + slug = models.SlugField(max_length=255, unique=True) name = models.CharField(max_length=255) description = models.TextField() @@ -27,6 +32,9 @@ class Todolist(models.Model): help_text='(Rebuild for soname bumps, Task for independent tasks)') last_modified = models.DateTimeField(editable=False) raw = models.TextField(blank=True) + stages = models.JSONField(default=list, editable=False) + # number of stages completed by the todo list as a whole + progress = models.PositiveSmallIntegerField(default=0, editable=False) class Meta: get_latest_by = 'created' @@ -50,10 +58,41 @@ def packages(self): if not hasattr(self, '_packages'): self._packages = self.todolistpackage_set.filter( removed__isnull=True).select_related( - 'pkg', 'repo', 'arch', 'user__userprofile').order_by( + 'todolist', 'pkg', 'repo', 'arch', 'user__userprofile').order_by( 'pkgname', 'arch') return self._packages + @property + def stage_list(self): + return [STAGES[key] for key in self.stages] + + @property + def finished(self) -> bool: + return self.progress >= len(self.stages) + + @property + def current_stage(self): + if self.finished: + return None + return self.stage_list[self.progress] + + def per_package_reached(self) -> bool: + '''Whether a stage asking for per-package completion has been reached.''' + return any(stage.per_package for stage in self.stage_list[:self.progress + 1]) + + def incomplete_packages(self): + '''Packages that have not completed the current stage.''' + return self.todolistpackage_set.filter( + removed__isnull=True, progress__lte=self.progress) + + def can_advance(self) -> bool: + stage = self.current_stage + if stage is None: + return False + if not stage.per_package: + return True + return not self.incomplete_packages().exists() + def missing_package_names(self): '''Names from the raw package list that did not end up in the list, e.g. because they are not found in any repository.''' @@ -68,15 +107,6 @@ def kind_str(self): class TodolistPackage(models.Model): - INCOMPLETE = 0 - COMPLETE = 1 - IN_PROGRESS = 2 - STATUS_CHOICES = ( - (INCOMPLETE, 'Incomplete'), - (COMPLETE, 'Complete'), - (IN_PROGRESS, 'In-progress'), - ) - todolist = models.ForeignKey(Todolist, on_delete=models.CASCADE) pkg = models.ForeignKey(Package, null=True, on_delete=models.SET_NULL) pkgname = models.CharField(max_length=255) @@ -86,7 +116,8 @@ class TodolistPackage(models.Model): created = models.DateTimeField(editable=False) last_modified = models.DateTimeField(editable=False) removed = models.DateTimeField(null=True, blank=True) - status = models.SmallIntegerField(default=INCOMPLETE, choices=STATUS_CHOICES) + # number of todo list stages completed by this package + progress = models.PositiveSmallIntegerField(default=0) user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) comments = models.TextField(null=True, blank=True) @@ -97,36 +128,19 @@ class Meta: def __str__(self) -> str: return self.pkgname + @property + def is_complete(self) -> bool: + return self.todolist.finished or self.progress > self.todolist.progress + def status_css_class(self): - return self.get_status_display().lower().replace('-', '') + return self.status_str.lower() @property def status_str(self) -> str: - return self.STATUS_CHOICES[self.status][1] - - -def check_todolist_complete(sender, instance, **kwargs): - if instance.status == instance.INCOMPLETE: - return - - query = TodolistPackage.objects.filter(todolist=instance.todolist, status__exact=instance.INCOMPLETE) - if query.count() > 0: - return - - # Send e-mail notification - subject = "The last package on the TODO list '%s' has been completed." % instance.todolist.name - tmpl = loader.get_template('todolists/complete_email_notification.txt') - toemail = [instance.todolist.creator.email] - ctx = { - 'todolist': instance.todolist, - } - msg = EmailMessage(subject, tmpl.render(ctx), 'Arch Website Notification ', toemail) - msg.send(fail_silently=True) + return 'Complete' if self.is_complete else 'Incomplete' pre_save.connect(set_created_field, sender=Todolist, dispatch_uid="todolists.models") pre_save.connect(set_created_field, sender=TodolistPackage, dispatch_uid="todolists.models") -post_save.connect(check_todolist_complete, sender=TodolistPackage, dispatch_uid='todolist.models') - # vim: set ts=4 sw=4 et: diff --git a/todolists/stages.py b/todolists/stages.py new file mode 100644 index 00000000..8c4a9940 --- /dev/null +++ b/todolists/stages.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Stage: + key: str + name: str + description: str + caption: str + per_package: bool + + +# Ordered by the sequence in which the stages appear on a todo list +STAGES = {stage.key: stage for stage in ( + Stage('draft', 'Draft', + 'The todo list is being prepared. Packages can still be added or removed; ' + 'nothing has been rebuilt yet.', + 'Start task', per_package=False), + Stage('rebuild_staging', 'Rebuild in staging', + 'Packages are rebuilt and added to the staging repositories. Mark each ' + 'package as complete once it is in staging. When all packages are ' + 'complete, they are moved to testing.', + 'Mark as moved to testing', per_package=True), + Stage('rebuild_testing', 'Rebuild in testing', + 'Packages are rebuilt and added to the testing repositories. Mark each ' + 'package as complete once it is in testing. When all packages are ' + 'complete, they are moved to the stable repositories.', + 'Mark as moved to stable', per_package=True), + Stage('test_testing', 'Test in testing', + 'Rebuilt packages are tested in the testing repositories before they are ' + 'moved to the stable repositories.', + 'Mark as moved to stable repos', per_package=False), + Stage('in_progress', 'In progress', + 'Work on this task is in progress. Mark each package as complete when it ' + 'is done.', + 'Mark as complete', per_package=True), +)} + +# vim: set ts=4 sw=4 et: diff --git a/todolists/tests/conftest.py b/todolists/tests/conftest.py index 42485b78..8799a8cd 100644 --- a/todolists/tests/conftest.py +++ b/todolists/tests/conftest.py @@ -15,7 +15,8 @@ def todolist(user, arches, repos, package): description=DESCRIPTION, slug=SLUG, creator=user, - raw=RAW) + raw=RAW, + stages=Todolist.DEFAULT_STAGES[Todolist.REBUILD]) yield todolist todolist.delete() diff --git a/todolists/tests/test_models.py b/todolists/tests/test_models.py index 9fc27ec9..6d3406f1 100644 --- a/todolists/tests/test_models.py +++ b/todolists/tests/test_models.py @@ -1,5 +1,3 @@ -from main.models import Package -from todolists.models import TodolistPackage from todolists.tests.conftest import NAME @@ -40,19 +38,65 @@ def test_status_str(admin_user, todolist, todolistpackage): assert todolistpackage.status_str == 'Incomplete' -def test_todolist_complete(admin_user, todolist, todolistpackage, mailoutbox): - pkg = Package.objects.last() - todopkg = TodolistPackage.objects.create(pkg=pkg, pkgname=pkg.pkgname, - pkgbase=pkg.pkgbase, arch=pkg.arch, - repo=pkg.repo, user=admin_user, - todolist=todolist, - status=TodolistPackage.COMPLETE) - assert todopkg - assert len(mailoutbox) == 0 - todolistpackage.status = TodolistPackage.COMPLETE +def test_stages(todolist): + assert [stage.name for stage in todolist.stage_list] == [ + 'Draft', 'Rebuild in staging', 'Test in testing'] + assert todolist.current_stage.key == 'draft' + assert not todolist.finished + + todolist.progress = 3 + assert todolist.current_stage is None + assert todolist.finished + + +def test_per_package_reached(todolist): + assert not todolist.per_package_reached() + todolist.progress = 1 + assert todolist.per_package_reached() + todolist.progress = 2 + assert todolist.per_package_reached() + + +def test_package_complete(todolist, todolistpackage): + todolist.progress = 1 + todolist.save() + todolistpackage.progress = 1 + assert not todolistpackage.is_complete + todolistpackage.progress = 2 + assert todolistpackage.is_complete + assert todolistpackage.status_str == 'Complete' + + +def test_package_complete_finished_list(todolist, todolistpackage): + todolist.progress = 3 + todolist.save() + todolistpackage.progress = 3 + assert todolistpackage.is_complete + + +def test_can_advance(todolist, todolistpackage): + # draft does not ask for per-package completion + assert todolist.can_advance() + + todolist.progress = 1 + todolistpackage.progress = 1 + todolistpackage.save() + assert not todolist.can_advance() + + todolistpackage.progress = 2 + todolistpackage.save() + assert todolist.can_advance() + + todolist.progress = 3 + assert not todolist.can_advance() + + +def test_can_advance_ignores_removed(todolist, todolistpackage): + todolist.progress = 1 + todolistpackage.progress = 1 + todolistpackage.removed = todolistpackage.created todolistpackage.save() - assert len(mailoutbox) == 1 - todopkg.delete() + assert todolist.can_advance() def test_missing_package_names(admin_user, todolist, todolistpackage): diff --git a/todolists/tests/test_views.py b/todolists/tests/test_views.py index c97559ce..3f2db9d0 100644 --- a/todolists/tests/test_views.py +++ b/todolists/tests/test_views.py @@ -1,12 +1,22 @@ +import pytest + +from packages.models import PackageRelation from todolists.models import Todolist, TodolistPackage +@pytest.fixture +def maintainer(user, todolistpackage): + PackageRelation.objects.create(pkgbase=todolistpackage.pkgbase, user=user, + type=PackageRelation.MAINTAINER) + + def assert_create_todo(client): response = client.post('/todo/add/', { 'name': 'Foo rebuild', 'description': 'The Foo Rebuild, please read the instructions', 'raw': 'linux', 'kind': Todolist.KIND_CHOICES[0][0], # rebuild + 'stages': Todolist.DEFAULT_STAGES[Todolist.REBUILD], }, follow=True) assert response.status_code == 200 @@ -40,21 +50,23 @@ def test_flag_pkg(developer_client, arches, repos, package): assert_create_todo(developer_client) todolist = Todolist.objects.first() + response = developer_client.post(f'/todo/{todolist.slug}/advance/') + assert response.status_code == 302 package = todolist.packages().first() - assert package.status == TodolistPackage.INCOMPLETE + assert package.status_str == 'Incomplete' flag_url = f'/todo/{todolist.slug}/flag/{package.id}/' response = developer_client.get(flag_url) assert response.status_code == 302 package = todolist.packages().first() - assert package.status == TodolistPackage.COMPLETE + assert package.status_str == 'Complete' # Flag incomplete again response = developer_client.get(flag_url) assert response.status_code == 302 package = todolist.packages().first() - assert package.status == TodolistPackage.INCOMPLETE + assert package.status_str == 'Incomplete' # Test flagging from JS response = developer_client.get(flag_url, {}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') @@ -121,3 +133,263 @@ def test_detail_no_missing_packages_warning(developer_client, todolist, todolist todolist.save() response = developer_client.get(todolist.get_absolute_url()) assert 'todo-warning' not in response.content.decode() + + +def test_add_view_default_stages(developer_client): + response = developer_client.get('/todo/add/') + form = response.context['form'] + assert form.fields['stages'].initial == Todolist.DEFAULT_STAGES[Todolist.REBUILD] + # used to select the default stages when changing the kind + assert response.context['default_stages'] == Todolist.DEFAULT_STAGES + assert 'id="default-stages"' in response.content.decode() + + +def test_create_todolist_requires_stages(developer_client): + response = developer_client.post('/todo/add/', { + 'name': 'Foo', + 'description': 'Foo', + 'raw': 'linux', + 'kind': Todolist.REBUILD, + }) + assert response.status_code == 200 + assert 'stages' in response.context['form'].errors + assert not Todolist.objects.exists() + + +def test_create_todolist_custom_stages(developer_client): + response = developer_client.post('/todo/add/', { + 'name': 'Foo', + 'description': 'Foo', + 'raw': 'linux', + 'kind': Todolist.REBUILD, + 'stages': ['rebuild_testing', 'draft'], + }) + assert response.status_code == 302 + assert Todolist.objects.get().stages == ['draft', 'rebuild_testing'] + Todolist.objects.all().delete() + + +def test_edit_keeps_stages(developer_client, todolist): + response = developer_client.get(f'/todo/{todolist.slug}/edit/') + assert 'stages' not in response.context['form'].fields + + response = developer_client.post(f'/todo/{todolist.slug}/edit/', { + 'name': todolist.name, + 'description': todolist.description, + 'raw': todolist.raw, + 'kind': Todolist.REBUILD, + 'stages': ['in_progress'], + }) + assert response.status_code == 302 + todolist.refresh_from_db() + assert todolist.stages == Todolist.DEFAULT_STAGES[Todolist.REBUILD] + + +def test_create_todolist_no_email_in_draft(developer_client, arches, repos, package, user, mailoutbox): + PackageRelation.objects.create(pkgbase='linux', user=user, type=PackageRelation.MAINTAINER) + assert_create_todo(developer_client) + assert len(mailoutbox) == 0 + Todolist.objects.all().delete() + + +def test_create_todolist_email_in_per_package_stage(developer_client, arches, repos, package, user, + mailoutbox): + PackageRelation.objects.create(pkgbase='linux', user=user, type=PackageRelation.MAINTAINER) + response = developer_client.post('/todo/add/', { + 'name': 'Foo', + 'description': 'Foo', + 'raw': 'linux', + 'kind': Todolist.TASK, + 'stages': ['in_progress'], + }) + assert response.status_code == 302 + assert len(mailoutbox) == 1 + assert 'Packages added to todo list' in mailoutbox[0].subject + Todolist.objects.all().delete() + + +def test_advance(developer_client, todolist, todolistpackage, maintainer, mailoutbox): + response = developer_client.post(f'/todo/{todolist.slug}/advance/') + assert response.status_code == 302 + todolist.refresh_from_db() + todolistpackage.refresh_from_db() + assert todolist.progress == 1 + assert todolistpackage.progress == 1 + assert len(mailoutbox) == 1 + assert 'reached stage \'Rebuild in staging\'' in mailoutbox[0].subject + assert todolistpackage.pkgname in mailoutbox[0].body + + +def test_advance_no_email_for_complete_packages(developer_client, todolist, todolistpackage, maintainer, + mailoutbox): + todolistpackage.progress = 2 + todolistpackage.save() + response = developer_client.post(f'/todo/{todolist.slug}/advance/') + assert response.status_code == 302 + todolistpackage.refresh_from_db() + assert todolistpackage.progress == 2 + assert len(mailoutbox) == 0 + + +def test_advance_no_email_for_non_per_package_stage(developer_client, todolist, todolistpackage, maintainer, + mailoutbox): + todolist.progress = 1 + todolist.save() + todolistpackage.progress = 2 + todolistpackage.save() + response = developer_client.post(f'/todo/{todolist.slug}/advance/') + assert response.status_code == 302 + todolist.refresh_from_db() + assert todolist.current_stage.key == 'test_testing' + assert len(mailoutbox) == 0 + + +def test_advance_incomplete_packages(developer_client, todolist, todolistpackage): + todolist.progress = 1 + todolist.save() + todolistpackage.progress = 1 + todolistpackage.save() + response = developer_client.post(f'/todo/{todolist.slug}/advance/') + assert response.status_code == 400 + todolist.refresh_from_db() + assert todolist.progress == 1 + + +def test_advance_finished(developer_client, todolist): + todolist.progress = 3 + todolist.save() + response = developer_client.post(f'/todo/{todolist.slug}/advance/') + assert response.status_code == 400 + + +def test_advance_get(developer_client, todolist): + response = developer_client.get(f'/todo/{todolist.slug}/advance/') + assert response.status_code == 405 + + +def test_advance_permission(client, todolist): + response = client.post(f'/todo/{todolist.slug}/advance/') + assert response.status_code == 302 + todolist.refresh_from_db() + assert todolist.progress == 0 + + +def test_back(developer_client, todolist, todolistpackage, mailoutbox): + todolist.progress = 2 + todolist.save() + todolistpackage.progress = 2 + todolistpackage.save() + response = developer_client.post(f'/todo/{todolist.slug}/back/') + assert response.status_code == 302 + todolist.refresh_from_db() + todolistpackage.refresh_from_db() + assert todolist.progress == 1 + # packages keep their completion status + assert todolistpackage.progress == 2 + assert todolistpackage.is_complete + assert len(mailoutbox) == 0 + + +def test_back_first_stage(developer_client, todolist): + response = developer_client.post(f'/todo/{todolist.slug}/back/') + assert response.status_code == 400 + + +def test_flag_non_per_package_stage(developer_client, todolist, todolistpackage): + response = developer_client.get(f'/todo/{todolist.slug}/flag/{todolistpackage.id}/') + assert response.status_code == 400 + + +def test_flag_last_package_emails_owner(developer_client, todolist, todolistpackage, mailoutbox): + todolist.progress = 1 + todolist.save() + todolistpackage.progress = 1 + todolistpackage.save() + response = developer_client.get(f'/todo/{todolist.slug}/flag/{todolistpackage.id}/') + assert response.status_code == 302 + assert len(mailoutbox) == 1 + assert mailoutbox[0].to == [todolist.creator.email] + assert 'completed stage \'Rebuild in staging\'' in mailoutbox[0].subject + + +def test_flag_remaining_packages_no_email(developer_client, todolist, todolistpackage, mailoutbox): + todolist.progress = 1 + todolist.save() + todolistpackage.progress = 1 + todolistpackage.save() + other = TodolistPackage.objects.create(pkg=todolistpackage.pkg, pkgname='other', + pkgbase='other', arch=todolistpackage.arch, + repo=todolistpackage.repo, todolist=todolist, + progress=1) + response = developer_client.get(f'/todo/{todolist.slug}/flag/{todolistpackage.id}/') + assert response.status_code == 302 + assert len(mailoutbox) == 0 + other.delete() + + +def test_detail_stages(developer_client, todolist, todolistpackage): + response = developer_client.get(todolist.get_absolute_url()) + content = response.content.decode() + assert 'Current stage: Draft' in content + assert 'Start task' in content + assert 'Back to previous stage' not in content + + +def test_detail_finished(developer_client, todolist): + todolist.progress = 3 + todolist.save() + response = developer_client.get(todolist.get_absolute_url()) + content = response.content.decode() + assert 'This todo list is complete.' in content + assert 'Back to previous stage' in content + + +def test_todolist_overview_stage(developer_client, todolist): + response = developer_client.get('/todo/') + assert 'Draft' in response.content.decode() + + +def test_detail_status_per_package_stage_only(developer_client, todolist, todolistpackage): + response = developer_client.get(todolist.get_absolute_url()) + content = response.content.decode() + assert 'Status' not in content + assert 'id_incomplete' not in content + + todolist.progress = 1 + todolist.save() + response = developer_client.get(todolist.get_absolute_url()) + content = response.content.decode() + assert 'Status' in content + assert 'id_incomplete' in content + + +@pytest.mark.parametrize('url', ['/todo/', '/devel/']) +def test_incomplete_count_per_package_stage_only(developer_client, todolist, todolistpackage, url): + # the only package is complete, so the incomplete count is 0 + todolistpackage.progress = 2 + todolistpackage.save() + response = developer_client.get(url) + assert '0' not in response.content.decode() + + todolist.progress = 1 + todolist.save() + response = developer_client.get(url) + assert '0' in response.content.decode() + + +def test_dashboard_incomplete_packages(developer_client, todolist, todolistpackage, maintainer): + # nothing to do for maintainers in stages without per-package completion + response = developer_client.get('/devel/') + assert response.context['todopkgs'] == [] + + todolist.progress = 1 + todolist.save() + todolistpackage.progress = 1 + todolistpackage.save() + response = developer_client.get('/devel/') + assert response.context['todopkgs'] == [todolistpackage] + + todolistpackage.progress = 2 + todolistpackage.save() + response = developer_client.get('/devel/') + assert response.context['todopkgs'] == [] diff --git a/todolists/urls.py b/todolists/urls.py index 0a75a50a..fce08fd1 100644 --- a/todolists/urls.py +++ b/todolists/urls.py @@ -5,6 +5,8 @@ DeleteTodolist, TodolistListView, add, + advance, + back, edit, flag, list_pkgbases, @@ -25,6 +27,10 @@ permission_required('todolists.delete_todolist')(DeleteTodolist.as_view())), re_path(r'^(?P[-\w]+)/flag/(?P\d+)/$', permission_required('todolists.change_todolistpackage')(flag)), + re_path(r'^(?P[-\w]+)/advance/$', + permission_required('todolists.change_todolist')(advance)), + re_path(r'^(?P[-\w]+)/back/$', + permission_required('todolists.change_todolist')(back)), re_path(r'^(?P[-\w]+)/pkgbases/$', list_pkgbases), ] diff --git a/todolists/utils.py b/todolists/utils.py index 28628f6b..66f5c543 100644 --- a/todolists/utils.py +++ b/todolists/utils.py @@ -7,15 +7,16 @@ def todo_counts(): sql = """ -SELECT todolist_id, count(*), SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) - FROM todolists_todolistpackage - WHERE removed IS NULL - GROUP BY todolist_id +SELECT tp.todolist_id, count(*), SUM(CASE WHEN tp.progress > t.progress THEN 1 ELSE 0 END) + FROM todolists_todolistpackage tp + JOIN todolists_todolist t ON t.id = tp.todolist_id + WHERE tp.removed IS NULL + GROUP BY tp.todolist_id """ database = router.db_for_write(TodolistPackage) connection = connections[database] cursor = connection.cursor() - cursor.execute(sql, [TodolistPackage.COMPLETE]) + cursor.execute(sql) results = cursor.fetchall() return {row[0]: (row[1], row[2]) for row in results} @@ -29,13 +30,14 @@ def get_annotated_todolists(incomplete_only=False): for todolist in lists: counts = lookup.get(todolist.id, (0, 0)) todolist.pkg_count = counts[0] - todolist.complete_count = counts[1] - todolist.incomplete_count = counts[0] - counts[1] + # all packages of a finished list are complete + todolist.complete_count = counts[0] if todolist.finished else counts[1] + todolist.incomplete_count = counts[0] - todolist.complete_count if incomplete_only: - lists = [lst for lst in lists if lst.incomplete_count > 0] + lists = [lst for lst in lists if not lst.finished] else: - lists = sorted(lists, key=lambda todolist: todolist.incomplete_count == 0) + lists = sorted(lists, key=lambda todolist: todolist.finished) return lists diff --git a/todolists/views.py b/todolists/views.py index 137c5024..5703402b 100644 --- a/todolists/views.py +++ b/todolists/views.py @@ -4,11 +4,12 @@ from django import forms from django.core.mail import send_mail from django.db import transaction -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import get_object_or_404, redirect, render from django.template import loader from django.utils.timezone import now from django.views.decorators.cache import never_cache +from django.views.decorators.http import require_POST from django.views.generic import DeleteView, ListView from api.deprecation import deprecated_json_endpoint @@ -17,6 +18,7 @@ from packages.utils import PackageJSONEncoder, attach_maintainers from .models import Todolist, TodolistPackage +from .stages import STAGES from .utils import attach_staging, get_annotated_todolists @@ -24,6 +26,22 @@ class TodoListForm(forms.ModelForm): raw = forms.CharField(label='Packages', required=False, help_text='(one per line)', widget=forms.Textarea(attrs={'rows': '20', 'cols': '60'})) + stages = forms.MultipleChoiceField( + choices=[(stage.key, stage.name) for stage in STAGES.values()], + initial=Todolist.DEFAULT_STAGES[Todolist.REBUILD], + widget=forms.CheckboxSelectMultiple(attrs={'class': 'checkbox-list'}), + help_text='(cannot be changed later)') + + field_order = ('name', 'kind', 'stages', 'description', 'raw') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # stages are immutable once a todo list has been created + if self.instance.pk: + del self.fields['stages'] + + def selected_stages(self): + return [key for key in STAGES if key in self.cleaned_data['stages']] def package_names(self): return {s.strip() for s in self.cleaned_data['raw'].split("\n")} @@ -41,23 +59,60 @@ class Meta: @never_cache def flag(request, slug, pkg_id): todolist = get_object_or_404(Todolist, slug=slug) - tlpkg = get_object_or_404(TodolistPackage, id=pkg_id, removed__isnull=True) + tlpkg = get_object_or_404(TodolistPackage, id=pkg_id, todolist=todolist, + removed__isnull=True) + stage = todolist.current_stage + if stage is None or not stage.per_package: + return HttpResponseBadRequest('The current stage does not track package completion') # TODO: none of this; require absolute value on submit - if tlpkg.status == TodolistPackage.INCOMPLETE: - tlpkg.status = TodolistPackage.COMPLETE + if tlpkg.is_complete: + tlpkg.progress = todolist.progress else: - tlpkg.status = TodolistPackage.INCOMPLETE + tlpkg.progress = todolist.progress + 1 tlpkg.user = request.user - tlpkg.save(update_fields=('status', 'user', 'last_modified')) + tlpkg.save(update_fields=('progress', 'user', 'last_modified')) + if tlpkg.is_complete and todolist.can_advance(): + send_stage_complete_email(todolist, stage) if request.headers.get('x-requested-with') == 'XMLHttpRequest': data = { - 'status': tlpkg.get_status_display(), + 'status': tlpkg.status_str, 'css_class': tlpkg.status_css_class(), } return HttpResponse(json.dumps(data), content_type='application/json') return redirect(todolist) +@never_cache +@require_POST +@transaction.atomic +def advance(request, slug): + todolist = get_object_or_404(Todolist.objects.select_for_update(), slug=slug) + if not todolist.can_advance(): + return HttpResponseBadRequest('The todo list cannot proceed to the next stage') + todolist.progress += 1 + todolist.save(update_fields=('progress', 'last_modified')) + todolist.todolistpackage_set.filter( + removed__isnull=True, progress__lt=todolist.progress).update( + progress=todolist.progress) + stage = todolist.current_stage + if stage and stage.per_package: + send_todolist_emails(todolist, todolist.incomplete_packages(), stage) + return redirect(todolist) + + +@never_cache +@require_POST +@transaction.atomic +def back(request, slug): + todolist = get_object_or_404(Todolist.objects.select_for_update(), slug=slug) + if todolist.progress == 0: + return HttpResponseBadRequest('The todo list is in its first stage') + # packages keep their progress, going back does not invalidate it + todolist.progress -= 1 + todolist.save(update_fields=('progress', 'last_modified')) + return redirect(todolist) + + def view(request, slug): todolist = get_object_or_404(Todolist, slug=slug) # we don't hold onto the result, but the objects are the same here, @@ -66,10 +121,13 @@ def view(request, slug): attach_staging(todolist.packages(), todolist.pk) arches = {tp.arch for tp in todolist.packages()} repos = {tp.repo for tp in todolist.packages()} + stage = todolist.current_stage context = { 'list': todolist, 'arches': sorted(arches), 'repos': sorted(repos), + # package status is only meaningful in stages with per-package completion + 'per_package': stage is not None and stage.per_package, } return render(request, 'todolists/view.html', context) @@ -108,9 +166,10 @@ def add(request): 'title': 'Add Todo List', 'description': '', 'form': form, + 'default_stages': Todolist.DEFAULT_STAGES, 'submit_text': 'Create List' } - return render(request, 'general_form.html', page_dict) + return render(request, 'todolists/form.html', page_dict) # TODO: this calls for transaction management and async emailing @@ -133,7 +192,7 @@ def edit(request, slug): 'form': form, 'submit_text': 'Save List' } - return render(request, 'general_form.html', page_dict) + return render(request, 'todolists/form.html', page_dict) class DeleteTodolist(DeleteView): @@ -153,6 +212,7 @@ def create_todolist_packages(form, creator=None): todolist = form.save(commit=False) todolist.creator = creator todolist.slug = find_unique_slug(Todolist, todolist.name) + todolist.stages = form.selected_stages() todolist.save() else: # todo list already existed @@ -178,6 +238,7 @@ def create_todolist_packages(form, creator=None): 'pkg': package, 'pkgbase': package.pkgbase, 'repo': package.repo, + 'progress': todolist.progress, } todo_pkg, created = TodolistPackage.objects.get_or_create( todolist=todolist, @@ -190,6 +251,7 @@ def create_todolist_packages(form, creator=None): save = False if todo_pkg.removed is not None: todo_pkg.removed = None + todo_pkg.progress = todolist.progress save = True if todo_pkg.pkg != package: todo_pkg.pkg = package @@ -200,14 +262,20 @@ def create_todolist_packages(form, creator=None): return todo_pkgs -def send_todolist_emails(todo_list, new_packages): +def send_todolist_emails(todo_list, todo_packages, stage=None): '''Sends emails to package maintainers notifying them that packages have - been added to a todo list.''' + been added to a todo list, or that the todo list reached a stage which + asks for per-package completion. Nothing is sent before the first such + stage has been reached.''' + if not todo_list.per_package_reached(): + return # start by flipping the incoming list on its head: we want a list of # involved maintainers and the packages they need to be notified about. orphan_packages = [] maint_packages = {} - for todo_package in new_packages: + for todo_package in todo_packages: + if todo_package.pkg is None: + continue maints = todo_package.pkg.maintainers.values_list('email', flat=True) if not maints: orphan_packages.append(todo_package) @@ -220,15 +288,34 @@ def send_todolist_emails(todo_list, new_packages): ctx = { 'todo_packages': packages, 'todolist': todo_list, + 'stage': stage, } + if stage: + subject = f'Todo list \'{todo_list.name}\' reached stage \'{stage.name}\'' + else: + subject = f'Packages added to todo list \'{todo_list.name}\'' template = loader.get_template('todolists/email_notification.txt') - send_mail('Packages added to todo list \'%s\'' % todo_list.name, + send_mail(subject, template.render(ctx), 'Arch Website Notification ', [maint], fail_silently=True) +def send_stage_complete_email(todo_list, stage): + '''Notifies the todo list creator that all packages completed a stage.''' + ctx = { + 'todolist': todo_list, + 'stage': stage, + } + template = loader.get_template('todolists/complete_email_notification.txt') + send_mail(f'All packages on the todo list \'{todo_list.name}\' completed stage \'{stage.name}\'', + template.render(ctx), + 'Arch Website Notification ', + [todo_list.creator.email], + fail_silently=True) + + class TodoListJSONEncoder(PackageJSONEncoder): def default(self, obj): if isinstance(obj, Todolist): From 163addb1f09b5372dae674a5442f2eb6ed6cc2ef Mon Sep 17 00:00:00 2001 From: Lukas Fleischer Date: Wed, 11 Nov 2026 16:54:37 -0500 Subject: [PATCH 3/4] todolists: warn about overlapping rebuilds Rebuilds touching the same package bases interfere with each other in the staging and testing repositories. Show a warning on an active rebuild for every other active rebuild sharing package bases with it. --- templates/todolists/view.html | 8 ++++++ todolists/models.py | 19 ++++++++++++++ todolists/tests/test_models.py | 47 ++++++++++++++++++++++++++++++++++ todolists/tests/test_views.py | 15 +++++++++++ 4 files changed, 89 insertions(+) diff --git a/templates/todolists/view.html b/templates/todolists/view.html index 724b545b..2cc6b57f 100644 --- a/templates/todolists/view.html +++ b/templates/todolists/view.html @@ -48,6 +48,14 @@

{{ list.kind_str | title }} Todo List {{ list.name }}

{% endif %} {% endwith %} + {% for other, pkgbases in list.overlapping_rebuilds %} +
+ Warning: The active rebuild + {{ other.name }} + ({{ other.current_stage.name }}) shares the following package base{{ pkgbases|pluralize }} + with this todo list: {{ pkgbases|join:", " }} +
+ {% endfor %} {% if perms.todolists.change_todolist %} {% if list.progress > 0 %}
{% csrf_token %} diff --git a/todolists/models.py b/todolists/models.py index b6914451..7defaf81 100644 --- a/todolists/models.py +++ b/todolists/models.py @@ -93,6 +93,25 @@ def can_advance(self) -> bool: return True return not self.incomplete_packages().exists() + def overlapping_rebuilds(self): + '''Other active rebuild todo lists sharing package bases with this + one, if this one is an active rebuild. Returns a list of (todolist, + pkgbases) tuples.''' + if self.kind != self.REBUILD or self.finished: + return [] + pkgbases = self.todolistpackage_set.filter( + removed__isnull=True).values('pkgbase') + others = TodolistPackage.objects.filter( + removed__isnull=True, pkgbase__in=pkgbases, + todolist__kind=self.REBUILD).exclude( + todolist=self).select_related('todolist').order_by( + 'todolist__created', 'pkgbase') + overlaps = {} + for todo_pkg in others: + if not todo_pkg.todolist.finished: + overlaps.setdefault(todo_pkg.todolist, set()).add(todo_pkg.pkgbase) + return [(todolist, sorted(pkgbases)) for todolist, pkgbases in overlaps.items()] + def missing_package_names(self): '''Names from the raw package list that did not end up in the list, e.g. because they are not found in any repository.''' diff --git a/todolists/tests/test_models.py b/todolists/tests/test_models.py index 6d3406f1..26142878 100644 --- a/todolists/tests/test_models.py +++ b/todolists/tests/test_models.py @@ -1,6 +1,22 @@ +import pytest + +from todolists.models import Todolist, TodolistPackage from todolists.tests.conftest import NAME +@pytest.fixture +def other_todolist(user, todolistpackage): + other = Todolist.objects.create(name='Other rebuild', description='Other rebuild', + slug='other-rebuild', creator=user, + stages=Todolist.DEFAULT_STAGES[Todolist.REBUILD]) + TodolistPackage.objects.create(todolist=other, pkg=todolistpackage.pkg, + pkgname=todolistpackage.pkgname, + pkgbase=todolistpackage.pkgbase, + arch=todolistpackage.arch, repo=todolistpackage.repo) + yield other + other.delete() + + def test_stripped_description(todolist): todolist.description = 'Boost rebuild ' desc = todolist.stripped_description @@ -107,3 +123,34 @@ def test_missing_package_names(admin_user, todolist, todolistpackage): def test_missing_package_names_none(admin_user, todolist, todolistpackage): todolist.raw = todolistpackage.pkgname assert todolist.missing_package_names() == [] + + +def test_overlapping_rebuilds(todolist, todolistpackage, other_todolist): + assert todolist.overlapping_rebuilds() == [(other_todolist, [todolistpackage.pkgbase])] + assert other_todolist.overlapping_rebuilds() == [(todolist, [todolistpackage.pkgbase])] + + +def test_overlapping_rebuilds_finished(todolist, todolistpackage, other_todolist): + other_todolist.progress = 3 + other_todolist.save() + assert todolist.overlapping_rebuilds() == [] + # finished lists do not show the warning themselves + assert other_todolist.overlapping_rebuilds() == [] + + +def test_overlapping_rebuilds_task(todolist, todolistpackage, other_todolist): + other_todolist.kind = Todolist.TASK + other_todolist.save() + assert todolist.overlapping_rebuilds() == [] + assert other_todolist.overlapping_rebuilds() == [] + + +def test_overlapping_rebuilds_removed(todolist, todolistpackage, other_todolist): + todolistpackage.removed = todolistpackage.created + todolistpackage.save() + assert todolist.overlapping_rebuilds() == [] + assert other_todolist.overlapping_rebuilds() == [] + + +def test_overlapping_rebuilds_none(todolist, todolistpackage): + assert todolist.overlapping_rebuilds() == [] diff --git a/todolists/tests/test_views.py b/todolists/tests/test_views.py index 3f2db9d0..6d590641 100644 --- a/todolists/tests/test_views.py +++ b/todolists/tests/test_views.py @@ -393,3 +393,18 @@ def test_dashboard_incomplete_packages(developer_client, todolist, todolistpacka todolistpackage.save() response = developer_client.get('/devel/') assert response.context['todopkgs'] == [] + + +def test_detail_overlapping_rebuild_warning(developer_client, user, todolist, todolistpackage): + other = Todolist.objects.create(name='Other rebuild', description='Other rebuild', + slug='other-rebuild', creator=user, + stages=Todolist.DEFAULT_STAGES[Todolist.REBUILD]) + TodolistPackage.objects.create(todolist=other, pkg=todolistpackage.pkg, + pkgname=todolistpackage.pkgname, + pkgbase=todolistpackage.pkgbase, + arch=todolistpackage.arch, repo=todolistpackage.repo) + response = developer_client.get(todolist.get_absolute_url()) + content = response.content.decode() + assert 'The active rebuild' in content + assert other.get_absolute_url() in content + other.delete() From 4fb460768fce7ec6215037e2a80bd11ab5a90c6a Mon Sep 17 00:00:00 2001 From: Lukas Fleischer Date: Wed, 11 Nov 2026 16:40:24 -0500 Subject: [PATCH 4/4] todolists: fix todo list view for users without a profile Users without a profile, e.g. created using createsuperuser, have no time zone set, which made rendering the "Last Touched By" column fail as soon as a package had been touched. Fall back to UTC in that case. --- templates/todolists/view.html | 4 +++- todolists/tests/test_views.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/templates/todolists/view.html b/templates/todolists/view.html index 2cc6b57f..3a74c533 100644 --- a/templates/todolists/view.html +++ b/templates/todolists/view.html @@ -150,7 +150,9 @@

Filter Todo List Packages

{% if pkg.user %} {% if user.is_authenticated %} - {{ pkg.user }} ({{ pkg.last_modified|naturaltime }}) + {% with time_zone=user.userprofile.time_zone|default:"UTC" %} + {{ pkg.user }} ({{ pkg.last_modified|naturaltime }}) + {% endwith %} {% else %} {{ pkg.user }} ({{ pkg.last_modified|naturaltime }}) {% endif %} diff --git a/todolists/tests/test_views.py b/todolists/tests/test_views.py index 6d590641..d4353c20 100644 --- a/todolists/tests/test_views.py +++ b/todolists/tests/test_views.py @@ -408,3 +408,8 @@ def test_detail_overlapping_rebuild_warning(developer_client, user, todolist, to assert 'The active rebuild' in content assert other.get_absolute_url() in content other.delete() + + +def test_detail_user_without_profile(admin_client, todolist, todolistpackage): + response = admin_client.get(todolist.get_absolute_url()) + assert response.status_code == 200