Browse Source

gestion des tâches

Élie Bouttier 6 years ago
parent
commit
5ca005008e

+ 1 - 0
djadhere/settings.py

@@ -40,6 +40,7 @@ INSTALLED_APPS = [
     'services',
     'banking',
     'stocking',
+    'todo',
     'djadhere',
 
     'bootstrap4',

+ 4 - 0
djadhere/static/css/djadhere.css

@@ -0,0 +1,4 @@
+.container {
+  padding-top: 2rem;
+  padding-bottom: 2rem;
+}

+ 3 - 0
djadhere/templates/_base.html

@@ -15,6 +15,9 @@
     {% block js %}
     {% bootstrap_javascript jquery="full" %}
     {% endblock %}
+
+    {% block extrahead %}{% endblock extrahead %}
+    {% block extra_js %}{% endblock extra_js %}
   </head>
 
   <body>

+ 9 - 0
djadhere/templates/base.html

@@ -14,6 +14,8 @@
 {% endblock %}
 
 {% block body %}
+{% block pageheader %}
+  <header>
     <nav class="navbar navbar-expand-md navbar-dark bg-dark">
       <a class="navbar-brand" href="#">tetaneutral.net</a>
       <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarTop" aria-expanded="false" aria-controls="navbarTop" aria-label="Toggle navigation">
@@ -40,6 +42,11 @@
           </li>
           {% endif %}
           {% endfor %}
+          <li class="nav-item{% block todotab %}{% endblock %}">
+            <a class="nav-link" href="{% url 'todo:list-tasklists' %}">
+              <span class="glyphicon glyphicon-heart-empty"></span>&nbsp;Liste des tâches
+            </a>
+          </li>
         </ul>
         <ul class="navbar-nav">
           {% if request.user.is_staff %}
@@ -60,6 +67,8 @@
         </ul>
       </div>
     </nav>
+  </header>
+{% endblock %}
 
 {% block container %}
 	<main class="container">

+ 6 - 5
djadhere/urls.py

@@ -13,16 +13,17 @@ Including another URLconf
     1. Import the include() function: from django.conf.urls import url, include
     2. Add a URL to urlpatterns:  url(r'^blog/', include('blog.urls'))
 """
-from django.conf.urls import url, include
+from django.urls import include, path
 from django.contrib.gis import admin
 from django.conf import settings
 
 
 urlpatterns = [
-    url(r'^accounts/', include('accounts.urls')),
-    url(r'^', include('services.urls')),
-    url(r'^', include('adhesions.urls')),
-    url(r'^admin/', admin.site.urls),
+    path('accounts/', include('accounts.urls')),
+    path('todo/', include('todo.urls')),
+    path('', include('services.urls')),
+    path('', include('adhesions.urls')),
+    path('admin/', admin.site.urls),
 ]
 
 if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS:

+ 1 - 0
todo/__init__.py

@@ -0,0 +1 @@
+default_app_config = 'todo.apps.TodoConfig'

+ 11 - 0
todo/admin.py

@@ -0,0 +1,11 @@
+from django.contrib import admin
+
+from .models import TaskList, Task, TaskComment
+
+
+class TaskListAdmin(admin.ModelAdmin):
+    list_display = ('name',)
+    prepopulated_fields = {'slug': ('name',)}
+
+
+admin.site.register(TaskList, TaskListAdmin)

+ 6 - 0
todo/apps.py

@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class TodoConfig(AppConfig):
+    name = 'todo'
+    verbose_name = 'Tâches'

+ 21 - 0
todo/decorators.py

@@ -0,0 +1,21 @@
+from django.shortcuts import get_object_or_404
+from django.core.exceptions import PermissionDenied
+from django.contrib.auth.decorators import login_required
+
+from functools import wraps
+
+from .models import TaskList
+
+
+def allowed_tasklist_required(view_func):
+    def wrapped_view(request, *args, **kwargs):
+        if not request.user.is_authenticated:
+            return login_required(view_func)(request, *args, **kwargs)
+        tasklist_slug = kwargs.pop('tasklist_slug')
+        tasklist = get_object_or_404(TaskList, slug=tasklist_slug)
+        if not request.user.is_superuser \
+                and not request.user.groups.all().intersection(tasklist.groups.all()):
+            raise PermissionDenied
+        kwargs['tasklist'] = tasklist
+        return view_func(request, **kwargs)
+    return wraps(view_func)(wrapped_view)

+ 34 - 0
todo/forms.py

@@ -0,0 +1,34 @@
+from django import forms
+
+from adhesions.models import User
+from .models import Task, TaskComment
+
+
+class TaskForm(forms.ModelForm):
+    def __init__(self, *args, **kwargs):
+        tasklist = kwargs.pop('tasklist')
+        super().__init__(*args, **kwargs)
+        if tasklist.groups.exists():
+            members = User.objects.filter(groups__in=tasklist.groups.all())
+        else:
+            members = User.objects.all()
+        members = members.order_by('adhesion__id')
+        members = members.select_related('adhesion')
+        self.fields['assigned_to'].queryset = members
+        self.fields["assigned_to"].label_from_instance = lambda obj: "ADT%d %s" % (
+            obj.adhesion.id,
+            str(obj.profile),
+        )
+
+    class Meta:
+        model = Task
+        fields = ('title', 'note', 'due_date', 'assigned_to',)
+        widgets = {
+            'due_date': forms.DateInput(attrs={"type": "date"}),
+        }
+
+
+class CommentForm(forms.ModelForm):
+    class Meta:
+        model = TaskComment
+        fields = ('body',)

+ 67 - 0
todo/migrations/0001_initial.py

@@ -0,0 +1,67 @@
+# Generated by Django 2.2 on 2019-04-19 22:16
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('auth', '0011_update_proxy_permissions'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Task',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('title', models.CharField(max_length=140, verbose_name='titre')),
+                ('created_date', models.DateField(blank=True, default=django.utils.timezone.now, null=True)),
+                ('due_date', models.DateField(blank=True, null=True, verbose_name='due pour le')),
+                ('completed_date', models.DateField(blank=True, null=True)),
+                ('note', models.TextField(blank=True, null=True)),
+                ('priority', models.PositiveIntegerField(blank=True, null=True)),
+                ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_task_set', to=settings.AUTH_USER_MODEL, verbose_name='assignée à')),
+                ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_task_set', to=settings.AUTH_USER_MODEL, verbose_name='créée par')),
+            ],
+            options={
+                'verbose_name': 'tâche',
+                'verbose_name_plural': 'tâches',
+                'ordering': ['priority', 'created_date'],
+            },
+        ),
+        migrations.CreateModel(
+            name='TaskList',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=60, verbose_name='nom')),
+                ('slug', models.SlugField(unique=True)),
+                ('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='groupe')),
+            ],
+            options={
+                'verbose_name': 'liste de tâches',
+                'verbose_name_plural': 'listes de tâches',
+                'ordering': ['name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='TaskComment',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('body', models.TextField(blank=True)),
+                ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+                ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='todo.Task')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='task',
+            name='task_list',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='todo.TaskList'),
+        ),
+    ]

+ 0 - 0
todo/migrations/__init__.py


+ 82 - 0
todo/models.py

@@ -0,0 +1,82 @@
+from django.db import models
+from django.utils import timezone
+from django.conf import settings
+from django.contrib.auth.models import Group
+
+import datetime
+
+import textwrap
+
+
+class TaskList(models.Model):
+    name = models.CharField(max_length=60, verbose_name='nom')
+    slug = models.SlugField(null=False, blank=False, unique=True)
+    groups = models.ManyToManyField(Group, verbose_name='groupe', blank=True)
+
+    @property
+    def completed_task_set(self):
+        return self.task_set.filter(completed_date__isnull=False)
+
+    @property
+    def uncompleted_task_set(self):
+        return self.task_set.filter(completed_date__isnull=True)
+
+    def __str__(self):
+        return self.name
+
+    class Meta:
+        ordering = ['name']
+        verbose_name = 'liste de tâches'
+        verbose_name_plural = 'listes de tâches'
+
+
+class Task(models.Model):
+    title = models.CharField(max_length=140, verbose_name='titre')
+    task_list = models.ForeignKey(TaskList, on_delete=models.PROTECT)
+    created_date = models.DateField(default=timezone.now, blank=True, null=True)
+    due_date = models.DateField(blank=True, null=True, verbose_name='due pour le')
+    completed_date = models.DateField(blank=True, null=True)
+    created_by = models.ForeignKey(
+        settings.AUTH_USER_MODEL,
+        on_delete=models.PROTECT,
+        related_name='created_task_set',
+        verbose_name='créée par',
+    )
+    assigned_to = models.ForeignKey(
+        settings.AUTH_USER_MODEL,
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+        related_name='assigned_task_set',
+        verbose_name='assignée à',
+    )
+    note = models.TextField(blank=True, null=True)
+    priority = models.PositiveIntegerField(blank=True, null=True)
+
+    def overdue_status(self):
+        "Returns whether the Tasks's due date has passed or not."
+        if self.due_date and datetime.date.today() > self.due_date:
+            return True
+
+    def __str__(self):
+        return self.title
+
+    class Meta:
+        ordering = ['priority', 'created_date']
+        verbose_name = 'tâche'
+        verbose_name_plural = 'tâches'
+
+
+class TaskComment(models.Model):
+    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
+    task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='comments')
+    date = models.DateTimeField(default=timezone.now)
+    body = models.TextField(blank=True)
+
+    @property
+    def snippet(self):
+        body_snippet = textwrap.shorten(self.body, width=35, placeholder="...")
+        return "{author} - {snippet}...".format(author=str(self.author), snippet=body_snippet)
+
+    def __str__(self):
+        return self.snippet

+ 3 - 0
todo/templates/todo/base.html

@@ -0,0 +1,3 @@
+{% extends "base.html" %}
+
+{% block todotab %} active{% endblock %}

+ 103 - 0
todo/templates/todo/task_detail.html

@@ -0,0 +1,103 @@
+{% extends "todo/base.html" %}
+{% load bootstrap4 %}
+
+{% block content %}
+  <div class="card-deck">
+    <div class="card col-sm-8">
+      <div class="card-body">
+        <h3 class="card-title">{{ task.title }}</h3>
+        {% if task.note %}
+        <div class="card-text">{{ task.note|safe|urlize|linebreaks }}</div>
+        {% endif %}
+      </div>
+    </div>
+
+    <div class="card col-sm-4 p-0">
+      <ul class="list-group list-group-flush">
+        <li class="list-group-item">
+          <a href="{% url 'todo:edit-task' tasklist.slug task.id %}" class="btn btn-sm btn-primary">Éditer</a>
+
+          <form method="post" action="{% url "todo:toggle-task-done" tasklist.slug task.id %}" role="form" class="d-inline">
+            {% csrf_token %}
+            <div style="display:inline;">
+              <button class="btn btn-info btn-sm" type="submit" name="toggle_done">
+                {% if task.completed_date %} Marquer non terminée {% else %} Marquer terminée {% endif %}
+              </button>
+            </div>
+          </form>
+
+          {% comment %}
+          <form method="post" action="{% url "todo:delete-task" tasklist.slug task.id %}" role="form" class="d-inline">
+            {% csrf_token %}
+            <div style="display:inline;">
+              <button class="btn btn-danger btn-sm" type="submit" name="submit_delete">
+                Supprimer
+              </button>
+            </div>
+          </form>
+          {% endcomment %}
+        </li>
+        <li class="list-group-item">
+          <strong>Assignée à :</strong>
+          {% if task.assigned_to %}{{ task.assigned_to.profile }}{% else %}–{% endif %}
+        </li>
+        <li class="list-group-item">
+          <strong>Créée par :</strong> {{ task.created_by.profile }}
+        </li>
+        <li class="list-group-item">
+          <strong>Due pour le :</strong>
+          {% if task.overdue_status %}
+          <span class="text-danger">{{ task.due_date|default:"–" }}</span>
+          {% else %}
+          {{ task.due_date|default:"–" }}
+          {% endif %}
+        </li>
+        <li class="list-group-item">
+          <strong>Terminée le :</strong> {{ task.completed_date|default:"–" }}
+        </li>
+        <li class="list-group-item">
+          <strong>Dans la liste :</strong>
+          {% if task.completed_date %}
+          <a href="{% url 'todo:show-tasklist-completed' tasklist.slug %}">{{ task.task_list }}</a>
+          {% else %}
+          <a href="{% url 'todo:show-tasklist' tasklist.slug %}">{{ task.task_list }}</a>
+          {% endif %}
+        </li>
+      </ul>
+    </div>
+  </div>
+
+  <div class="mt-3">
+    <h5>Ajouter un commentaire</h5>
+    <form action="" method="post">
+      {% csrf_token %}
+      <div class="form-group">
+        <textarea name="body" rows="3" class="form-control" title="" id="id_body"></textarea>
+      </div>
+      <input class="btn btn-sm btn-primary" type="submit" name="add_comment" value="Ajouter un commentaire">
+    </form>
+  </div>
+
+  <div class="task_comments mt-4">
+    {% if task.comments.exists %}
+      <h5>Commentaires</h5>
+      {% for comment in task.comments.all %}
+      <div class="mb-3 card">
+        <div class="card-header">
+          <div class="float-left">
+            {{ comment.author }}
+          </div>
+          <span class="float-right d-inline-block text-muted">
+            {{ comment.date|date:"F d Y P" }}
+          </span>
+        </div>
+        <div class="card-body">
+          {{ comment.body|safe|urlize|linebreaks }}
+        </div>
+      </div>
+      {% endfor %}
+    {% else %}
+        <h5>Pas de commentaire</h5>
+    {% endif %}
+  </div>
+{% endblock %}

+ 32 - 0
todo/templates/todo/task_form.html

@@ -0,0 +1,32 @@
+{% extends "base.html" %}
+{% load bootstrap4 %}
+
+{% block todotab %} active{% endblock %}
+
+{% block extrahead %}
+<link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.6-rc.0/css/select2.min.css" rel="stylesheet" />
+<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.6-rc.0/js/select2.min.js"></script>
+<script type="text/javascript">
+$(document).ready(function() {
+    $('#id_assigned_to').select2();
+});
+</script>
+{% endblock %}
+
+{% block content %}
+  <h1>{% if task %}Ajouter{% else %}Modifier{% endif %} une tâche</h1>
+
+  <form action="" method="post">
+    {% csrf_token %}
+    {% bootstrap_form form %}
+    {% buttons %}
+    {% if task %}
+    {% bootstrap_button "Modifier" button_type="submit" button_class="btn-primary" %}
+    {% else %}
+    {% bootstrap_button "Ajouter" button_type="submit" button_class="btn-primary" %}
+    {% endif %}
+    {% bootstrap_button "Annuler" button_type="link" href=cancel_url %}
+    {% endbuttons %}
+  </form>
+
+{% endblock %}

+ 125 - 0
todo/templates/todo/tasklist_detail.html

@@ -0,0 +1,125 @@
+{% extends "todo/base.html" %}
+
+{% block content %}
+
+  <a href="{% url 'todo:add-task' tasklist.slug %}" class="btn btn-primary">Ajouter une tâche</a>
+  <hr />
+
+  {% if task_list %}
+      {% if completed %}
+      <h1>Tâches terminées de la liste « {{ tasklist.name }} »</h1>
+      {% else %}
+      <h1>Tâches de la liste « {{ tasklist.name }} »</h1>
+      <p><small><i>Faites glisser les tâches pour définir les priorités.</i></small></p>
+      {% endif %}
+
+      <table class="table" id="tasktable">
+        <tr class="nodrop">
+          <th>Tâche</th>
+          <th>Créé le</th>
+          {% if completed %}
+          <th>Terminée le</th>
+          {% else %}
+          <th>Due pour le</th>
+          {% endif %}
+          <th>Créé par</th>
+          <th>Assigné à</th>
+          {% comment %}<th>Mark</th{% endcomment %}
+        </tr>
+
+        {% for task in task_list %}
+          <tr id="{{ task.id }}">
+            <td>
+                <a href="{% url 'todo:show-task' tasklist.slug task.id %}">{{ task.title|truncatewords:10 }}</a>
+            </td>
+            <td>
+                {{ task.created_date|date:"m/d/Y" }}
+            </td>
+            <td>
+              <span{% if task.overdue_status %} class="text-danger"{% endif %}>
+                {{ task.due_date|date:"m/d/Y"|default:"–" }}
+              </span>
+            </td>
+            <td>
+              {{ task.created_by }}
+            </td>
+            <td>
+              {% if task.assigned_to %}{{ task.assigned_to }}{% else %}–{% endif %}
+            </td>
+            {% comment %}
+            <td>
+              <form method="post" action="{% url "todo:task_toggle_done" task.id %}" role="form">
+                {% csrf_token %}
+                <button class="btn btn-info btn-sm" type="submit" name="toggle_done">
+                  {% if view_completed %}
+                    Not Done
+                  {% else %}
+                    Done
+                  {% endif %}
+                </button>
+              </form>
+            </td>
+            {% endcomment %}
+          </tr>
+        {% endfor %}
+      </table>
+
+  {% else %}
+    <h4>Aucune tâche {% if completed %}terminée{% else %}en cours{% endif %} dans la liste « {{ tasklist.name }} ».</h4>
+  {% endif %}
+
+  {% if completed %}
+  <a href="{% url 'todo:show-tasklist' tasklist.slug %}" class="btn btn-sm btn-warning">Voir les tâches non terminées</a>
+  {% else %}
+  <a href="{% url 'todo:show-tasklist-completed' tasklist.slug %}" class="btn btn-sm btn-warning">Voir les tâches terminées</a>
+  {% endif %}
+
+{% endblock %}
+
+{% block extra_js %}
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/TableDnD/0.9.1/jquery.tablednd.js" integrity="sha256-d3rtug+Hg1GZPB7Y/yTcRixO/wlI78+2m08tosoRn7A=" crossorigin="anonymous"></script>
+
+  <script type="text/javascript">
+    function getCookie(name) {
+        var cookieValue = null;
+        if (document.cookie && document.cookie !== '') {
+            var cookies = document.cookie.split(';');
+            for (var i = 0; i < cookies.length; i++) {
+                var cookie = cookies[i].trim();
+                // Does this cookie string begin with the name we want?
+                if (cookie.substring(0, name.length + 1) === (name + '=')) {
+                    cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
+                    break;
+                }
+            }
+        }
+        return cookieValue;
+    }
+    var csrftoken = getCookie('csrftoken');
+    function order_tasks(data) {
+      // The JQuery plugin tableDnD provides a serialize() function which provides the re-ordered
+      // data in a list. We pass that list as an object ("data") to a Django view
+      // to save new priorities on each task in the list.
+      $.ajax({
+          url: "{% url 'todo:reorder-tasklist' tasklist.slug %}",
+          type: "post",
+          headers: {
+              "X-CSRFToken": csrftoken
+          },
+          data: data,
+          dataType: "json"
+      });
+      return false;
+    };
+
+    $(document).ready(function() {
+      // Initialise the task table for drag/drop re-ordering
+      $('#tasktable').tableDnD({
+        onDrop: function(table, row) {
+          order_tasks($.tableDnD.serialize());
+        }
+      });
+
+    });
+  </script>
+{% endblock extra_js %}

+ 17 - 0
todo/templates/todo/tasklist_list.html

@@ -0,0 +1,17 @@
+{% extends "todo/base.html" %}
+
+{% block content %}
+  <h1>Liste des tâches</h1>
+
+  <p>{{ task_count }} tâche{{ task_count|pluralize }} dans {{ list_count }} liste{{ list_count|pluralize }}</p>
+
+  <ul class="list-group mb-4">
+    {% for list in lists %}
+    <li class="list-group-item d-flex justify-content-between align-items-center">
+      <a href="{% url 'todo:show-tasklist' list.slug %}">{{ list.name }}</a>
+      <span class="badge badge-primary badge-pill">{{ list.uncompleted_task_set.count }}</span>
+    </li>
+    {% endfor %}
+  </ul>
+
+{% endblock %}

+ 3 - 0
todo/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 19 - 0
todo/urls.py

@@ -0,0 +1,19 @@
+from django.urls import path
+
+from . import views
+
+
+app_name = 'todo'
+
+
+urlpatterns = [
+    path('', views.tasklist_list, name='list-tasklists'),
+    path('<str:tasklist_slug>/', views.tasklist_detail, name='show-tasklist'),
+    path('<str:tasklist_slug>/completed/', views.tasklist_detail, {'completed': True}, name='show-tasklist-completed'),
+    path('<str:tasklist_slug>/reorder/', views.tasklist_reorder, name='reorder-tasklist'),
+    path('<str:tasklist_slug>/add/', views.task_form, name='add-task'),
+    path('<str:tasklist_slug>/<int:task_id>/', views.task_detail, name='show-task'),
+    path('<str:tasklist_slug>/<int:task_id>/toggle-done/', views.task_toggle_done, name='toggle-task-done'),
+    path('<str:tasklist_slug>/<int:task_id>/edit/', views.task_form, name='edit-task'),
+    path('<str:tasklist_slug>/<int:task_id>/delete/', views.task_delete, name='delete-task'),
+]

+ 137 - 0
todo/views.py

@@ -0,0 +1,137 @@
+from django.contrib.auth.decorators import login_required
+from django.shortcuts import render, redirect, get_object_or_404
+from django.core.exceptions import PermissionDenied
+from django.contrib import messages
+from django.urls import reverse
+from django.http import HttpResponse
+from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_POST
+from django.utils import timezone
+
+from .models import TaskList, Task
+from .forms import TaskForm, CommentForm
+from .decorators import allowed_tasklist_required
+
+
+@login_required
+def tasklist_list(request):
+    lists = TaskList.objects.all()
+    if not request.user.is_superuser:
+        lists = lists.filter(group__in=request.user.groups.all())
+    lists = lists.order_by('name')
+    return render(request, 'todo/tasklist_list.html', {
+        'lists': lists,
+        'task_count': Task.objects.filter(completed_date__isnull=True, task_list__in=lists).count(),
+        'list_count': lists.count(),
+    })
+
+
+@allowed_tasklist_required
+def tasklist_detail(request, tasklist, completed=False):
+    task_list = tasklist.task_set.filter(completed_date__isnull=not completed)
+    return render(request, 'todo/tasklist_detail.html', {
+        'tasklist': tasklist,
+        'task_list': task_list,
+        'completed': completed,
+    })
+
+
+@allowed_tasklist_required
+def tasklist_reorder(request, tasklist):
+    newtasklist = request.POST.getlist("tasktable[]")
+    if newtasklist:
+        # Re-prioritize each task in list
+        i = 1
+        for pk in newtasklist:
+            try:
+                task = Task.objects.get(task_list=tasklist, pk=pk)
+                task.priority = i
+                task.save()
+                i += 1
+            except Task.DoesNotExist:
+                # Can occur if task is deleted behind the scenes during re-ordering.
+                # Not easy to remove it from the UI without page refresh, but prevent crash.
+                pass
+    # All views must return an httpresponse of some kind ... without this we get
+    # error 500s in the log even though things look peachy in the browser.
+    return HttpResponse(status=201)
+
+
+@allowed_tasklist_required
+def task_form(request, tasklist, task_id=None):
+    if task_id:
+        task = get_object_or_404(Task, task_list=tasklist, pk=task_id)
+        redirect_url = reverse('todo:show-task', kwargs={'tasklist_slug': tasklist.slug, 'task_id': task.pk})
+    else:
+        task = None
+        redirect_url = reverse('todo:show-tasklist', kwargs={'tasklist_slug': tasklist.slug})
+    form = TaskForm(request.POST or None, tasklist=tasklist, instance=task)
+    if request.method == 'POST' and form.is_valid():
+        if task:
+            form.save()
+            messages.success(request, 'Tâche mise à jour avec succès.')
+        else:
+            task = form.save(commit=False)
+            task.task_list = tasklist
+            task.created_by = request.user
+            task.save()
+            messages.success(request, 'Tâche créée avec succès.')
+        return redirect(redirect_url)
+    return render(request, 'todo/task_form.html', {
+        'tasklist': tasklist,
+        'task': task,
+        'form': form,
+        'cancel_url': redirect_url,
+    })
+
+
+@allowed_tasklist_required
+def task_detail(request, tasklist, task_id):
+    task = get_object_or_404(Task, task_list=tasklist, pk=task_id)
+    form = CommentForm(request.POST or None)
+    if request.method == 'POST' and form.is_valid():
+        comment = form.save(commit=False)
+        comment.task = task
+        comment.author = request.user
+        comment.save()
+        messages.success(request, 'Commentaire ajouté avec succès.')
+        return redirect(reverse('todo:show-task', kwargs={'tasklist_slug': tasklist.slug, 'task_id': task_id}))
+    return render(request, 'todo/task_detail.html', {
+        'tasklist': tasklist,
+        'task': task,
+        'form': form,
+    })
+
+
+@allowed_tasklist_required
+def task_toggle_done(request, tasklist, task_id):
+    task = get_object_or_404(Task, task_list=tasklist, pk=task_id)
+    if task.completed_date:
+        task.completed_date = None
+        messages.success(request, "La tâche « {} » a été marquée en cours.".format(task.title))
+    else:
+        task.completed_date = timezone.now()
+        messages.success(request, "La tâche « {} » a été marquée complétée.".format(task.title))
+    task.save()
+    return redirect(reverse('todo:show-task', kwargs={'tasklist_slug': tasklist.slug, 'task_id': task_id}))
+
+
+@allowed_tasklist_required
+def task_edit(request, tasklist, task_id):
+    task = get_object_or_404(Task, task_list=tasklist, pk=task_id)
+    form = CommentForm(request.POST)
+    if form.is_valid():
+        comment = form.save(commit=False)
+        comment.save()
+        messages.success(request, "La tâche « {} » a été marquée complétée.".format(task.title))
+    return redirect(reverse('todo:show-task', kwargs={'tasklist_slug': tasklist.slug, 'task_id': task_id}))
+
+
+# TODO: are you sure?
+@require_POST
+@allowed_tasklist_required
+def task_delete(request, tasklist, task_id):
+    task = get_object_or_404(Task, task_list=tasklist, pk=task_id)
+    task.delete()
+    messages.success(request, "La tâche « {} » a été supprimée.".format(task.title))
+    return redirect(reverse('todo:show-tasklist', kwargs={'tasklist_slug': tasklist.slug}))