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 1d8d50f3..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,23 @@ 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; + + color: #8a6d3b; + background: #fcf8e3; + border: 1px solid #faebcc; +} + .todo-list h4 { margin-top: 0; margin-bottom: 0.4em; 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 873213c4..3a74c533 100644 --- a/templates/todolists/view.html +++ b/templates/todolists/view.html @@ -31,6 +31,47 @@

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

{{list.stripped_description|default:'(no description)'|urlize|linebreaks}} +
+ {% 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 %} + {% 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 %} + +
+ {% endif %} + {% if stage %} +
{% csrf_token %} + +
+ {% endif %} + {% endif %} + {% endwith %} +
+

List of pkgbase values

@@ -52,8 +93,10 @@

Filter Todo List Packages

{% endif %} + {% if per_package %}
+ {% endif %}
@@ -73,7 +116,7 @@

Filter Todo List Packages

Current Version Staging Version Maintainers - Status + {% if per_package %}Status{% endif %} Last Touched By @@ -94,18 +137,22 @@

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 %} - {{ 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 %} @@ -125,8 +172,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 c5328cc2..7defaf81 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,67 @@ 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 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.''' + 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''' @@ -61,15 +126,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) @@ -79,7 +135,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) @@ -90,36 +147,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 8f9fb6b9..26142878 100644 --- a/todolists/tests/test_models.py +++ b/todolists/tests/test_models.py @@ -1,8 +1,22 @@ -from main.models import Package -from todolists.models import TodolistPackage +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 @@ -40,16 +54,103 @@ 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): + 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() == [] + + +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 fa53f2f0..d4353c20 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') @@ -105,3 +117,299 @@ 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() + + +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'] == [] + + +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() + + +def test_detail_user_without_profile(admin_client, todolist, todolistpackage): + response = admin_client.get(todolist.get_absolute_url()) + assert response.status_code == 200 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):