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 @@
@@ -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):
|